├── .github ├── contributing.md └── pull_request_template.md ├── .gitignore ├── LICENSE ├── README.md ├── __main__.py ├── bot.py ├── cogs ├── fun │ └── __init__.py ├── guild_config │ ├── __init__.py │ ├── cache.py │ ├── commands.py │ └── prefixes.py ├── information │ ├── __init__.py │ ├── audit_logs.py │ ├── perms.py │ └── user_info.py ├── meta │ ├── __init__.py │ ├── app_commands │ │ ├── __init__.py │ │ └── reminders.py │ ├── embed.py │ ├── news.py │ ├── reminders.py │ └── sauce.py ├── moderation │ ├── __init__.py │ ├── channel.py │ ├── message │ │ ├── __init__.py │ │ ├── cog.py │ │ ├── parser.py │ │ └── tokens.py │ ├── mutes.py │ ├── new_account_gate.py │ ├── role.py │ └── standard.py ├── owner │ ├── __init__.py │ ├── badges.py │ ├── blacklist.py │ ├── eval.py │ ├── news.py │ ├── sql.py │ ├── test_shit.py │ └── update.py └── tags.py ├── pyproject.toml ├── requirements.txt ├── schema.sql └── utils ├── __init__.py ├── bases ├── __init__.py ├── autocomplete.py ├── base_cog.py ├── blacklist.py ├── command.py ├── context.py ├── errors.py ├── help.py ├── ipc.py ├── ipc_base.py ├── launcher.py └── timer.py ├── cache.py ├── checks.py ├── command_errors.py ├── converters.py ├── errorhandler.py ├── example.env ├── helpers.py ├── interactions ├── __init__.py ├── checks.py ├── command_errors.py ├── errorhandler.py └── errors.py ├── ipc_routes.py ├── jishaku └── __init__.py ├── logging.py ├── paginators.py ├── time.py └── types ├── __init__.py ├── constants.py └── exception.py /.github/contributing.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Contributing to DuckBot Rewrite 4 | 5 | First off, thanks for taking the time to contribute. It makes the library substantially better. :+1: 6 | 7 | The following is a set of guidelines for contributing to the repository. These are guidelines, not hard rules. 8 | 9 | > **Warning** 10 | > 11 | > The production version of this code can be located at [`branch:master`](https://github.com/DuckBot-Discord/DuckBot/tree/master). If you're looking to submit a bugfix you found in the bot, target [`branch:master`](https://github.com/DuckBot-Discord/DuckBot/tree/master) instead. 12 | 13 | ## Good Bug Reports 14 | 15 | Please be aware of the following things when filing bug reports. 16 | 17 | 1. Don't open duplicate issues. Please search your issue to see if it has been asked already. Duplicate issues will be closed. 18 | 2. When filing a bug about exceptions or tracebacks, please include the *complete* traceback. This will allow us to see where in the code base the error happened. 19 | 3. Make sure to provide enough information to make the issue workable. 20 | - A **summary** of your bug report. This is generally a quick sentence or two to describe the issue in human terms. 21 | - Guidance on **how to reproduce the issue**. Let us know what the steps were, how often it happens, etc. 22 | - Tell us **what you expected to happen**. That way we can meet that expectation. 23 | - Tell us **what actually happens**. What ends up happening in reality? It's not helpful to say "it fails" or "it doesn't work". Say *how* it failed, do you get an exception? Does it hang? How are the expectations different from reality? 24 | - Tell us **information about your environment**. What operating system are you running on? Are the installed requirements up-to-date? These are valuable questions and information that we use. 25 | 26 | If the bug report is missing this information then it'll take us longer to fix the issue. We will probably ask for clarification, and barring that if no response was given then the issue will be closed. 27 | 28 | ## Submitting a Pull Request 29 | 30 | Submitting a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. It would be incredibly lovely if the style is consistent to that found in the project. This project follows PEP-8 guidelines (mostly) with a column limit of 125. We strive to have everything documented so that the code is self-explainatory. 31 | 32 | ### Git Commit Guidelines 33 | 34 | - Use present tense (e.g. "Add feature" not "Added feature") 35 | - Limit all lines to 72 characters or less. 36 | - Reference issues or pull requests outside of the first line. 37 | - Please use the shorthand `#123` and not the full URL. 38 | 39 | If you do not meet any of these guidelines, don't fret. Chances are they will be fixed upon rebasing but please do try to meet them to remove some of the workload. 40 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | ## Checklist 6 | 7 | 8 | 9 | - [ ] If code changes were made then they have been tested. 10 | - [ ] I have updated the docstrings to reflect the changes, if applicable. 11 | - [ ] This PR fixes an issue. 12 | - [ ] This PR is **not** a code change. 13 | *e.g. docstrings, command parameter descriptions, fixing a typo.* 14 | - [ ] This PR adds something new (e.g. new method or parameters). 15 | - [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | .dmypy.json 111 | dmypy.json 112 | 113 | # Pyre type checker 114 | .pyre/ 115 | 116 | # .idea 117 | .idea 118 | /.idea 119 | .idea/ 120 | 121 | # Ignored 122 | ./utils/ignored/ 123 | /utils/ignored/* 124 | /utils/ignored 125 | /utils/ignored/ 126 | 127 | # vscode 128 | .vscode/ 129 | 130 | # Dead projects 131 | cogs/role_menus.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hello, I'm [DuckBot](https://top.gg/bot/788278464474120202 "top.gg/bot/788278464474120202") 💞 2 | 3 | This is DuckBot's source code. Invite me using the hyperlink above. 4 | 5 | ## Hosting locally 6 | 7 | 1. Install python 3.11 on your machine. 8 | 2. Create a `.env` file, in `/utils/.env`. 9 | 3. Put the contents of `/utils/example.env` in it and fill out the information as needed. (some fields are optional). 10 | 4. Create a PostgreSQL database and a user in it, then run the `schema.sql` file to create the required tables. 11 | 5. Install the requirements from `requirements.txt`. 12 | 6. Run the bot: `python .`. or to enable discord.py debug logs, `python . --verbose` 13 | 14 | > **Note** 15 | > Using a [virtual environment](https://docs.python.org/3/library/venv.html) is recommended. 16 | 17 | > **Warning** 18 | > lru-dict installation errors. 19 | > 20 | > the `lru_dict` requirement may need you to install the `python-dev` package on linux (use the appropriate one for 21 | > your python version), or [Microsoft Visual C++](https://web3py.readthedocs.io/en/v5/troubleshooting.html?#why-am-i-getting-visual-c-or-cython-not-installed-error) 22 | > on windows. 23 | 24 | ## Contributing 25 | 26 | Thanks for taking an interest in contributing to DuckBot! Please check out [the contributing guidelines](/.github/contributing.md)! 27 | 28 | > **Note** 29 | > Rewrite code base (not production). 30 | > 31 | > The production code of this bot can be found in [`branch:master`](https://github.com/DuckBot-Discord/DuckBot/tree/master). 32 | > If you are looking to submit a pull request to fix a bug in the current version of DuckBot, check out that branch instead! 33 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import click 3 | import logging 4 | 5 | from utils.bases.launcher import run_bot 6 | from utils import ColourFormatter 7 | 8 | 9 | @click.command() 10 | @click.option('--brief', is_flag=True, help='Brief logging output.') 11 | def run(brief): 12 | """Options to run the bot.""" 13 | 14 | handler = logging.StreamHandler() 15 | handler.setFormatter(ColourFormatter(brief=brief)) 16 | 17 | logging.basicConfig( 18 | level=logging.INFO, 19 | handlers=[handler], 20 | ) 21 | 22 | asyncio.run(run_bot()) 23 | 24 | 25 | if __name__ == '__main__': 26 | run() 27 | -------------------------------------------------------------------------------- /cogs/fun/__init__.py: -------------------------------------------------------------------------------- 1 | from utils import DuckCog 2 | 3 | 4 | class Fun(DuckCog, emoji="🤪", brief="Fun commands."): 5 | """All sorts of entertainment commands. These range from image manipulation stuff, 6 | sending random images, games, etc. Everything that is fun is here!""" 7 | 8 | ... 9 | 10 | 11 | async def setup(bot): 12 | return 13 | -------------------------------------------------------------------------------- /cogs/guild_config/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from bot import DuckBot 4 | 5 | from .prefixes import PrefixChanges 6 | from .commands import CommandConfig 7 | 8 | 9 | class GuildConfig( 10 | PrefixChanges, 11 | CommandConfig, 12 | name="Guild Config", 13 | emoji="\N{WRENCH}", 14 | brief="Configurations for the current server.", 15 | ): 16 | """Commands that allow you to configure the bot for the current server, 17 | these include things such as permissions to use specific commands, making the 18 | bot ignore channels, change the custom prefixes for this server, logging, and much more!""" 19 | 20 | 21 | async def setup(bot: DuckBot): 22 | await bot.add_cog(GuildConfig(bot)) 23 | -------------------------------------------------------------------------------- /cogs/guild_config/cache.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | # 5 | # ORIGINAL SOURCE: https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/cache.py 6 | # 7 | 8 | from __future__ import annotations 9 | 10 | import asyncio 11 | import enum 12 | import time 13 | 14 | from functools import wraps 15 | from typing import Any, Callable, Coroutine, MutableMapping, TypeVar, Protocol 16 | 17 | from lru import LRU 18 | 19 | R = TypeVar('R') 20 | 21 | 22 | # Can't use ParamSpec due to https://github.com/python/typing/discussions/946 23 | class CacheProtocol(Protocol[R]): 24 | cache: MutableMapping[str, asyncio.Task[R]] 25 | 26 | def __call__(self, *args: Any, **kwds: Any) -> asyncio.Task[R]: ... 27 | 28 | def get_key(self, *args: Any, **kwargs: Any) -> str: ... 29 | 30 | def invalidate(self, *args: Any, **kwargs: Any) -> bool: ... 31 | 32 | def invalidate_containing(self, key: str) -> None: ... 33 | 34 | def get_stats(self) -> tuple[int, int]: ... 35 | 36 | 37 | class ExpiringCache(dict): 38 | def __init__(self, seconds: float): 39 | self.__ttl: float = seconds 40 | super().__init__() 41 | 42 | def __verify_cache_integrity(self): 43 | # Have to do this in two steps... 44 | current_time = time.monotonic() 45 | to_remove = [k for (k, (_, t)) in self.items() if current_time > (t + self.__ttl)] 46 | for k in to_remove: 47 | del self[k] 48 | 49 | def __contains__(self, key: str): 50 | self.__verify_cache_integrity() 51 | return super().__contains__(key) 52 | 53 | def __getitem__(self, key: str): 54 | self.__verify_cache_integrity() 55 | return super().__getitem__(key) 56 | 57 | def __setitem__(self, key: str, value: Any): 58 | super().__setitem__(key, (value, time.monotonic())) 59 | 60 | 61 | class Strategy(enum.Enum): 62 | lru = 1 63 | raw = 2 64 | timed = 3 65 | 66 | 67 | def cache( 68 | maxsize: int = 128, 69 | strategy: Strategy = Strategy.lru, 70 | ignore_kwargs: bool = False, 71 | ) -> Callable[[Callable[..., Coroutine[Any, Any, R]]], CacheProtocol[R]]: 72 | def decorator(func: Callable[..., Coroutine[Any, Any, R]]) -> CacheProtocol[R]: 73 | if strategy is Strategy.lru: 74 | _internal_cache = LRU(maxsize) 75 | _stats = _internal_cache.get_stats 76 | elif strategy is Strategy.raw: 77 | _internal_cache = {} 78 | _stats = lambda: (0, 0) 79 | elif strategy is Strategy.timed: 80 | _internal_cache = ExpiringCache(maxsize) 81 | _stats = lambda: (0, 0) 82 | 83 | def _make_key(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: 84 | # this is a bit of a cluster fuck 85 | # we do care what 'self' parameter is when we __repr__ it 86 | def _true_repr(o): 87 | if o.__class__.__repr__ is object.__repr__: 88 | return f'<{o.__class__.__module__}.{o.__class__.__name__}>' 89 | return repr(o) 90 | 91 | key = [f'{func.__module__}.{func.__name__}'] 92 | key.extend(_true_repr(o) for o in args) 93 | if not ignore_kwargs: 94 | for k, v in kwargs.items(): 95 | # note: this only really works for this use case in particular 96 | # I want to pass asyncpg.Connection objects to the parameters 97 | # however, they use default __repr__ and I do not care what 98 | # connection is passed in, so I needed a bypass. 99 | if k == 'connection' or k == 'pool': 100 | continue 101 | 102 | key.append(_true_repr(k)) 103 | key.append(_true_repr(v)) 104 | 105 | return ':'.join(key) 106 | 107 | @wraps(func) 108 | def wrapper(*args: Any, **kwargs: Any): 109 | key = _make_key(args, kwargs) 110 | try: 111 | task = _internal_cache[key] 112 | except KeyError: 113 | _internal_cache[key] = task = asyncio.create_task(func(*args, **kwargs)) 114 | return task 115 | else: 116 | return task 117 | 118 | def _invalidate(*args: Any, **kwargs: Any) -> bool: 119 | try: 120 | del _internal_cache[_make_key(args, kwargs)] 121 | except KeyError: 122 | return False 123 | else: 124 | return True 125 | 126 | def _invalidate_containing(key: str) -> None: 127 | to_remove = [] 128 | for k in _internal_cache.keys(): 129 | if key in k: 130 | to_remove.append(k) 131 | for k in to_remove: 132 | try: 133 | del _internal_cache[k] 134 | except KeyError: 135 | continue 136 | 137 | wrapper.cache = _internal_cache # type: ignore 138 | wrapper.get_key = lambda *args, **kwargs: _make_key(args, kwargs) # type: ignore 139 | wrapper.invalidate = _invalidate # type: ignore 140 | wrapper.get_stats = _stats # type: ignore 141 | wrapper.invalidate_containing = _invalidate_containing # type: ignore 142 | return wrapper # type: ignore 143 | 144 | return decorator 145 | -------------------------------------------------------------------------------- /cogs/guild_config/prefixes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Optional, TYPE_CHECKING 4 | 5 | import discord 6 | from discord.ext import commands 7 | 8 | from utils import DuckCog, DuckGuildContext, group 9 | 10 | if TYPE_CHECKING: 11 | from bot import DuckBot 12 | 13 | 14 | class PrefixChanges(DuckCog): 15 | def __init__(self, bot: DuckBot) -> None: 16 | super().__init__(bot) 17 | 18 | @group(name='prefix', aliases=['prefixes', 'pre'], invoke_without_command=True) 19 | @commands.guild_only() 20 | async def prefix(self, ctx: DuckGuildContext, *, prefix: Optional[str] = None) -> Optional[discord.Message]: 21 | """Adds a prefix for this server (you can have up to 25 prefixes). 22 | 23 | Parameters 24 | ---------- 25 | prefix: Optional[:class:`str`] 26 | The prefix to add to the bot. If no prefix is given, 27 | the current prefixes will be shown. 28 | """ 29 | if prefix is None: 30 | prefixes = await self.bot.get_prefix(ctx.message, raw=True) 31 | embed = discord.Embed(title='Current Prefixes', description='\n'.join(prefixes)) 32 | return await ctx.send(embed=embed) 33 | 34 | if not ctx.author.guild_permissions.manage_guild: 35 | raise commands.MissingPermissions(['manage_guild']) 36 | 37 | if len(prefix) > 50: 38 | return await ctx.send('Prefixes can only be up to 50 characters long.') 39 | 40 | prefixes = await self.bot.pool.fetchval( 41 | """ 42 | INSERT INTO guilds (guild_id, prefixes) VALUES ($1, ARRAY( 43 | SELECT DISTINCT * FROM unnest(array_append($3::text[], $2::text)))) 44 | ON CONFLICT (guild_id) DO UPDATE SET prefixes = ARRAY( 45 | SELECT DISTINCT * FROM UNNEST( ARRAY_APPEND( 46 | CASE WHEN array_length(guilds.prefixes, 1) > 0 47 | THEN guilds.prefixes ELSE $3::text[] END, $2))) 48 | RETURNING guilds.prefixes 49 | """, 50 | ctx.guild.id, 51 | prefix, 52 | ctx.bot.command_prefix, 53 | ) 54 | 55 | await ctx.send(f'✅ Added prefix {prefix}') 56 | 57 | @prefix.command(name='clear', aliases=['wipe']) 58 | @commands.guild_only() 59 | @commands.has_permissions(manage_guild=True) 60 | async def prefix_clear(self, ctx: DuckGuildContext) -> Optional[discord.Message]: 61 | """Clears all prefixes from this server, restting them to default.""" 62 | await self.bot.pool.execute("UPDATE guilds SET prefixes = ARRAY[]::TEXT[] WHERE guild_id = $1", ctx.guild.id) 63 | await ctx.send('✅ Reset prefixes to the default.') 64 | 65 | @discord.utils.copy_doc(prefix) 66 | @prefix.command(name='add', aliases=['append']) 67 | @commands.guild_only() 68 | @commands.has_permissions(manage_guild=True) 69 | async def prefix_add(self, ctx: DuckGuildContext, *, prefix: str) -> Optional[discord.Message]: 70 | return await ctx.invoke(self.prefix, prefix=prefix) 71 | 72 | @prefix.command(name='remove', aliases=['delete', 'del', 'rm']) 73 | @commands.guild_only() 74 | @commands.has_permissions(manage_guild=True) 75 | async def prefix_remove(self, ctx: DuckGuildContext, *, prefix: str) -> Optional[discord.Message]: 76 | """Removes a prefix from the bots prefixes. 77 | 78 | Parameters 79 | ---------- 80 | prefix: :class:`str` 81 | The prefix to remove from the bots prefixes. 82 | """ 83 | if len(prefix) > 50: 84 | return await ctx.send('Prefixes can only be up to 50 characters long.') 85 | 86 | await self.bot.pool.execute( 87 | "UPDATE guilds SET prefixes = ARRAY_REMOVE(prefixes, $1) WHERE guild_id = $2", prefix, ctx.guild.id 88 | ) 89 | return await ctx.send(f'✅ Removed prefix {prefix}') 90 | 91 | @prefix_remove.autocomplete('prefix') # type: ignore 92 | async def prefix_remove_autocomplete(self, ctx: DuckGuildContext, value: str) -> List[str]: 93 | return list(await ctx.bot.get_prefix(ctx.message, raw=True)) 94 | -------------------------------------------------------------------------------- /cogs/information/__init__.py: -------------------------------------------------------------------------------- 1 | from .user_info import UserInfo 2 | from .perms import PermsViewer 3 | from .audit_logs import AuditLogViewer 4 | 5 | 6 | class Info(UserInfo, PermsViewer, AuditLogViewer, emoji='📜', brief="Informational commands."): 7 | """All commands that provide information about the users, channels, etc.""" 8 | 9 | 10 | async def setup(bot): 11 | await bot.add_cog(Info(bot)) 12 | -------------------------------------------------------------------------------- /cogs/information/perms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from typing import Union 5 | 6 | import discord 7 | from discord import Permissions, PermissionOverwrite, Member, Role 8 | 9 | from utils import DuckContext, DuckCog, DeleteButton, group, View 10 | from utils.types import constants 11 | from bot import DuckBot 12 | 13 | # These are just for making it look nicer, 14 | # I know it looks ugly, but I prefer it. 15 | 16 | message_perms_text = """``` 17 | {send_messages} Send Messages 18 | {manage_messages} Manage Messages 19 | {read_message_history} Read Message History 20 | {attach_files} Attach Files 21 | {embed_links} Embed Links 22 | {add_reactions} Add Reactions 23 | {external_emojis} Use External Emoji 24 | {external_stickers} Use External Stickers 25 | {send_messages_in_threads} Send Messages in Threads 26 | {send_tts_messages} Use `/tts` 27 | ```""" 28 | 29 | mod_perms_text = """``` 30 | {administrator} Administrator 31 | {kick_members} Kick Members 32 | {ban_members} Ban Members 33 | {manage_roles} Manage Roles 34 | {manage_guild} Manage Server 35 | {manage_expressions} Manage Expressions 36 | {manage_events} Manage Events 37 | {manage_threads} Manage Threads 38 | {manage_channels} Manage Channels 39 | {manage_webhooks} Manage Webhooks 40 | {manage_nicknames} Manage Nicknames 41 | {moderate_members} Timeout Members 42 | {view_guild_insights} Server Insights 43 | {view_audit_log} View Audit Log 44 | ```""" 45 | 46 | normal_perms_text = """``` 47 | {read_messages} View Channels 48 | {change_nickname} Change Own Nickname 49 | {create_public_threads} Create Public Threads 50 | {create_private_threads} Create Private Threads 51 | {mention_everyone} Mention Everyone and Roles 52 | ```""" 53 | 54 | voice_perms_text = """``` 55 | {connect} Connect 56 | {speak} Speak 57 | {stream} Stream 58 | {priority_speaker} Priority Speaker 59 | {mute_members} Mute Members 60 | {deafen_members} Deafen Members 61 | {move_members} Move Members 62 | {request_to_speak} Request to Speak 63 | {use_voice_activation} Use Voice Activation 64 | {use_embedded_activities} Use VC Games 65 | ```""" 66 | 67 | 68 | def get_type(entity: typing.Any) -> type: 69 | if isinstance(entity, SimpleOverwrite): 70 | return type(entity.entity) 71 | return type(entity) 72 | 73 | 74 | class PermsEmbed(discord.Embed): 75 | def __init__( 76 | self, 77 | entity: Union[Member, Role, SimpleOverwrite], 78 | permissions: Union[Permissions, PermissionOverwrite], 79 | channel: discord.abc.GuildChannel | None = None, 80 | ): 81 | descriptor = 'Permissions' if isinstance(permissions, discord.Permissions) else 'Overwrites' 82 | 83 | extra = "" 84 | if channel: 85 | extra = f" in {channel.mention}" 86 | 87 | super().__init__( 88 | description=f"{descriptor} for {entity.mention} {extra}", 89 | color=entity.color, 90 | ) 91 | formatted = {p: constants.SQUARE_TICKS.get(v) for p, v in permissions} 92 | self.add_field(name='Message Permissions', value=message_perms_text.format(**formatted), inline=False) 93 | self.add_field(name='Moderator Permissions', value=mod_perms_text.format(**formatted), inline=False) 94 | self.add_field(name='Normal Permissions', value=normal_perms_text.format(**formatted), inline=False) 95 | self.add_field(name='Voice Permissions', value=voice_perms_text.format(**formatted), inline=False) 96 | 97 | 98 | class SimpleOverwrite: 99 | def __init__( 100 | self, entity: typing.Union[discord.Member, discord.Role, discord.Object], overwrite: discord.PermissionOverwrite, pos 101 | ): 102 | self.entity = entity 103 | self.overwrite = overwrite 104 | self.position = pos 105 | 106 | @property 107 | def mention(self): 108 | entity = self.entity 109 | if isinstance(entity, discord.Object): 110 | return f"unknown {entity.id}" 111 | return entity.mention 112 | 113 | @property 114 | def permissions(self): 115 | return self.overwrite 116 | 117 | @property 118 | def id(self): 119 | return self.entity.id 120 | 121 | @property 122 | def name(self): 123 | return str(self.entity) 124 | 125 | @property 126 | def emoji(self): 127 | if isinstance(self.entity, discord.Role): 128 | return constants.ROLES_ICON 129 | else: 130 | return '\N{BUST IN SILHOUETTE}' 131 | 132 | @property 133 | def color(self): 134 | return getattr(self.entity, 'color', discord.Color.default()) 135 | 136 | def __str__(self): 137 | return self.name 138 | 139 | def __repr__(self): 140 | return f"" 141 | 142 | 143 | class GuildPermsViewer(View): 144 | """ 145 | A view that shows the permissions for an object. 146 | """ 147 | 148 | def __init__( 149 | self, 150 | ctx: DuckContext, 151 | ): 152 | super().__init__(bot=ctx.bot, author=ctx.author, disable_on_timeout=True) 153 | self.ctx = ctx 154 | 155 | @discord.ui.select(cls=discord.ui.RoleSelect) 156 | async def select_role(self, interaction: discord.Interaction[DuckBot], select: discord.ui.RoleSelect): 157 | role = select.values[0] 158 | embed = PermsEmbed(role, role.permissions) 159 | await interaction.response.edit_message(embed=embed, view=self) 160 | 161 | @discord.ui.button(label='Exit', style=discord.ButtonStyle.red) 162 | async def exit(self, interaction: discord.Interaction[DuckBot], button: discord.ui.Button): 163 | self.stop() 164 | await interaction.response.defer() 165 | await interaction.delete_original_response() 166 | try: 167 | await self.ctx.message.add_reaction(self.ctx.bot.done_emoji) 168 | except: 169 | pass 170 | 171 | @classmethod 172 | async def start(cls, ctx: DuckContext): 173 | """ 174 | Starts the viewer using the `ctx.guild`'s permissions. 175 | 176 | Parameters 177 | ---------- 178 | ctx: DuckContext 179 | The context to use. 180 | """ 181 | new = cls(ctx) 182 | message = await ctx.send(view=new) 183 | new.message = message 184 | new.ctx.bot.views.add(new) 185 | 186 | 187 | class OverwritesViewer(View): 188 | def __init__(self, ctx: DuckContext, overwrites: list[SimpleOverwrite]): 189 | super().__init__(bot=ctx.bot, author=ctx.author, disable_on_timeout=True) 190 | self.ctx = ctx 191 | self.overwrites = overwrites 192 | self.current_page: int = 0 193 | self.per_page = 10 194 | 195 | def update_select_options(self): 196 | range = self.overwrites[self.current_page * self.per_page : (self.current_page + 1) * self.per_page] 197 | self.select_overwrite.options = [ 198 | discord.SelectOption(label=over.name, emoji=over.emoji, value=over.position) for over in range 199 | ] 200 | 201 | @discord.ui.select(cls=discord.ui.Select) 202 | async def select_overwrite(self, interaction: discord.Interaction, select: discord.ui.Select): 203 | overwrite = self.overwrites[int(select.values[0])] 204 | embed = PermsEmbed(overwrite, overwrite.permissions) 205 | await interaction.response.edit_message(embed=embed) 206 | 207 | @discord.ui.button(label='<') 208 | async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button): 209 | self.current_page = min(len(self.overwrites), max(0, self.current_page - 1)) 210 | self.update_select_options() 211 | await interaction.response.edit_message(view=self) 212 | 213 | @discord.ui.button(label='>') 214 | async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): 215 | self.current_page = min(len(self.overwrites), max(0, self.current_page + 1)) 216 | self.update_select_options() 217 | await interaction.response.edit_message(view=self) 218 | 219 | @discord.ui.button(label='Exit', style=discord.ButtonStyle.red) 220 | async def exit(self, interaction: discord.Interaction, button: discord.ui.Button): 221 | self.stop() 222 | await interaction.response.defer() 223 | await interaction.delete_original_response() 224 | try: 225 | await self.ctx.message.add_reaction(self.ctx.bot.done_emoji) 226 | except: 227 | pass 228 | 229 | @classmethod 230 | async def start(cls, ctx: DuckContext, channel: discord.abc.GuildChannel): 231 | overwrites = [ 232 | SimpleOverwrite(entry, overwrite, pos) 233 | for pos, (entry, overwrite) in enumerate(filter(lambda x: not x[1].is_empty(), channel.overwrites.items())) 234 | ] 235 | if not overwrites: 236 | await ctx.send(f"No permission overwrites found in this channel.") 237 | return 238 | 239 | new = cls(ctx, overwrites) 240 | new.update_select_options() 241 | message = await ctx.send(view=new) 242 | new.message = message 243 | 244 | 245 | class PermsViewer(DuckCog): 246 | @group(hybrid=True, fallback='for', invoke_without_command=True) 247 | async def permissions(self, ctx: DuckContext, *, entity: discord.Role | discord.Member) -> None: 248 | """Shows the global permissions of a user or role. 249 | 250 | Parameters 251 | ---------- 252 | entity: Role | Member 253 | The user or role that will be checked. 254 | """ 255 | 256 | if isinstance(entity, discord.Role): 257 | perms = entity.permissions 258 | else: 259 | perms = entity.guild_permissions 260 | 261 | embed = PermsEmbed(entity=entity, permissions=perms) 262 | await DeleteButton.send_to(ctx, embed=embed, author=ctx.author) 263 | 264 | @permissions.command(name='in') 265 | async def permissions_in( 266 | self, ctx: DuckContext, channel: discord.abc.GuildChannel, entity: discord.Role | discord.Member 267 | ): 268 | """Shows a role or user's permissions for a channel. 269 | 270 | This shows the effective permissions of a user in a channel, which take into consideration both channel-specific (user/role) and global (role) permissions for all roles the user has. 271 | 272 | Parameters 273 | ---------- 274 | channel: GuildChannel 275 | The channel to get permission information from. 276 | entity: 277 | The user or role that will be checked. 278 | """ 279 | perms = channel.permissions_for(entity) 280 | embed = PermsEmbed(entity=entity, permissions=perms, channel=channel) 281 | await DeleteButton.send_to(ctx, embed=embed, author=ctx.author) 282 | 283 | @permissions.command(name='all') 284 | async def permissions_all(self, ctx: DuckContext, *, entity: discord.abc.GuildChannel | None): 285 | """Shows all the server or a channel's permissions and overwrites. 286 | 287 | Parameters 288 | ---------- 289 | channel: GuildChannel 290 | The channel to get permission information from. 291 | """ 292 | if entity: 293 | return await OverwritesViewer.start(ctx, entity) 294 | 295 | await GuildPermsViewer.start(ctx) 296 | -------------------------------------------------------------------------------- /cogs/meta/__init__.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from .news import News 3 | from bot import DuckBot 4 | from utils import DuckBotNotStarted 5 | from .reminders import Reminders 6 | from .app_commands import ApplicationMeta 7 | from .embed import EmbedMaker 8 | from .sauce import Sauce 9 | 10 | 11 | class Meta( 12 | News, 13 | Reminders, 14 | ApplicationMeta, 15 | EmbedMaker, 16 | Sauce, 17 | emoji="\N{INFORMATION SOURCE}", 18 | brief="Commands about the bot itself.", 19 | ): 20 | """All commands about the bot itself. Such as news, reminders, information about the bot, etc.""" 21 | 22 | @discord.utils.cached_property 23 | def brief(self): 24 | if not self.bot.user: 25 | raise DuckBotNotStarted('Somehow, the bot has not logged in yet') 26 | return f"Commands related to {self.bot.user.name}" 27 | 28 | 29 | async def setup(bot: DuckBot): 30 | await bot.add_cog(Meta(bot)) 31 | -------------------------------------------------------------------------------- /cogs/meta/app_commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .reminders import ApplicationReminders 2 | 3 | 4 | class ApplicationMeta(ApplicationReminders): 5 | """Application commands of meta cog.""" 6 | -------------------------------------------------------------------------------- /cogs/meta/app_commands/reminders.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import discord 4 | from discord import app_commands 5 | 6 | from bot import DuckBot 7 | from utils import DuckCog, UserFriendlyTime, TimerNotFound, shorten, human_timedelta 8 | 9 | 10 | class ApplicationReminders(DuckCog): 11 | """Reminds the user of something""" 12 | 13 | slash_reminder = app_commands.Group(name='reminder', description='Reminds the user of something') 14 | 15 | @slash_reminder.command(name='add') 16 | async def slash_remind_add( 17 | self, 18 | interaction: discord.Interaction[DuckBot], 19 | when: UserFriendlyTime(default=False), # type: ignore 20 | what: str, 21 | ) -> None: 22 | """Reminds you of something in the future. 23 | 24 | Parameters 25 | ---------- 26 | when: UserFriendlyTime 27 | When should I remind you? E.g. "Tomorrow", "1 day", "next Monday" 28 | what: str 29 | A description or note about this reminder. 30 | """ 31 | bot: DuckBot = interaction.client 32 | 33 | if when.arg: 34 | await interaction.response.send_message("Could not parse time", ephemeral=True) 35 | return 36 | 37 | await interaction.response.defer() 38 | original = await interaction.original_response() 39 | 40 | await bot.create_timer( 41 | when.dt, 42 | 'reminder', 43 | interaction.user.id, 44 | interaction.channel_id, 45 | what, 46 | message_id=original.id, 47 | precise=False, 48 | ) 49 | await interaction.followup.send(f"Alright, {discord.utils.format_dt(when.dt, 'R')}: {what}") 50 | 51 | @slash_reminder.command(name='delete') 52 | @app_commands.describe(id='The ID of the reminder you want to delete.') 53 | async def slash_remind_delete(self, interaction: discord.Interaction[DuckBot], id: int) -> None: 54 | """Deletes one fo your reminders.""" 55 | await interaction.response.defer(ephemeral=True) 56 | bot: DuckBot = interaction.client 57 | try: 58 | timer = await bot.get_timer(id) 59 | if timer.event != 'reminder': 60 | raise TimerNotFound(timer.id) 61 | if timer.args[0] != interaction.user.id: 62 | raise TimerNotFound(timer.id) 63 | await timer.delete(bot) 64 | await interaction.followup.send( 65 | shorten(f'{bot.done_emoji} Okay, I deleted reminder with ID {timer.id}: {timer.args[2]}') 66 | ) 67 | except TimerNotFound as error: 68 | await interaction.followup.send(f"I couldn't find a reminder with ID {error.id}.") 69 | 70 | @slash_remind_delete.autocomplete('id') 71 | async def autocomplete_slash_remind_delete_id( 72 | self, interaction: discord.Interaction, current: str 73 | ) -> list[app_commands.Choice[int]]: 74 | if current.isdigit(): 75 | timers = await self.bot.pool.fetch( 76 | """ 77 | SELECT id, expires, (extra->'args'->2) AS reason FROM timers 78 | WHERE event = 'reminder' AND (extra->'args'->0)::bigint = $1 79 | ORDER BY similarity(id::TEXT, $2) DESC, expires LIMIT 25 80 | """, 81 | interaction.user.id, 82 | current, 83 | ) 84 | elif current: 85 | timers = await self.bot.pool.fetch( 86 | """ 87 | SELECT id, expires, (extra->'args'->2) AS reason FROM timers 88 | WHERE event = 'reminder' AND (extra->'args'->0)::bigint = $1 89 | ORDER BY similarity(reason, $2) DESC, expires LIMIT 25 90 | """, 91 | interaction.user.id, 92 | current, 93 | ) 94 | else: 95 | 96 | timers = await self.bot.pool.fetch( 97 | """ 98 | SELECT id, expires, (extra->'args'->2) AS reason FROM timers 99 | WHERE event = 'reminder' AND (extra->'args'->0)::bigint = $1 100 | ORDER BY expires LIMIT 25 101 | """, 102 | interaction.user.id, 103 | ) 104 | 105 | return [ 106 | app_commands.Choice( 107 | name=shorten(f"id: {t['id']} — in {human_timedelta(t['expires'], brief=True)} — {t['reason']}", length=100), 108 | value=t['id'], 109 | ) 110 | for t in timers 111 | ] 112 | 113 | @slash_reminder.command(name='list') 114 | async def slash_remind_list(self, interaction: discord.Interaction[DuckBot]) -> None: 115 | """Lists all of your reminders.""" 116 | bot: DuckBot = interaction.client 117 | 118 | await interaction.response.defer(ephemeral=True) 119 | 120 | timers = await bot.pool.fetch( 121 | """ 122 | SELECT id, expires, (extra->'args'->2) AS reason FROM timers 123 | WHERE event = 'reminder' AND (extra->'args'->0)::bigint = $1 124 | ORDER BY expires 125 | """, 126 | interaction.user.id, 127 | ) 128 | 129 | if not timers: 130 | await interaction.followup.send("You have no upcoming reminders.") 131 | return 132 | 133 | embed = discord.Embed(title="Upcoming reminders", color=discord.Color.blurple()) 134 | embed.set_author(name=interaction.user.display_name, icon_url=interaction.user.display_avatar.url) 135 | 136 | for index, (r_id, expires, reason) in enumerate(timers): 137 | if index > 9: 138 | embed.set_footer(text=f"(... and {len(timers) - index} more)") 139 | break 140 | 141 | try: 142 | relative = discord.utils.format_dt(expires, 'R') 143 | except Exception as e: 144 | relative = 'in a long time...' 145 | logging.debug(f'Failed to format relative time: {expires} {repr(expires)}', exc_info=e) 146 | 147 | name = f"{r_id}: {relative}" 148 | value = reason if len(reason) < 1024 else reason[:1021] + '...' 149 | 150 | if (len(embed) + len(name) + len(value)) > 5900: 151 | embed.set_footer(text=f"(... and {len(timers) - index} more)") 152 | break 153 | 154 | embed.add_field(name=name, value=value, inline=False) 155 | else: 156 | embed.set_footer(text=f"(Showing all {len(timers)} reminders)") 157 | 158 | await interaction.followup.send(embed=embed) 159 | -------------------------------------------------------------------------------- /cogs/meta/embed.py: -------------------------------------------------------------------------------- 1 | import re 2 | import typing 3 | import discord 4 | from discord.ext import commands 5 | 6 | from utils import DuckContext, DuckCog, command, FlagConverter # for `--inline` instead of `--inline yes/no` 7 | from cogs.tags import TagName 8 | 9 | try: 10 | from utils.ignored import HORRIBLE_HELP_EMBED # type: ignore 11 | except ImportError: 12 | HORRIBLE_HELP_EMBED = discord.Embed(title='No information available...') 13 | 14 | __all__ = ('EmbedMaker', 'EmbedFlags') 15 | 16 | 17 | def strip_codeblock(content): 18 | """Automatically removes code blocks from the code.""" 19 | # remove ```py\n``` 20 | if content.startswith('```') and content.endswith('```'): 21 | return content.strip('```') 22 | 23 | # remove `foo` 24 | return content.strip('` \n') 25 | 26 | 27 | def verify_link(argument: str) -> str: 28 | link = re.fullmatch('http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|%[0-9a-fA-F][0-9a-fA-F])+', argument) 29 | if not link: 30 | raise commands.BadArgument('Invalid URL provided.') 31 | return link.string 32 | 33 | 34 | class FieldFlags(FlagConverter, prefix='--', delimiter='', case_insensitive=True): 35 | name: str 36 | value: str 37 | inline: bool = True 38 | 39 | 40 | class FooterFlags(commands.FlagConverter, prefix='--', delimiter='', case_insensitive=True): 41 | text: str 42 | icon: str = commands.flag(converter=verify_link, default=None) 43 | 44 | 45 | class AuthorFlags(commands.FlagConverter, prefix='--', delimiter='', case_insensitive=True): 46 | name: str 47 | icon: str = commands.flag(converter=verify_link, default=None) 48 | url: str = commands.flag(converter=verify_link, default=None) 49 | 50 | 51 | class EmbedFlags(commands.FlagConverter, prefix='--', delimiter='', case_insensitive=True): 52 | @classmethod 53 | async def convert(cls, ctx: DuckContext, argument: str): 54 | argument = strip_codeblock(argument).replace(' —', ' --') 55 | # Here we strip the code block if any and replace the iOS dash with 56 | # a regular double-dash for ease of use. 57 | return await super().convert(ctx, argument) 58 | 59 | title: typing.Optional[str] = commands.flag(default=None) 60 | description: typing.Optional[str] = commands.flag(default=None) 61 | color: typing.Optional[discord.Color] = commands.flag(default=None) 62 | field: typing.List[FieldFlags] = commands.flag(default=None) 63 | footer: typing.Optional[FooterFlags] = commands.flag(default=None) 64 | image: str = commands.flag(converter=verify_link, default=None) 65 | author: typing.Optional[AuthorFlags] = commands.flag(default=None) 66 | thumbnail: str = commands.flag(converter=verify_link, default=None) 67 | save: typing.Optional[TagName] = commands.flag(default=None) 68 | 69 | 70 | class EmbedMaker(DuckCog): 71 | @command(brief='Sends an embed using flags') 72 | async def embed(self, ctx: DuckContext, *, flags: typing.Union[typing.Literal['--help'], EmbedFlags]): 73 | """Sends an embed using flags. 74 | Please see ``embed --help`` for 75 | usage information. 76 | 77 | Parameters 78 | ---------- 79 | flags: EmbedFlags 80 | The flags to use. 81 | """ 82 | 83 | if flags == '--help': 84 | return await ctx.send(embed=HORRIBLE_HELP_EMBED) 85 | 86 | embed = discord.Embed(title=flags.title, description=flags.description, colour=flags.color) 87 | 88 | if flags.field and len(flags.field) > 25: 89 | raise commands.BadArgument('You can only have up to 25 fields!') 90 | 91 | for f in flags.field or []: 92 | embed.add_field(name=f.name, value=f.value, inline=f.inline) 93 | 94 | if flags.thumbnail: 95 | embed.set_thumbnail(url=flags.thumbnail) 96 | 97 | if flags.image: 98 | embed.set_image(url=flags.image) 99 | 100 | if flags.author: 101 | embed.set_author(name=flags.author.name, url=flags.author.url, icon_url=flags.author.icon) 102 | 103 | if flags.footer: 104 | embed.set_footer(text=flags.footer.text, icon_url=flags.footer.icon or None) 105 | 106 | if not embed: 107 | raise commands.BadArgument('You must pass at least one of the necessary (marked with `*`) flags!') 108 | if len(embed) > 6000: 109 | raise commands.BadArgument('The embed is too big! (too much text!) Max length is 6000 characters.') 110 | if not flags.save: 111 | try: 112 | await ctx.channel.send(embed=embed) 113 | except discord.HTTPException as e: 114 | raise commands.BadArgument(f'Failed to send the embed! {type(e).__name__}: {e.text}`') 115 | except Exception as e: 116 | raise commands.BadArgument(f'An unexpected error occurred: {type(e).__name__}: {e}') 117 | else: 118 | query = """ 119 | SELECT EXISTS ( 120 | SELECT * FROM tags 121 | WHERE name = $1 122 | AND guild_id = $2 123 | AND owner_id = $3 124 | ) 125 | """ 126 | confirm = await ctx.bot.pool.fetchval(query, flags.save, ctx.guild.id, ctx.author.id) 127 | if confirm is True: 128 | confirm = await ctx.confirm( 129 | f"{ctx.author.mention} do you want to add this embed to " 130 | f"tag {flags.save!r}\n_This prompt will time out in 3 minutes, " 131 | f"so take your time_", 132 | embed=embed, 133 | timeout=180, 134 | ) 135 | if confirm is True: 136 | query = """ 137 | with upsert as ( 138 | UPDATE tags 139 | SET embed = $1 140 | WHERE name = $2 141 | AND guild_id = $3 142 | AND owner_id = $4 143 | RETURNING * 144 | ) 145 | SELECT EXISTS ( SELECT * FROM upsert ) 146 | """ 147 | added = await ctx.bot.pool.fetchval(query, embed.to_dict(), flags.save, ctx.guild.id, ctx.author.id) 148 | if added is True: 149 | await ctx.send(f'Added embed to tag {flags.save!r}!') 150 | else: 151 | await ctx.send(f'Could not edit tag. Are you sure it exists and you own it?') 152 | elif confirm is False: 153 | await ctx.send(f'Cancelled!') 154 | else: 155 | await ctx.send(f'Could not find tag {flags.save!r}. Are you sure it exists and you own it?') 156 | -------------------------------------------------------------------------------- /cogs/meta/news.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import typing 5 | from typing import TYPE_CHECKING, List, Optional, Tuple, Type, TypeVar 6 | 7 | import asyncpg 8 | import cachetools 9 | import discord 10 | from discord.ext import commands 11 | 12 | from bot import DuckBot 13 | from utils import DuckCog, DuckContext, View, format_date, command 14 | 15 | NVT = TypeVar('NVT', bound='NewsViewer') 16 | 17 | 18 | T = TypeVar('T') 19 | 20 | fm_dt = discord.utils.format_dt 21 | 22 | 23 | class Page(typing.NamedTuple): 24 | """Represents a page of news.""" 25 | 26 | news_id: int 27 | title: str 28 | content: str 29 | author_id: int 30 | 31 | 32 | class NewsFeed: 33 | """ 34 | Represents a news feed that the user can navigate through. 35 | 36 | Attributes 37 | ---------- 38 | news: List[:class:`Page`] 39 | A list of news pages. 40 | max_pages: :class:`int` 41 | The maximum number of pages in the feed. 42 | """ 43 | 44 | __slots__: Tuple[str, ...] = ( 45 | 'news', 46 | 'max_pages', 47 | '_current_page', 48 | ) 49 | 50 | def __init__(self, news: List[asyncpg.Record]) -> None: 51 | self.news: List[Page] = [Page(**n) for n in news] 52 | self.max_pages = len(news) 53 | self._current_page = 0 54 | 55 | def advance(self) -> None: 56 | """Advance to the next page.""" 57 | self._current_page += 1 58 | if self._current_page >= self.max_pages: 59 | self._current_page = 0 60 | 61 | def go_back(self) -> None: 62 | """Go back to the previous page.""" 63 | self._current_page -= 1 64 | if self._current_page < 0: 65 | self._current_page = self.max_pages - 1 66 | 67 | @property 68 | def previous(self) -> Page: 69 | """Get the previous page.""" 70 | number = self._current_page - 1 if self._current_page > 0 else self.max_pages - 1 71 | return self.news[number] 72 | 73 | @property 74 | def current(self) -> Page: 75 | """Get the current page""" 76 | return self.news[self._current_page] 77 | 78 | @property 79 | def next(self) -> Page: 80 | """Get the next page""" 81 | number = self._current_page + 1 if self._current_page + 1 < self.max_pages else 0 82 | return self.news[number] 83 | 84 | @property 85 | def current_index(self): 86 | """Get the current index of the paginator.""" 87 | return self._current_page 88 | 89 | 90 | class NewsViewer(View): 91 | """The news viewer View. 92 | 93 | This class implements the functionality of the news viewer, 94 | allowing the user to navigate through the news feed. 95 | 96 | Attributes 97 | ---------- 98 | news: :class:`NewsFeed` 99 | The news feed. 100 | """ 101 | 102 | if TYPE_CHECKING: 103 | message: discord.Message 104 | ctx: Optional[DuckContext] 105 | 106 | def __init__(self, obj: typing.Union[DuckContext, discord.Interaction[DuckBot]], news: List[asyncpg.Record]): 107 | if isinstance(obj, DuckContext): 108 | self.author = obj.author 109 | self.bot: DuckBot = obj.bot 110 | self.ctx = obj 111 | 112 | else: 113 | self.ctx = None 114 | self.author = obj.user 115 | self.bot: DuckBot = obj.client 116 | 117 | super().__init__(bot=self.bot, author=self.author) 118 | self.news = NewsFeed(news) 119 | 120 | @cachetools.cached(cachetools.LRUCache(maxsize=10)) 121 | def get_embed(self, page: Page) -> discord.Embed: 122 | """:class:`discord.Embed`: Used to get the embed for the current page.""" 123 | embed = discord.Embed( 124 | title=f"\N{NEWSPAPER} {page.title}", 125 | colour=self.bot.colour, 126 | description=page.content, 127 | timestamp=discord.utils.snowflake_time(page.news_id), 128 | ) 129 | 130 | author = self.bot.get_user(page.author_id) 131 | if author: 132 | embed.set_footer(text=f"Authored by {author}", icon_url=author.display_avatar.url) 133 | 134 | return embed 135 | 136 | @staticmethod 137 | def format_snowflake(snowflake: int) -> str: 138 | """:class:`str`: Used to format a snowflake.""" 139 | date = discord.utils.snowflake_time(snowflake) 140 | return format_date(date) 141 | 142 | @discord.ui.button(style=discord.ButtonStyle.blurple, label='\u226a') 143 | async def previous(self, interaction: discord.Interaction[DuckBot], button: discord.ui.Button) -> None: 144 | """Used to go back to the previous page. 145 | 146 | Parameters 147 | ---------- 148 | button: :class:`discord.ui.Button` 149 | The button that was pressed. 150 | interaction: :class:`discord.Interaction` 151 | The interaction that was created. 152 | """ 153 | self.news.advance() 154 | page = self.news.current 155 | self.update_labels() 156 | await interaction.response.edit_message(embed=self.get_embed(page), view=self) 157 | 158 | @discord.ui.button(style=discord.ButtonStyle.red) 159 | async def current(self, interaction: discord.Interaction[DuckBot], button: discord.ui.Button) -> None: 160 | """Used to stop the news viewer. 161 | 162 | Parameters 163 | ---------- 164 | button: :class:`discord.ui.Button` 165 | The button that was pressed. 166 | interaction: :class:`discord.Interaction` 167 | The interaction that was created. 168 | """ 169 | self.stop() 170 | await self.message.delete() 171 | 172 | if self.ctx and isinstance(self.ctx, commands.Context): 173 | with contextlib.suppress(discord.HTTPException): 174 | await self.ctx.message.add_reaction(self.bot.done_emoji) 175 | 176 | @discord.ui.button(style=discord.ButtonStyle.blurple, label='\u226b') 177 | async def next(self, interaction: discord.Interaction[DuckBot], button: discord.ui.Button): 178 | """Used to go to the next page. 179 | 180 | Parameters 181 | ---------- 182 | button: :class:`discord.ui.Button` 183 | The button that was pressed. 184 | interaction: :class:`discord.Interaction` 185 | The interaction that was created. 186 | """ 187 | self.news.go_back() 188 | page = self.news.current 189 | self.update_labels() 190 | await interaction.response.edit_message(embed=self.get_embed(page), view=self) 191 | 192 | def update_labels(self): 193 | """Used to update the internal cache of the view, it will update the labels of the buttons.""" 194 | previous_page_num = self.news.max_pages - self.news.news.index(self.news.previous) 195 | self.next.disabled = previous_page_num == 1 196 | 197 | self.current.label = str(self.news.max_pages - self.news.current_index) 198 | 199 | next_page_num = self.news.max_pages - self.news.news.index(self.news.next) 200 | self.previous.disabled = next_page_num == self.news.max_pages 201 | 202 | @classmethod 203 | async def start(cls: Type[NVT], ctx: DuckContext, news: List[asyncpg.Record]) -> NVT: 204 | """Used to start the view and build internal cache. 205 | 206 | Parameters 207 | ---------- 208 | ctx: :class:`DuckContext` 209 | The context of the command. 210 | news: :class:`List[Dict[:class:`str`, Any]]` 211 | The news feed. 212 | 213 | Returns 214 | ------- 215 | :class:`NewsViewer` 216 | The news viewer after it has finished. 217 | """ 218 | new = cls(ctx, news) 219 | new.update_labels() 220 | new.message = await ctx.send(embed=new.get_embed(new.news.current), view=new) 221 | new.bot.views.add(new) 222 | await new.wait() 223 | return new 224 | 225 | 226 | class News(DuckCog): 227 | @command(invoke_without_command=True, hybrid=True) 228 | async def news(self, ctx: DuckContext): 229 | """See what's new on DuckBot!""" 230 | news = await ctx.bot.pool.fetch("SELECT * FROM news ORDER BY news_id DESC") 231 | if not news: 232 | return await ctx.send("No news has been posted yet.") 233 | 234 | await NewsViewer.start(ctx, news) 235 | -------------------------------------------------------------------------------- /cogs/meta/reminders.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import datetime 5 | from typing import Optional, Union 6 | 7 | import discord 8 | from discord.ext import commands 9 | 10 | from utils import DuckCog, group, DuckContext, UserFriendlyTime, TimerNotFound, Timer, shorten 11 | 12 | log = logging.getLogger('DuckBot.cogs.meta.reminders') 13 | 14 | 15 | class JumpView(discord.ui.View): 16 | def __init__(self, jump_url: str, *, label: Optional[str] = None): 17 | super().__init__(timeout=1) 18 | self.add_item(discord.ui.Button(label=label or 'Go to message', url=jump_url)) 19 | 20 | 21 | class Reminders(DuckCog): 22 | """Used to create and manage reminders.""" 23 | 24 | @group(name='remind', aliases=['remindme', 'reminder'], invoke_without_command=True) 25 | async def remindme(self, ctx: DuckContext, *, when: UserFriendlyTime(commands.clean_content, default='...')) -> None: # type: ignore 26 | """Reminds you of something in the future. 27 | 28 | Parameters 29 | ---------- 30 | when : `UserFriendlyTime` 31 | When and for what to remind you for. Which can either be a date (YYYY-MM-DD) or a human-readable time, like: 32 | 33 | - "next thursday at 3pm do something funny" 34 | - "do the dishes tomorrow" 35 | - "in 3 days do the thing" 36 | - "2d unmute someone" 37 | 38 | Times are in UTC. 39 | """ 40 | await self.bot.create_timer( 41 | when.dt, 'reminder', ctx.author.id, ctx.channel.id, when.arg, message_id=ctx.message.id, precise=False 42 | ) 43 | await ctx.send(f"Alright {ctx.author.mention}, {discord.utils.format_dt(when.dt, 'R')}: {when.arg}") 44 | 45 | # noinspection PyShadowingBuiltins 46 | @remindme.command(name='delete', alias=['remove']) 47 | async def remindme_delete(self, ctx: DuckContext, id: int) -> None: 48 | """Deletes a reminder. 49 | 50 | Parameters 51 | ---------- 52 | id : `int` 53 | The ID of the reminder to delete. 54 | """ 55 | try: 56 | timer = await self.bot.get_timer(id) 57 | if timer.event != 'reminder': 58 | raise TimerNotFound(timer.id) 59 | if timer.args[0] != ctx.author.id: 60 | raise TimerNotFound(timer.id) 61 | await timer.delete(self.bot) 62 | await ctx.send(shorten(f'{self.bot.done_emoji} Okay, I deleted reminder with ID {timer.id}: {timer.args[2]}')) 63 | 64 | except TimerNotFound as error: 65 | await ctx.send(f"I couldn't find a reminder with ID {error.id}.") 66 | 67 | @remindme.command(name='list') 68 | async def remindme_list(self, ctx: DuckContext) -> None: 69 | """Lists all your upcoming reminders.""" 70 | 71 | timers = await self.bot.pool.fetch( 72 | """ 73 | SELECT id, expires, (extra->'args'->2) AS reason FROM timers 74 | WHERE event = 'reminder' AND (extra->'args'->0)::bigint = $1 75 | ORDER BY expires 76 | """, 77 | ctx.author.id, 78 | ) 79 | 80 | if not timers: 81 | await ctx.send("You have no upcoming reminders.") 82 | return 83 | 84 | embed = discord.Embed(title="Upcoming reminders", color=discord.Color.blurple()) 85 | embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url) 86 | 87 | for index, (r_id, expires, reason) in enumerate(timers): 88 | if index > 9: 89 | embed.set_footer(text=f"(And {len(timers) - index} more)") 90 | break 91 | 92 | name = f"{r_id} - {discord.utils.format_dt(expires, 'R')}" 93 | value = reason if len(reason) < 1024 else reason[:1021] + '...' 94 | 95 | if (len(embed) + len(name) + len(value)) > 5900: 96 | embed.set_footer(text=f"(And {len(timers) - index} more)") 97 | break 98 | 99 | embed.add_field(name=name, value=value, inline=False) 100 | 101 | await ctx.send(embed=embed) 102 | 103 | @commands.Cog.listener('on_reminder_timer_complete') 104 | async def reminder_dispatch(self, timer: Timer) -> None: 105 | await self.bot.wait_until_ready() 106 | 107 | user_id, channel_id, user_input = timer.args 108 | 109 | channel: Union[discord.TextChannel, discord.Thread] = self.bot.get_channel(channel_id) # type: ignore 110 | if channel is None: 111 | return log.warning('Discarding channel %s as it\'s not found in cache.', channel_id) 112 | 113 | guild_id = channel.guild.id if isinstance(channel, (discord.TextChannel, discord.Thread)) else '@me' 114 | 115 | # We need to format_dt in utc so the user 116 | # can see the time in their local timezone. If not 117 | # the user will see the timestamp as 5 hours ahead. 118 | aware = timer.created_at.replace(tzinfo=datetime.timezone.utc) 119 | msg = f'<@{user_id}>, {discord.utils.format_dt(aware, "R")}: {user_input}' 120 | 121 | view = discord.utils.MISSING 122 | if message_id := timer.kwargs.get('message_id'): 123 | jump_url = f'https://discordapp.com/channels/{guild_id}/{channel_id}/{message_id}' 124 | view = JumpView(jump_url) 125 | 126 | mentions = discord.AllowedMentions(users=True, everyone=False, roles=False) 127 | await channel.send(msg, view=view, allowed_mentions=mentions) 128 | -------------------------------------------------------------------------------- /cogs/meta/sauce.py: -------------------------------------------------------------------------------- 1 | import os 2 | import inspect 3 | from typing import Optional 4 | from utils import DuckCog, command, DuckContext 5 | 6 | 7 | class Sauce(DuckCog): 8 | @command(aliases=['source', 'src', 'github']) 9 | async def sauce(self, ctx: DuckContext, *, command: Optional[str]): 10 | """Displays my full source code or for a specific command. 11 | 12 | Parameters 13 | ---------- 14 | command: Optional[str] 15 | The command to display the source code for. 16 | """ 17 | source_url = 'https://github.com/DuckBot-Discord/DuckBot' 18 | branch = 'rewrite' 19 | if command is None: 20 | return await ctx.send(f"<{source_url}>") 21 | 22 | if command == 'help': 23 | src = type(self.bot.help_command) 24 | module = src.__module__ 25 | filename = inspect.getsourcefile(src) 26 | else: 27 | obj = self.bot.get_command(command.replace('.', ' ')) 28 | if obj is None: 29 | return await ctx.send('Could not find command.') 30 | elif obj.cog.__class__.__name__ in ('Jishaku', 'DuckBotJishaku'): 31 | return await ctx.send( 32 | '<:jsk:984549118129111060> Jishaku, a debugging and utility extension for discord.py bots:' 33 | '\nSee the full source here: ' 34 | ) 35 | 36 | # since we found the command we're looking for, presumably anyway, let's 37 | # try to access the code itself 38 | src = obj.callback.__code__ 39 | module = obj.callback.__module__ 40 | filename = src.co_filename 41 | 42 | try: 43 | lines, firstlineno = inspect.getsourcelines(src) 44 | except Exception as e: 45 | await ctx.send(f'**Could not retrieve source:**\n{e.__class__.__name__}:{e}') 46 | return 47 | if not module.startswith('discord'): 48 | # not a built-in command 49 | if filename is None: 50 | return await ctx.send('Could not find source for command.') 51 | 52 | location = os.path.relpath(filename).replace('\\', '/') 53 | else: 54 | location = module.replace('.', '/') + '.py' 55 | source_url = 'https://github.com/Rapptz/discord.py' 56 | branch = 'master' 57 | 58 | final_url = f'<{source_url}/blob/{branch}/{location}#L{firstlineno}-L{firstlineno + len(lines) - 1}>' 59 | await ctx.send(final_url) 60 | -------------------------------------------------------------------------------- /cogs/moderation/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from .channel import ChannelModeration 6 | from .mutes import TempMute 7 | from .standard import StandardModeration 8 | from .message import MessagePurge 9 | from .new_account_gate import NewAccountGate 10 | from .role import Roles 11 | 12 | if TYPE_CHECKING: 13 | from bot import DuckBot 14 | 15 | 16 | # ['archive', 'role', 'slowmode'] 17 | 18 | 19 | class Moderation( 20 | TempMute, 21 | StandardModeration, 22 | ChannelModeration, 23 | MessagePurge, 24 | NewAccountGate, 25 | Roles, 26 | emoji='\N{HAMMER AND PICK}', 27 | brief='Moderation commands!', 28 | ): 29 | """All commands to moderate members, roles, channels, etc.""" 30 | 31 | 32 | async def setup(bot: DuckBot): 33 | await bot.add_cog(Moderation(bot)) 34 | -------------------------------------------------------------------------------- /cogs/moderation/message/__init__.py: -------------------------------------------------------------------------------- 1 | from .cog import * 2 | -------------------------------------------------------------------------------- /cogs/moderation/message/cog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from collections import Counter 5 | from typing import Optional, Annotated 6 | 7 | import discord 8 | from discord.ext import commands 9 | 10 | from utils import DuckCog, DuckContext, group, command 11 | from .parser import SearchResult, PurgeSearchConverter 12 | from .tokens import DateDeterminer 13 | 14 | 15 | class MessagePurge(DuckCog): 16 | async def purge(self, ctx: commands.Context, search: int | None, predicate, **extra_kwargs): 17 | if not isinstance(ctx.channel, discord.abc.GuildChannel): 18 | return "..." 19 | 20 | async with ctx.typing(): 21 | messages = await ctx.channel.purge(limit=search, check=predicate, **extra_kwargs) 22 | 23 | spammers = Counter(str(m.author) for m in messages) 24 | deletion_header = f"Deleted {len(messages)} message" + ('s.' if len(messages) != 1 else '.') 25 | 26 | if spammers: 27 | formatted_counts = '\n\n' + '\n'.join( 28 | f"**{k}:** {v}" for k, v in sorted(spammers.items(), key=lambda x: x[1], reverse=True) 29 | ) 30 | 31 | if (len(deletion_header) + len(formatted_counts)) <= 2000: 32 | deletion_header += formatted_counts 33 | 34 | return deletion_header 35 | 36 | @group( 37 | name='purge', 38 | aliases=['remove', 'clear', 'delete', 'clean'], 39 | ) 40 | @commands.has_permissions(manage_messages=True) 41 | @commands.bot_has_permissions(manage_messages=True) 42 | @commands.is_owner() 43 | async def purge_messages( 44 | self, 45 | ctx: DuckContext, 46 | search: Optional[int] = 500, 47 | *, 48 | search_argument: Annotated[Optional[SearchResult], PurgeSearchConverter] = None, 49 | ): 50 | """Removes messages that meet a criteria. 51 | 52 | This command uses syntax similar to Discord's search bar. Arguments can be separated with `and` and `or` for a more granular search. You can also use parentheses to narrow down your search. 53 | 54 | Flags 55 | ----- 56 | 57 | `user: <@someone>` Removes messages from the given user. 58 | `has: [link|embed|file|video|image|sound|sticker|reaction|emoji]` Checks if the message has one one of these things, just like in the discord search feature. 59 | `is: [bot|human|webhook]` Checks the type of user. \\*`bot` does not match webhooks. 60 | `contains: ` Messages that contain a substring. 61 | `prefix: ` Messages that start with a string. 62 | `suffix: ` Messages that end with a string. 63 | `pinned: [yes|no]` Whether a message is pinned. (default: no) 64 | 65 | To narrow down *when* to search, you can use these arguments: 66 | These three date arguments must __not__ be within parentheses, and __cannot__ be separated with `or` from other search terms. 67 | `before: ` Messages before the given message ID. 68 | `after: ` Messages after the given message ID. 69 | `around: ` Messages around the given message ID. (or `during:`) 70 | 71 | Notes 72 | ----- 73 | 74 | In order to use this command, you must have Manage Messages permissions. So does the bot. Cannot be executed in DMs. 75 | When the command is done, you will get a recap of the removed messages, composed of the authors and a count of messages for each. 76 | 77 | Examples 78 | -------- 79 | 80 | `db.purge from: @duckbot has: image` 81 | `db.purge has: link or has: reactions` 82 | `db.purge 100` 83 | `db.purge 350 from: @leocx1000 contains: discord.gg` 84 | `db.purge (has:link contains: google.com) or pinned:yes` 85 | 86 | """ 87 | extra_kwargs = {} 88 | 89 | if search_argument: 90 | await search_argument.init(ctx) 91 | 92 | predicate = search_argument.build_predicate() 93 | 94 | # Let's look for `after`, `before` and `around` (DateDelim) 95 | for token in search_argument.predicates: 96 | if isinstance(token, DateDeterminer): 97 | extra_kwargs[token.invoked_with] = token.parsed_argument.created_at 98 | 99 | else: 100 | predicate = lambda m: not m.pinned 101 | 102 | # Only allow BULK deletion. Single-message-deletion is too rate-limited, especially for ancient messages. 103 | if 'after' in extra_kwargs: 104 | after = extra_kwargs['after'] 105 | cut_off = ctx.message.created_at - datetime.timedelta(days=14) 106 | if after > cut_off: 107 | return await ctx.send( 108 | f'Cannot delete messages older than 14 days. ({discord.utils.format_dt(cut_off, "D")})' 109 | ) 110 | else: 111 | extra_kwargs['after'] = ctx.message.created_at - datetime.timedelta(days=14) 112 | 113 | if 'before' not in extra_kwargs: 114 | # For accountability, set this so it doesn't delete OP's message. 115 | extra_kwargs['before'] = ctx.message.created_at 116 | 117 | if isinstance(ctx.channel, discord.abc.GuildChannel): 118 | if not await ctx.confirm(f'Are you sure you want to search through {search} messages and delete matching ones?'): 119 | return 120 | 121 | deletion_header = await self.purge(ctx, search, predicate, **extra_kwargs) 122 | await ctx.send(deletion_header, delete_after=10) 123 | else: 124 | await ctx.send('Somehow this was ran in a DM?') 125 | 126 | @command() 127 | @commands.cooldown(1, 5.0, type=commands.BucketType.channel) 128 | async def cleanup(self, ctx: DuckContext, search: int = 25): 129 | """ 130 | Cleans up the bot's messages from the channel. 131 | 132 | If a search number is specified, it searches that many messages to delete. 133 | If the bot has Manage Messages permissions then it will try to delete 134 | messages that look like they invoked the bot as well. 135 | 136 | After the cleanup is completed, the bot will send you a message with 137 | which people got their messages deleted and their count. This is useful 138 | to see which users are spammers. 139 | 140 | Members with Manage Messages can search up to 1000 messages. 141 | Members without can search up to 25 messages. 142 | """ 143 | if not ctx.permissions.manage_messages: 144 | search = min(search, 25) 145 | else: 146 | search = min(search, 1000) 147 | prefixes = tuple(await ctx.bot.get_prefix(ctx.message)) 148 | check = lambda m: (m.author == ctx.me or m.content.startswith(prefixes)) and not m.mentions 149 | message = await self.purge(ctx, search, check) 150 | await ctx.send(message, delete_after=10) 151 | -------------------------------------------------------------------------------- /cogs/moderation/message/parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import itertools 4 | import textwrap 5 | from typing import Optional, Any, List, Callable 6 | from enum import Enum 7 | 8 | import discord 9 | import regex 10 | 11 | from discord.ext import commands 12 | 13 | from utils import DuckContext 14 | from .tokens import Token, ALL_TOKENS, TokenParsingError 15 | 16 | regex.DEFAULT_VERSION = regex.VERSION1 17 | 18 | 19 | class SomethingWentWrong(commands.CommandError): ... 20 | 21 | 22 | class Separator(Enum): 23 | OR = 0 24 | AND = 1 25 | 26 | 27 | BRACES = {'{': '}', '(': ')', '[': ']', None: None} 28 | 29 | 30 | class SearchResult: 31 | def __init__(self, parent: Optional[SearchResult] = None, opening_brace: Optional[str] = None) -> None: 32 | self._parent: Optional[SearchResult] = parent 33 | self.predicates: List[Token | Separator | SearchResult] = [] 34 | self.closing_brace: Optional[str] = BRACES[opening_brace] 35 | 36 | @property 37 | def root_parent(self) -> SearchResult: 38 | if self._parent: 39 | return self._parent.root_parent 40 | return self 41 | 42 | def child(self, opening_brace: str) -> SearchResult: 43 | child = SearchResult(parent=self, opening_brace=opening_brace) 44 | self.add_pred(child) 45 | return child 46 | 47 | def parent(self): 48 | if self._parent: 49 | return self._parent 50 | raise SomethingWentWrong('No parent found') 51 | 52 | def add_pred(self, pred: Token | Separator | SearchResult): 53 | try: 54 | previous = self.predicates[-1] 55 | if not isinstance(pred, SearchResult) and isinstance(previous, pred.__class__): 56 | raise SomethingWentWrong('Somehow two of the same pred type got chained.') 57 | self.predicates.append(pred) 58 | except IndexError: 59 | if isinstance(pred, Separator): 60 | raise SomethingWentWrong('Cannot start with separator') 61 | self.predicates.append(pred) 62 | 63 | def build_predicate(self) -> Callable[[discord.Message], bool]: 64 | if len(self.predicates) == 1: 65 | predicate = self.predicates[0] 66 | if isinstance(predicate, Token): 67 | return predicate.check 68 | elif isinstance(predicate, SearchResult): 69 | return predicate.build_predicate() 70 | else: 71 | raise SomethingWentWrong('Wrong predicate found') 72 | 73 | built_pred: Callable[..., Any] | None = None 74 | previous: Callable[..., Any] | None = None 75 | pairwise = itertools.pairwise(self.predicates) 76 | for current, subsequent in pairwise: 77 | if isinstance(current, Token): 78 | previous = current.check 79 | elif isinstance(current, SearchResult): 80 | previous = current.build_predicate() 81 | else: 82 | if isinstance(subsequent, SearchResult): 83 | subsequent = subsequent.build_predicate() 84 | elif isinstance(subsequent, Token): 85 | subsequent = subsequent.check 86 | else: 87 | raise SomethingWentWrong('Something borked.') 88 | 89 | if current is Separator.AND: 90 | meth = lambda x, y: x and y 91 | else: 92 | meth = lambda x, y: x or y 93 | 94 | built_pred = lambda msg: meth(previous(msg), subsequent(msg)) # type: ignore 95 | 96 | return built_pred # type: ignore 97 | 98 | async def init(self, ctx: DuckContext): 99 | try: 100 | for pred in self.predicates: 101 | if isinstance(pred, Token): 102 | await pred.parse(ctx) 103 | elif isinstance(pred, SearchResult): 104 | await pred.init(ctx) 105 | except TokenParsingError as error: 106 | leading_ws = len(error.token.argument) - len(error.token.argument.lstrip()) 107 | message = "Unrecognised search term...\n" + textwrap.indent( 108 | '```\n' 109 | + error.token.full_string 110 | + '\n' 111 | + (' ' * (error.token.start - len(error.token.invoked_with) - 1)) 112 | + ('~' * (len(error.token.invoked_with) + 1 + leading_ws)) 113 | + ('^' * ((error.token.stop or len(error.token.full_string)) - error.token.start - leading_ws)) 114 | + '\n' 115 | + str(error.error) 116 | + '```', 117 | '> ', 118 | ) 119 | raise commands.BadArgument(message) 120 | 121 | def all_tokens(self): 122 | for pred in self.predicates: 123 | if isinstance(pred, Token): 124 | yield pred 125 | elif isinstance(pred, SearchResult): 126 | yield from pred.all_tokens() 127 | 128 | def __repr__(self) -> str: 129 | return f"<{type(self).__name__} predicates={self.predicates} closing_brace={self.closing_brace!r}>" 130 | 131 | 132 | def next_until(n: int, idx: int, enumerator: enumerate): 133 | if idx >= n: 134 | return 135 | for idx, _ in enumerator: 136 | if idx >= n - 1: 137 | return 138 | 139 | 140 | # TODO this: 141 | """ 142 | 143 | handle errors returned in converters 144 | 145 | give more elaborate error messages n stuff with ^^^ 146 | """ 147 | 148 | token_map: dict[str, type[Token]] = {} 149 | for token in ALL_TOKENS: 150 | if isinstance(token.name, tuple): 151 | token_map.update({name.lower(): token for name in token.name}) 152 | else: 153 | token_map[token.name.lower()] = token 154 | 155 | full = '|'.join(regex.escape(k) for k in token_map.keys()) 156 | 157 | token_re = regex.compile( 158 | # fmt: off 159 | r"(?:(?P[\)\]\}]+)\Z|(?P[\)\]\}]+)?\ ?\b(?Pand|or)?\ ?\b(?P" + full + r"):)", 160 | # This regex: It either is a parentheses at the end of the string, or it's a directive (like `from:` or `has:`) and could potentially 161 | # have some a separator (and|or) and before, some closing parentheses too. 162 | flags=regex.IGNORECASE, 163 | # fmt: on 164 | ) 165 | 166 | 167 | class PurgeSearchConverter: 168 | async def convert(self, ctx: DuckContext, unparsed: str) -> SearchResult: 169 | scanner = token_re.finditer(unparsed) 170 | 171 | current = SearchResult() 172 | match = next(scanner) 173 | start, end = match.span(0) 174 | enumerator = enumerate(unparsed) 175 | for idx, char in enumerator: 176 | if char in BRACES: 177 | current = current.child(char) 178 | 179 | elif idx == start: 180 | closing_braces: Optional[str] = match.group('parentheses') 181 | if closing_braces: 182 | for brace in closing_braces: 183 | if brace != current.closing_brace: 184 | raise SomethingWentWrong(f'Unmatched parentheses. {brace} - {current.closing_brace}') 185 | 186 | current = current.parent() 187 | 188 | directive: str = match.group('directive') 189 | separator: Optional[str] = match.group('separator') 190 | 191 | if current.predicates and directive and not separator: 192 | separator = 'and' 193 | 194 | if separator: 195 | current.add_pred(Separator.AND if separator.lower() == 'and' else Separator.OR) 196 | next_until(end, idx, enumerator) 197 | 198 | try: 199 | next_match = next(scanner) 200 | next_start, next_end = next_match.span(0) 201 | 202 | if directive: 203 | token_cls = token_map[directive] 204 | token = token_cls(unparsed, end, next_start, invoked_with=directive, result_found_at=current) 205 | 206 | current.add_pred(token) 207 | else: 208 | next_until(len(unparsed), idx, enumerator) 209 | continue 210 | 211 | match, start, end = next_match, next_start, next_end 212 | 213 | next_until(next_start, idx, enumerator) 214 | except StopIteration: 215 | if directive: 216 | token_cls = token_map[directive] 217 | token = token_cls(unparsed, end, None, invoked_with=directive, result_found_at=current) 218 | 219 | current.add_pred(token) 220 | next_until(len(unparsed), idx, enumerator) 221 | 222 | if current.closing_brace: 223 | raise SomethingWentWrong('Unclosed parentheses.') 224 | 225 | return current.root_parent 226 | -------------------------------------------------------------------------------- /cogs/moderation/message/tokens.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import inspect 5 | from typing import Optional, Any, Literal, Union, List, Type, TYPE_CHECKING 6 | 7 | import discord 8 | import regex 9 | 10 | from discord.ext import commands 11 | 12 | from utils import DuckContext 13 | 14 | if TYPE_CHECKING: 15 | from .parser import SearchResult 16 | 17 | regex.DEFAULT_VERSION = regex.VERSION1 18 | 19 | URL_REGEX = regex.compile(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+') 20 | EMOJI_REGEX = regex.compile(r'') 21 | 22 | 23 | class TokenParsingError(Exception): 24 | def __init__(self, token: Token, error: Exception) -> None: 25 | self.token = token 26 | self.error = error 27 | super().__init__(f"Failed to parse token: `{token.invoked_with}:` because:\n{error}") 28 | 29 | 30 | class Token: 31 | """Base class for purge tokens. 32 | 33 | Subclasses must overwrite `converter` attribute or `parse` method, and `name` attribute. 34 | """ 35 | 36 | converter: Optional[Any] = NotImplemented # The converter to be used by discord.py to parse this string later 37 | name: str | tuple[str, ...] = NotImplemented # the thing that will be used to parse the text. 38 | # # I.e. ``from`` for looking for ``from: @user`` 39 | 40 | def __init__(self, full_string: str, start: int, stop: int | None, invoked_with: str, result_found_at: SearchResult): 41 | self.full_string = full_string 42 | self.argument = full_string[start:stop] 43 | self.start = start 44 | self.stop = stop 45 | self.invoked_with = invoked_with 46 | self.parsed_argument: Any = None 47 | self.result_found_at: SearchResult = result_found_at 48 | 49 | def __repr__(self) -> str: 50 | if isinstance(self.parsed_argument, Exception): 51 | return f"" 52 | elif self.parsed_argument is not None: 53 | return f"" 54 | else: 55 | return f"" 56 | 57 | def check(self, message: discord.Message): 58 | return NotImplemented 59 | 60 | async def parse(self, ctx: DuckContext): 61 | if self.converter is NotImplemented: 62 | raise RuntimeError('No converter given for Token %s' % type(self).__name__) 63 | try: 64 | self.parsed_argument = await commands.run_converters( 65 | ctx, 66 | self.converter, 67 | self.argument.strip(), 68 | commands.Parameter( 69 | self.invoked_with, 70 | kind=inspect._ParameterKind.POSITIONAL_ONLY, 71 | annotation=self.converter, 72 | ), 73 | ) 74 | self.validate() 75 | 76 | except Exception as e: 77 | self.parsed_argument = e 78 | raise TokenParsingError(self, e) 79 | 80 | def validate(self): 81 | return True 82 | 83 | 84 | class FromUser(Token): 85 | name = 'from' 86 | converter = Union[discord.Member, discord.User] 87 | 88 | def check(self, message: discord.Message): 89 | return message.author == self.parsed_argument 90 | 91 | 92 | class MentionsUser(FromUser): 93 | name = 'mentions' 94 | 95 | def check(self, message: discord.Message): 96 | return self.parsed_argument in message.mentions 97 | 98 | 99 | class HasPartOfAMessage(Token): 100 | name = 'has' 101 | converter = Literal[ 102 | 'link', 103 | 'links', 104 | 'embed', 105 | 'embeds', 106 | 'file', 107 | 'files', 108 | 'video', 109 | 'image', 110 | 'images', 111 | 'sound', 112 | 'sounds', 113 | 'sticker', 114 | 'stickers', 115 | 'reaction', 116 | 'reactions', 117 | 'emoji', 118 | 'emojis', 119 | ] 120 | 121 | def check(self, message: discord.Message): 122 | match self.parsed_argument: 123 | case 'link' | 'links': 124 | return URL_REGEX.search(message.content) is not None 125 | case 'embed' | 'embeds': 126 | return bool(message.embeds) 127 | case 'file' | 'files': 128 | return bool(message.attachments) 129 | case 'video': 130 | return any((att.content_type or '').startswith('video/') for att in message.attachments) 131 | case 'image' | 'images': 132 | return any((att.content_type or '').startswith('image/') for att in message.attachments) 133 | case 'sound' | 'sounds': 134 | return any((att.content_type or '').startswith('audio/') for att in message.attachments) 135 | case 'sticker' | 'stickers': 136 | return bool(message.stickers) 137 | case 'reaction' | 'reactions': 138 | return bool(message.reactions) 139 | case 'emoji' | 'emojis': 140 | return EMOJI_REGEX.search(message.content) is not None 141 | case _: 142 | return False 143 | 144 | 145 | class IsATypeOfUser(Token): 146 | name = 'is' 147 | converter = Literal['bot', 'human', 'user', 'webhook'] 148 | 149 | def check(self, message: discord.Message): 150 | match self.parsed_argument: 151 | case 'bot': 152 | return message.author.bot 153 | case 'human' | 'user': 154 | return not message.author.bot 155 | case 'webhook': 156 | return message.webhook_id is not None 157 | 158 | 159 | class DateDeterminer(Token): 160 | name = ('before', 'after', 'around', 'during') 161 | converter = discord.Object 162 | parsed_argument: discord.Object 163 | 164 | def _around_strategy(self, a: datetime.datetime, b: datetime.datetime): 165 | return a.year == b.year and a.month == b.month and a.day == b.day 166 | 167 | def check(self, message: discord.Message): 168 | return True # this will use the before and after arguments on Channel.purge 169 | 170 | async def parse(self, ctx: DuckContext): 171 | if self.invoked_with == 'during': 172 | self.invoked_with = 'around' 173 | return await super().parse(ctx) 174 | 175 | def validate(self): 176 | # this is a special parameter which will be passed directly to history() 177 | # so we need to do extra checking to ensure that this is used properly, and 178 | # in a non-confusing way. Disallowing `X or before:` and so on, and also disallowing 179 | # this token to be put within a parentheses. 180 | result = self.result_found_at 181 | 182 | if result != result.root_parent: 183 | raise commands.CommandError('`before:`, `after:` and `around:` must not be grouped within parentheses.') 184 | 185 | from .parser import Separator # Avoid circular imports 186 | 187 | for predicate in result.predicates: 188 | if predicate is Separator.OR: 189 | raise commands.CommandError( 190 | 'When using `before:`, `after:` or `around:`, you cannot use `or` to separate the different search terms.\n' 191 | 'For example: `from: @user around: ` is valid, so is `around: ' 192 | '(from: user or has: image)`. But the following is not: `from: @user or around: `' 193 | ) 194 | 195 | elif predicate is self: 196 | continue # we found ourselves. 197 | 198 | elif isinstance(predicate, DateDeterminer): 199 | if predicate.invoked_with == self.invoked_with: 200 | raise commands.CommandError(f'You have already used `{self.invoked_with}:` once before') 201 | 202 | elif self.invoked_with in ('around', 'during'): 203 | raise commands.CommandError('When using `around:` you cannot specify `before:` or `after:`') 204 | return True 205 | 206 | 207 | class ContainsSubstring(Token): 208 | name = ('contains', 'prefix', 'suffix') 209 | converter = str 210 | 211 | def check(self, message: discord.Message): 212 | match self.invoked_with: 213 | case 'contains': 214 | return self.parsed_argument in message.content 215 | case 'prefix': 216 | return message.content.startswith(self.parsed_argument) 217 | case 'suffix': 218 | return message.content.endswith(self.parsed_argument) 219 | 220 | def validate(self): 221 | if not self.argument.strip(): 222 | raise commands.BadArgument(f"`{self.invoked_with}:` cannot have an empty string.") 223 | 224 | 225 | class Pinned(Token): 226 | name = 'pinned' 227 | converter = bool 228 | 229 | def check(self, message: discord.Message): 230 | return message.pinned == self.parsed_argument 231 | 232 | 233 | ALL_TOKENS: List[Type[Token]] = [FromUser, MentionsUser, HasPartOfAMessage, IsATypeOfUser, DateDeterminer, ContainsSubstring] 234 | # Remember to update the doc-string of `purge` in `../cog.py` 235 | -------------------------------------------------------------------------------- /cogs/moderation/new_account_gate.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | import discord 5 | from discord.ext import commands 6 | 7 | from utils import DuckContext, DuckCog 8 | from utils.time import ShorterTime, human_timedelta 9 | 10 | log = logging.getLogger('auto-ban') 11 | 12 | 13 | class NewAccountGate(DuckCog): 14 | @commands.command(name='min-age') 15 | @commands.has_permissions(administrator=True) 16 | @commands.bot_has_permissions(ban_members=True) 17 | @commands.guild_only() 18 | async def min_age(self, ctx: DuckContext, *, time: ShorterTime | None = None): 19 | """Sets up or un-sets the minimum account age for the server. 20 | 21 | Joining members who's account age is smaller will be automatically kicked. Run with no argument to un-set the minimum account age. 22 | 23 | Valid time identifiers: weeks/w; days/d; hours/h; minutes/m; seconds/s (can be singular or plural). Months and years are not supported. 24 | 25 | Example 26 | `db.min-age 2weeks` 27 | `db.min-age 3weeks5days12h` 28 | """ 29 | if not time: 30 | await self.bot.pool.execute('UPDATE GUILDS SET min_join_age = NULL WHERE guild_id = $1', ctx.guild.id) 31 | await ctx.send('Unset minimum account age') 32 | else: 33 | seconds = (time.dt - ctx.message.created_at).total_seconds() 34 | await self.bot.pool.execute( 35 | 'INSERT INTO GUILDS (guild_id, min_join_age) VALUES ($1, $2)' 36 | 'ON CONFLICT (guild_id) DO UPDATE SET min_join_age = $2', 37 | ctx.guild.id, 38 | seconds, 39 | ) 40 | await ctx.send(f'I will now kick joining accounts that are less than **{human_timedelta(time.dt)}** old.') 41 | 42 | @commands.Cog.listener('on_member_join') 43 | async def kick_new_members(self, member: discord.Member): 44 | """kicks joining members, as per the user-defined settings.""" 45 | if member.bot: 46 | return 47 | 48 | if not member.guild.me.guild_permissions.kick_members: 49 | return 50 | 51 | threshold_seconds: int | None = await self.bot.pool.fetchval( 52 | 'SELECT min_join_age FROM guilds WHERE guild_id = $1', member.guild.id 53 | ) 54 | if not threshold_seconds: 55 | return 56 | account_age_seconds = (discord.utils.utcnow() - member.created_at).total_seconds() 57 | 58 | if account_age_seconds < threshold_seconds: 59 | min_age = discord.utils.utcnow() + datetime.timedelta(seconds=threshold_seconds) 60 | await member.kick(reason=f'Account too young. Did not exceed the *{human_timedelta(min_age)} old* threshold.') 61 | -------------------------------------------------------------------------------- /cogs/moderation/standard.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | import discord 6 | from discord import app_commands 7 | from discord.ext import commands 8 | 9 | from utils import ( 10 | DuckContext, 11 | HandleHTTPException, 12 | VerifiedMember, 13 | VerifiedUser, 14 | BanEntryConverter, 15 | DuckCog, 16 | safe_reason, 17 | mdr, 18 | command, 19 | UserFriendlyTime, 20 | human_timedelta, 21 | Timer, 22 | format_date, 23 | ) 24 | from utils.checks import hybrid_permissions_check 25 | from utils.helpers import can_execute_action 26 | 27 | 28 | class StandardModeration(DuckCog): 29 | """A cog dedicated to holding standard 30 | moderation commands. Such as ban or kick. 31 | """ 32 | 33 | @command(name='kick', aliases=['boot'], hybrid=True) 34 | @hybrid_permissions_check(kick_members=True, bot_kick_members=True) 35 | async def kick(self, ctx: DuckContext, member: VerifiedMember, *, reason: str = '...') -> Optional[discord.Message]: 36 | """ 37 | Kick a member from the server. 38 | 39 | Parameters 40 | ---------- 41 | member: :class:`discord.Member` 42 | The member to kick. (can be an ID) 43 | reason: Optional[:class:`str`] 44 | The reason for the kick.'. 45 | """ 46 | guild = ctx.guild 47 | if guild is None: 48 | return 49 | 50 | await ctx.defer() 51 | 52 | async with HandleHTTPException(ctx, title=f'Failed to kick {member}'): 53 | await member.kick(reason=safe_reason(ctx.author, reason)) 54 | 55 | return await ctx.send(f'Kicked **{member}** for: {reason}') 56 | 57 | @command(name='ban', hybrid=True) 58 | @hybrid_permissions_check(ban_members=True, bot_ban_members=True) 59 | @app_commands.rename(delete_days='delete-days') 60 | async def ban( 61 | self, 62 | ctx: DuckContext, 63 | user: VerifiedUser, 64 | *, 65 | delete_days: Optional[commands.Range[int, 0, 7]] = 1, 66 | reason: str = '...', 67 | ) -> Optional[discord.Message]: 68 | """ 69 | Bans a member from the server. 70 | 71 | Parameters 72 | ---------- 73 | user: :class:`discord.Member` 74 | The member to ban. 75 | delete_days: Optional[:class:`int`] 76 | The number of days worth of messages to delete. 77 | reason: Optional[:class:`str`] 78 | The reason for banning the member. Defaults to '...'. 79 | """ 80 | 81 | async with HandleHTTPException(ctx, title=f'Failed to ban {user}'): 82 | seconds = (delete_days or 0) * 86400 83 | await ctx.guild.ban(user, delete_message_seconds=seconds, reason=safe_reason(ctx.author, reason)) 84 | 85 | return await ctx.send(f'Banned **{user}** for: {reason}') 86 | 87 | @command(name='softban') 88 | @hybrid_permissions_check(ban_members=True, bot_ban_members=True) 89 | @app_commands.rename(delete_days='delete-days') 90 | async def softban( 91 | self, 92 | ctx: DuckContext, 93 | user: VerifiedUser, 94 | *, 95 | delete_days: Optional[commands.Range[int, 0, 7]] = 1, 96 | reason: str = '...', 97 | ) -> Optional[discord.Message]: 98 | """Ban a member from the server, then immediately unbans them, deleting all their messages in the process. 99 | 100 | Parameters 101 | ---------- 102 | user: :class:`discord.Member` 103 | The member to softban. 104 | delete_days: Optional[:class:`int`] 105 | The number of days worth of messages to delete. 106 | reason: Optional[:class:`str`] 107 | The reason for softbanning the member. Defaults to '...'. 108 | """ 109 | 110 | async with HandleHTTPException(ctx, title=f'Failed to ban {user}'): 111 | seconds = (delete_days or 0) * 86400 112 | 113 | await ctx.guild.ban(user, delete_message_seconds=seconds, reason=safe_reason(ctx.author, reason)) 114 | await ctx.guild.unban(user, reason=safe_reason(ctx.author, reason)) 115 | 116 | return await ctx.send(f'Banned **{user}** for: {reason}') 117 | 118 | @command(hybrid=True) 119 | @hybrid_permissions_check(ban_members=True, bot_ban_members=True) 120 | async def unban(self, ctx: DuckContext, *, user: BanEntryConverter): 121 | """Unbans a user from this server. You can search for this by: 122 | 123 | The lookup strategy for the ``user`` parameter is as follows (in order): 124 | 125 | - User ID: The ID of a user that. 126 | - User Mention: The mention of a user. 127 | - Name and discriminator: The Name#0000 format of a user (case sensitive, will look at the ban list to find the user). 128 | - Name: The name of a user (case insensitive, will look at the ban list to find the user). 129 | 130 | Parameters 131 | ---------- 132 | user: :class:`discord.User` 133 | The user to unban. 134 | """ 135 | guild = ctx.guild 136 | if guild is None: 137 | return 138 | 139 | async with HandleHTTPException(ctx, title=f'Failed to unban {user}'): 140 | await guild.unban(user.user, reason=f"Unban by {ctx.author} ({ctx.author.id})") 141 | 142 | extra = f"Previously banned for: {user.reason}" if user.reason else '' 143 | return await ctx.send(f"Unbanned **{user}**\n{extra}") 144 | 145 | @command(name='nick', hybrid=True) 146 | @hybrid_permissions_check(manage_nicknames=True, bot_manage_nicknames=True) 147 | async def nick(self, ctx: DuckContext, member: VerifiedMember, *, nickname: Optional[str] = None): 148 | """Change a member's nickname. 149 | 150 | Parameters 151 | ---------- 152 | member: :class:`discord.Member` 153 | The member to change the nickname of. 154 | nickname: Optional[:class:`str`] 155 | The nickname to set. If no nickname is provided, the nickname will be removed. 156 | """ 157 | await can_execute_action(ctx, member) 158 | 159 | if nickname is None and not member.nick: 160 | return await ctx.send(f'**{mdr(member)}** has no nickname to remove.') 161 | 162 | if nickname is not None and len(nickname) > 32: 163 | return await ctx.send(f'Nickname is too long! ({len(nickname)}/32)') 164 | 165 | async with HandleHTTPException(ctx, title=f'Failed to set nickname for {member}.'): 166 | await member.edit(nick=nickname) 167 | 168 | message = 'Changed nickname of **{user}** to **{nick}**.' if nickname else 'Removed nickname of **{user}**.' 169 | return await ctx.send(message.format(user=mdr(member), nick=mdr(nickname))) 170 | 171 | @command(name='tempban', aliases=['tban']) 172 | @hybrid_permissions_check(ban_members=True, bot_ban_members=True) 173 | async def tempban( 174 | self, 175 | ctx: DuckContext, 176 | member: VerifiedUser, 177 | *, 178 | when: UserFriendlyTime(commands.clean_content, default='...'), # type: ignore 179 | ) -> None: 180 | """ 181 | 182 | Temporarily ban a user from the server. 183 | 184 | This command temporarily bans a user from the server. The user will be able to rejoin the server, but will be 185 | unable to send messages. 186 | 187 | The user will be banned for 24 hours by default. If you wish to ban for a longer period of time, use the 188 | `ban` command instead. 189 | 190 | Examples 191 | -------- 192 | `db.tempban @DuckBot 3 hours being too good.` 193 | 194 | Parameters 195 | ---------- 196 | member : discord.Member 197 | The member to ban. 198 | when : UserFriendlyTime 199 | The reason and time of the ban, for example, "hours for being a bad" 200 | """ 201 | 202 | async with HandleHTTPException(ctx): 203 | await ctx.guild.ban(member, reason=safe_reason(ctx.author, when.arg)) 204 | 205 | await self.bot.pool.execute( 206 | """ 207 | DELETE FROM timers 208 | WHERE event = 'ban' 209 | AND (extra->'args'->0) = $1 210 | AND (extra->'args'->1) = $2 211 | """, 212 | member.id, 213 | ctx.guild.id, 214 | ) 215 | 216 | await self.bot.create_timer(when.dt, 'ban', member.id, ctx.guild.id, ctx.author.id, precise=False) 217 | 218 | await ctx.send(f"Banned **{mdr(member)}** for {human_timedelta(when.dt)}.") 219 | 220 | @commands.Cog.listener('on_ban_timer_complete') 221 | async def on_ban_timer_complete(self, timer: Timer): 222 | """Automatic unban handling.""" 223 | member_id, guild_id, moderator_id = timer.args 224 | 225 | guild = self.bot.get_guild(guild_id) 226 | if guild is None: 227 | self.logger.info('Guild %s not found, discarding...', guild_id) 228 | return # F 229 | 230 | moderator = await self.bot.get_or_fetch_user(moderator_id) 231 | if moderator is None: 232 | mod = f"@Unknown User ({moderator_id})" 233 | else: 234 | mod = f"@{moderator} ({moderator_id})" 235 | 236 | try: 237 | await guild.unban( 238 | discord.Object(id=member_id), 239 | reason=f"Automatic unban for temp-ban by {mod} " f"on {format_date(timer.created_at)}.", 240 | ) 241 | except discord.HTTPException as e: 242 | self.logger.debug(f"Failed to unban {member_id} in {guild_id}.", exc_info=e) 243 | -------------------------------------------------------------------------------- /cogs/owner/__init__.py: -------------------------------------------------------------------------------- 1 | from utils import DuckContext, HandleHTTPException 2 | from discord.ext.commands import NotOwner 3 | from utils import command, group 4 | 5 | from .blacklist import BlackListManagement 6 | from .test_shit import TestingShit 7 | from .badges import BadgeManagement 8 | from .eval import Eval 9 | from .sql import SQLCommands 10 | from .update import ExtensionsManager 11 | from .news import NewsManagement 12 | 13 | 14 | class Owner( 15 | BlackListManagement, 16 | TestingShit, 17 | BadgeManagement, 18 | Eval, 19 | SQLCommands, 20 | ExtensionsManager, 21 | NewsManagement, 22 | command_attrs=dict(hidden=True), 23 | emoji="<:blushycat:913554213555028069>", 24 | brief="Restricted! hah.", 25 | ): 26 | """The Cog for All owner commands.""" 27 | 28 | @group() 29 | async def dev(self, ctx: DuckContext): 30 | """Developer-only commands.""" 31 | if ctx.invoked_subcommand is None: 32 | await ctx.send_help(ctx.command) 33 | 34 | async def cog_load(self) -> None: 35 | for command in self.get_commands(): 36 | if command == self.dev: 37 | continue 38 | self.bot.remove_command(command.name) 39 | self.dev.add_command(command) 40 | 41 | async def cog_check(self, ctx: DuckContext) -> bool: 42 | """Check if the user is a bot owner.""" 43 | if await ctx.bot.is_owner(ctx.author): 44 | return True 45 | raise NotOwner 46 | 47 | @command() 48 | async def sync(self, ctx: DuckContext): 49 | """Syncs commands.""" 50 | msg = await ctx.send("Syncing...") 51 | ctx.bot.tree.copy_global_to(guild=ctx.guild) 52 | async with HandleHTTPException(ctx): 53 | cmds = await ctx.bot.tree.sync(guild=ctx.guild) 54 | await msg.edit(content=f"✅ Synced {len(cmds)} commands.") 55 | 56 | 57 | async def setup(bot): 58 | await bot.add_cog(Owner(bot)) 59 | -------------------------------------------------------------------------------- /cogs/owner/badges.py: -------------------------------------------------------------------------------- 1 | import asyncpg 2 | import discord 3 | from utils import DuckCog, group 4 | 5 | 6 | class BadgeManagement(DuckCog): 7 | @group(name='badges', aliases=['badge'], invoke_without_command=True) 8 | async def badges(self, ctx): 9 | """Displays all available badges.""" 10 | badges = await self.bot.pool.fetch("SELECT badge_id, name, emoji FROM badges") 11 | to_send = [] 12 | for badge_id, name, emoji in badges: 13 | to_send.append(f"**({badge_id})** {emoji} {name}") 14 | 15 | await ctx.send(embed=discord.Embed(title='All badges:', description="\n".join(to_send))) 16 | 17 | @badges.command(name='add', aliases=['create']) 18 | async def badges_add(self, ctx, emoji: str, *, name: str): 19 | """Adds a badge to the database. 20 | 21 | Parameters 22 | ---------- 23 | emoji: discord.PartialEmoji 24 | The emoji to use for the badge. 25 | name: str 26 | The name of the badge. 27 | """ 28 | badge_id = await self.bot.pool.fetchval( 29 | "INSERT INTO badges (name, emoji) VALUES ($1, $2) RETURNING badge_id", name, emoji 30 | ) 31 | await ctx.send(f"Created badge with id {badge_id}:\n" f"> {emoji} {name}") 32 | 33 | @badges.command(name='delete') 34 | async def badges_delete(self, ctx, badge_id: int): 35 | """Removes a badge from the database. 36 | 37 | Parameters 38 | ---------- 39 | badge_id: int 40 | The id of the badge to remove. 41 | """ 42 | b_id = await self.bot.pool.fetchval("DELETE FROM badges WHERE badge_id = $1 RETURNING badge_id", badge_id) 43 | if b_id is not None: 44 | await ctx.send(f"Removed badge with id {badge_id}.") 45 | else: 46 | await ctx.send(f"There's no tag with id {badge_id}.") 47 | 48 | @badges.command(name='grant', aliases=['give']) 49 | async def badges_grant(self, ctx, user: discord.User, badge_id: int): 50 | """Adds a badge to a user. 51 | 52 | Parameters 53 | ---------- 54 | user: :class:`discord.User` 55 | The user to give the badge to. 56 | badge_id: :class:`int` 57 | The ID of the badge. 58 | """ 59 | try: 60 | await self.bot.pool.execute( 61 | "INSERT INTO acknowledgements (user_id, badge_id) VALUES ($1, $2) " 62 | "ON CONFLICT (user_id, badge_id) DO NOTHING ", 63 | user.id, 64 | badge_id, 65 | ) 66 | await ctx.message.add_reaction("✅") 67 | except asyncpg.ForeignKeyViolationError: 68 | await ctx.send("That badge doesn't exist.") 69 | 70 | @badges.command(name='revoke', aliases=['remove']) 71 | async def badges_revoke(self, ctx, user: discord.User, badge_id: int): 72 | """Revokes a badge from a user 73 | 74 | Parameters 75 | ---------- 76 | user: :class:`discord.User` 77 | The user to take the badge from. 78 | badge_id: :class:`int` 79 | The ID of the badge. 80 | """ 81 | b_id = await self.bot.pool.execute( 82 | "DELETE FROM acknowledgements WHERE user_id = $1 AND badge_id = $2 RETURNING badge_id", user.id, badge_id 83 | ) 84 | if b_id is not None: 85 | await ctx.message.add_reaction("✅") 86 | else: 87 | await ctx.message.add_reaction("❌") 88 | -------------------------------------------------------------------------------- /cogs/owner/blacklist.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import math 5 | import asyncpg 6 | import discord 7 | from typing import List, Optional, Union 8 | from discord.ext import commands 9 | 10 | 11 | from utils import DuckCog, DuckContext, ShortTime, mdr, format_date, human_timedelta, group 12 | 13 | 14 | class BlackListManagement(DuckCog): 15 | async def format_entry(self, entry: asyncpg.Record) -> str: 16 | """Formats an entry from the blacklist. 17 | 18 | Parameters 19 | ---------- 20 | entry: List[List[str, int, int, datetime.datetime]] 21 | the entry to format. 22 | 23 | Returns 24 | ------- 25 | str 26 | the formatted entry. 27 | """ 28 | blacklist_type: str = entry['blacklist_type'] 29 | entity_id: int = entry['entity_id'] 30 | guild_id: int = entry['guild_id'] 31 | created: datetime.datetime = entry['created_at'] 32 | # No tuple unpacking. 33 | 34 | time = format_date(created) 35 | 36 | guild = f"{self.bot.get_guild(guild_id) or 'Unknown Guild'} ({guild_id})" if guild_id else 'Global' 37 | 38 | if blacklist_type == "user": 39 | user = f"@{await self.bot.get_or_fetch_user(entity_id) or 'Unknown User'} ({entity_id})" 40 | return f"[{time} | USER] {user}" + (f" in guild: {guild}" if guild else '') 41 | 42 | elif blacklist_type == "guild": 43 | guild = f"{self.bot.get_guild(entity_id) or 'Unknown Guild'} ({entity_id})" 44 | return f"[{time} | GUILD] {guild}" 45 | 46 | elif blacklist_type == "channel": 47 | or_g = self.bot.get_guild(guild_id) 48 | meth = or_g.get_channel if isinstance(or_g, discord.Guild) else self.bot.get_channel 49 | 50 | chan = f"{meth(entity_id) or 'Unknown Channel'} ({entity_id})" 51 | return f"[{time} | CHANNEL] {chan}" + (f" in guild: {guild}" if guild else '') 52 | else: 53 | return "..." 54 | 55 | @group(name='blacklist', aliases=['bl'], invoke_without_command=True) 56 | async def blacklist( 57 | self, 58 | ctx: DuckContext, 59 | entity: Union[discord.Guild, discord.User, discord.abc.GuildChannel], 60 | when: Optional[ShortTime] = None, 61 | ) -> None: 62 | """Base command for blacklist management. 63 | Also adds an entity to the bot globally. 64 | 65 | Parameters 66 | ---------- 67 | entity: Union[:class:`discord.Guild`, :class:`discord.User`, :class:`discord.abc.GuildChannel`] 68 | the entity to block globally. 69 | when: :class:`utils.ShortTime` 70 | the time to block the entity. Must be a short time. 71 | """ 72 | args: List[Union[str, discord.Guild, discord.User, discord.abc.GuildChannel]] = [entity] 73 | if when: 74 | args.append(f" for {human_timedelta(when.dt)}") 75 | dt = when.dt 76 | else: 77 | args.append('') 78 | dt = None 79 | blacklisted: bool = False 80 | if isinstance(entity, discord.Guild): 81 | blacklisted = await self.bot.blacklist.add_guild(entity, end_time=dt) 82 | elif isinstance(entity, discord.User): 83 | blacklisted = await self.bot.blacklist.add_user(entity, end_time=dt) 84 | elif isinstance(entity, discord.abc.GuildChannel): 85 | blacklisted = await self.bot.blacklist.add_channel(entity, end_time=dt) 86 | await ctx.send(ctx.tick(blacklisted, ('added {}{}.' if blacklisted else '{} already blacklisted{}.').format(*args))) 87 | 88 | @blacklist.command(name='remove', aliases=['rm']) 89 | async def blacklist_remove( 90 | self, 91 | ctx: DuckContext, 92 | entity: Union[discord.Guild, discord.User, discord.abc.GuildChannel], 93 | guild: discord.Guild = commands.param(default=None), 94 | ) -> None: 95 | """Removes an entity from the global blacklist. 96 | 97 | Parameters 98 | ---------- 99 | entity: Union[:class:`discord.Guild`, :class:`discord.User`, :class:`discord.abc.GuildChannel`] 100 | the entity to remove from the global blacklist. 101 | """ 102 | removed: bool = False 103 | if isinstance(entity, discord.Guild): 104 | removed = await self.bot.blacklist.remove_guild(entity) 105 | elif isinstance(entity, discord.User): 106 | removed = await self.bot.blacklist.remove_user(entity, guild) 107 | elif isinstance(entity, discord.abc.GuildChannel): 108 | removed = await self.bot.blacklist.remove_channel(entity) 109 | etype = str(type(entity).__name__).split('.')[-1] 110 | await ctx.send(ctx.tick(removed, '{} removed' if removed else '{} not blacklisted').format(etype)) 111 | 112 | @blacklist.command(name='local') 113 | async def blacklist_local( 114 | self, 115 | ctx: DuckContext, 116 | guild: Optional[discord.Guild], 117 | user: Union[discord.Member, discord.User], 118 | when: Optional[ShortTime] = None, 119 | ) -> None: 120 | """Adds an entity to the local blacklist. 121 | 122 | Parameters 123 | ---------- 124 | guild: Optional[:class:`discord.Guild`] 125 | the guild to add the entity to. 126 | user: Union[:class:`discord.Member`, :class:`discord.User`] 127 | the user to add to the local blacklist. 128 | when: :class:`utils.ShortTime` 129 | the time to block the entity. Must be a short time. 130 | """ 131 | dt = when.dt if when else None 132 | if isinstance(user, discord.User) and not guild: 133 | await ctx.send('Please specify a guild or mention a member not a user.') 134 | return 135 | if isinstance(user, discord.Member): 136 | guild = guild or user.guild 137 | 138 | success = await self.bot.blacklist.add_user(user, guild, dt) 139 | etype = str(type(user).__name__).split('.')[-1] 140 | await ctx.send( 141 | ctx.tick(success, 'Added {} for guild {}.' if success else '{} already blacklisted in {}.').format( 142 | etype, mdr(guild) 143 | ) 144 | ) 145 | 146 | @blacklist.command(name='list', aliases=['ls']) 147 | async def blacklist_list(self, ctx: DuckContext, page: int = 1) -> None: 148 | """Gets a list of all blocked users in a channel. 149 | If no channel is specified, it will show the 150 | blocked users for all the chnannels in the server. 151 | 152 | Parameters 153 | ---------- 154 | page: :class:`int` 155 | The page number to show. 156 | """ 157 | guild = ctx.guild 158 | if guild is None: 159 | return 160 | 161 | if page < 1: 162 | page = 1 163 | 164 | result = await self.bot.pool.fetch( 165 | "SELECT blacklist_type, entity_id, guild_id, created_at " "FROM blacklist ORDER BY created_at DESC OFFSET $1", 166 | (page - 1) * 10, 167 | ) 168 | count = await self.bot.pool.fetchval("SELECT COUNT(*) FROM blacklist") 169 | 170 | rows: List[str] = [await self.format_entry(row) for row in result] 171 | 172 | if not rows: 173 | await ctx.send(ctx.tick(False, 'no entries')) 174 | return 175 | 176 | formatted = '```\n' + '\n'.join(rows) + '\n```' 177 | pages = math.ceil(count / 10) 178 | 179 | message = ( 180 | f"📋 **|** Blacklisted entities - Showing `{len(result)}/{count}` entries - Page `{page}/{pages}`:\n{formatted}" 181 | ) 182 | await ctx.send(message) 183 | -------------------------------------------------------------------------------- /cogs/owner/eval.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import io 5 | import pprint 6 | import random 7 | import re 8 | 9 | import aiohttp 10 | import textwrap 11 | import traceback 12 | import typing 13 | import datetime 14 | 15 | import asyncio 16 | import discord 17 | from discord.ext import commands 18 | from import_expression import exec as e_exec 19 | 20 | from utils import DuckCog, DuckContext, DeleteButton, command, UntilFlag, FlagConverter, cb 21 | from bot import DuckBot 22 | 23 | CODEBLOCK_REGEX = re.compile(r'`{3}(python\n|py\n|\n)?(?P[^`]*)\n?`{3}') 24 | 25 | 26 | class EvalFlags(FlagConverter, prefix='--', delimiter='', case_insensitive=True): 27 | wrap: bool = commands.flag(aliases=['executor', 'exec'], default=False) 28 | 29 | 30 | def cleanup_code(content: str): 31 | """Automatically removes code blocks from the code.""" 32 | # remove ```py\n``` 33 | content = textwrap.dedent(content).strip() 34 | if content.startswith('```') and content.endswith('```'): 35 | return '\n'.join(content.split('\n')[1:-1]) 36 | 37 | # remove `foo` 38 | return content.strip('` \n') 39 | 40 | 41 | class react(contextlib.AbstractAsyncContextManager): 42 | def __init__(self, message: discord.Message) -> None: 43 | self.message = message 44 | self.bot: DuckBot = message._state._get_client() # type: ignore 45 | self.task: typing.Optional[asyncio.Task] = None 46 | self.exc: typing.Optional[BaseException] = None 47 | 48 | async def starting_reaction(self) -> None: 49 | await asyncio.sleep(1.5) 50 | try: 51 | await self.message.add_reaction('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}') 52 | except discord.HTTPException: 53 | pass 54 | 55 | async def ending_reaction(self, exception: typing.Optional[BaseException]): 56 | if not exception: 57 | await self.message.add_reaction('\N{WHITE HEAVY CHECK MARK}') 58 | elif isinstance(exception, asyncio.TimeoutError): 59 | await self.message.add_reaction('\N{STOPWATCH}') 60 | else: 61 | await self.message.add_reaction('\N{WARNING SIGN}') 62 | 63 | async def __aenter__(self): 64 | self.task = self.bot.create_task(self.starting_reaction()) 65 | return self 66 | 67 | async def __aexit__(self, *args) -> bool: 68 | if self.task: 69 | self.task.cancel() 70 | self.bot.create_task(self.ending_reaction(self.exc)) 71 | return False 72 | 73 | 74 | class Eval(DuckCog): 75 | def __init__(self, *args, **kwargs): 76 | super().__init__(*args, **kwargs) 77 | self._last_result = None 78 | self._last_context_menu_input = None 79 | 80 | @staticmethod 81 | def handle_return(ret: typing.Any, stdout: str | None = None) -> dict | None: 82 | kwargs = {} 83 | if isinstance(ret, discord.File): 84 | kwargs['files'] = [ret] 85 | elif isinstance(ret, discord.Message): 86 | kwargs['content'] = f"{repr(ret)}" 87 | elif isinstance(ret, Exception): 88 | kwargs['content'] = "".join(traceback.format_exception(type(ret), ret, ret.__traceback__)) 89 | elif ret is None: 90 | pass 91 | else: 92 | kwargs['content'] = f"{ret}" 93 | 94 | if stdout: 95 | kwargs['content'] = f"{kwargs.get('content', '')}{stdout}" 96 | 97 | if (content := kwargs.pop('content', None)) and len(content) > 1990: 98 | files = kwargs.get('files', []) 99 | file = discord.File(io.BytesIO(content.encode()), filename='output.py') 100 | files.append(file) 101 | kwargs['files'] = files 102 | elif content: 103 | kwargs['content'] = cb(content) 104 | 105 | return kwargs or None 106 | 107 | def clean_globals(self): 108 | return { 109 | '__name__': __name__, 110 | '__package__': __package__, 111 | '__file__': __file__, 112 | '__builtins__': __builtins__, 113 | 'annotations': annotations, 114 | 'traceback': traceback, 115 | 'io': io, 116 | 'typing': typing, 117 | 'asyncio': asyncio, 118 | 'discord': discord, 119 | 'commands': commands, 120 | 'datetime': datetime, 121 | 'aiohttp': aiohttp, 122 | 're': re, 123 | 'random': random, 124 | 'pprint': pprint, 125 | '_get': discord.utils.get, 126 | '_find': discord.utils.find, 127 | '_now': discord.utils.utcnow, 128 | 'bot': self.bot, 129 | '_': self._last_result, 130 | } 131 | 132 | async def eval( 133 | self, body: str, env: typing.Dict[str, typing.Any], wrap: bool = False, reactor: typing.Optional[react] = None 134 | ) -> dict | None: 135 | """Evaluates arbitrary python code""" 136 | env.update(self.clean_globals()) 137 | 138 | stdout = io.StringIO() 139 | 140 | if wrap: 141 | to_compile = f'def func():\n{textwrap.indent(body, " ")}' 142 | else: 143 | to_compile = f'async def func():\n{textwrap.indent(body, " ")}' 144 | 145 | try: 146 | e_exec(to_compile, env) 147 | except Exception as e: 148 | if reactor: 149 | reactor.exc = e 150 | return self.handle_return(e) 151 | 152 | func = env['func'] 153 | try: 154 | with contextlib.redirect_stdout(stdout): 155 | if wrap: 156 | ret = await self.bot.wrap(func) 157 | else: 158 | ret = await func() 159 | except Exception as e: 160 | value = stdout.getvalue() 161 | if reactor: 162 | reactor.exc = e 163 | return self.handle_return(e, stdout=value) 164 | 165 | else: 166 | value = stdout.getvalue() 167 | if ret is not None: 168 | self._last_result = ret 169 | return self.handle_return(ret, stdout=value) 170 | 171 | @command(name='eval') 172 | async def eval_command(self, ctx: DuckContext, *, body: UntilFlag[typing.Annotated[str, cleanup_code], EvalFlags]): 173 | """Evaluates arbitrary python code""" 174 | env = { 175 | 'ctx': ctx, 176 | 'channel': ctx.channel, 177 | '_c': ctx.channel, 178 | 'author': ctx.author, 179 | '_a': ctx.author, 180 | 'guild': ctx.guild, 181 | '_g': ctx.guild, 182 | 'message': ctx.message, 183 | '_m': ctx.message, 184 | '_r': getattr(ctx.message.reference, 'resolved', None), 185 | } 186 | 187 | result = None 188 | async with react(ctx.message) as reactor: 189 | result = await self.eval(body.value, env, wrap=body.flags.wrap, reactor=reactor) 190 | 191 | if result: 192 | await DeleteButton.send_to(**result, destination=ctx, author=ctx.author) 193 | -------------------------------------------------------------------------------- /cogs/owner/news.py: -------------------------------------------------------------------------------- 1 | from utils import DuckCog, DuckContext, HandleHTTPException, group 2 | 3 | 4 | class NewsManagement(DuckCog): 5 | 6 | @group() 7 | async def news(self, ctx): ... 8 | 9 | @news.command(hidden=True, hybrid=False) 10 | async def add(self, ctx: DuckContext, title: str, *, content: str): 11 | """Adds a news item to the news feed 12 | 13 | Parameters 14 | ---------- 15 | title: :class:`str` 16 | The title of the news item (up to 256 characters) 17 | content: :class:`str` 18 | The content of the news item (up to 1024 characters) 19 | """ 20 | async with ctx.bot.safe_connection() as conn: 21 | await conn.execute( 22 | "INSERT INTO news (news_id, title, content, author_id) VALUES ($1, $2, $3, $4)", 23 | ctx.message.id, 24 | title, 25 | content, 26 | ctx.author.id, 27 | ) 28 | 29 | async with HandleHTTPException(ctx): 30 | await ctx.message.add_reaction("\N{WHITE HEAVY CHECK MARK}") 31 | 32 | @news.command(hidden=True, hybrid=False) 33 | async def remove(self, ctx: DuckContext, news_id: int): 34 | """Removes a news item from the news feed 35 | 36 | Parameters 37 | ---------- 38 | news_id: :class:`int` 39 | The snowflake ID of the news item to remove 40 | """ 41 | async with ctx.bot.safe_connection() as conn: 42 | query = """ 43 | WITH deleted AS ( 44 | DELETE FROM news WHERE news_id = $1 RETURNING * 45 | ) SELECT COUNT(*) FROM deleted 46 | """ 47 | removed = await conn.fetchval(query, news_id) 48 | 49 | async with HandleHTTPException(ctx): 50 | await ctx.message.add_reaction("\N{WHITE HEAVY CHECK MARK}" if removed else "\N{WARNING SIGN}") 51 | -------------------------------------------------------------------------------- /cogs/owner/sql.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import io 3 | 4 | import time 5 | from tabulate import tabulate 6 | from typing import List, Annotated 7 | 8 | from import_expression import eval 9 | from discord import File 10 | from discord.ext.commands import FlagConverter, flag, Converter 11 | from utils import DuckCog, DuckContext, command, UntilFlag 12 | 13 | from .eval import cleanup_code 14 | 15 | 16 | class plural: 17 | def __init__(self, value): 18 | self.value = value 19 | 20 | def __format__(self, format_spec): 21 | v = self.value 22 | singular, _, plural = format_spec.partition('|') 23 | plural = plural or f'{singular}s' 24 | if abs(v) != 1: 25 | return f'{v} {plural}' 26 | return f'{v} {singular}' 27 | 28 | 29 | class EvaluatedArg(Converter): 30 | async def convert(self, ctx: DuckContext, argument: str) -> str: 31 | return eval(cleanup_code(argument), {'bot': ctx.bot, 'ctx': ctx}) 32 | 33 | 34 | class SqlCommandFlags(FlagConverter, prefix="--", delimiter=" ", case_insensitive=True): 35 | args: List[str] = flag(name='argument', aliases=['a', 'arg'], converter=List[EvaluatedArg], default=[]) 36 | 37 | 38 | class SQLCommands(DuckCog): 39 | @command() 40 | async def sql(self, ctx: DuckContext, *, query: UntilFlag[Annotated[str, cleanup_code], SqlCommandFlags]): 41 | """Executes an SQL query 42 | 43 | Parameters 44 | ---------- 45 | query: str 46 | The query to execute. 47 | """ 48 | is_multistatement = query.value.count(';') > 1 49 | if is_multistatement: 50 | # fetch does not support multiple statements 51 | strategy = ctx.bot.pool.execute 52 | else: 53 | strategy = ctx.bot.pool.fetch 54 | 55 | try: 56 | start = time.perf_counter() 57 | results = await strategy(query.value, *query.flags.args) 58 | dt = (time.perf_counter() - start) * 1000.0 59 | except Exception as e: 60 | return await ctx.send(f'{type(e).__name__}: {e}') 61 | 62 | rows = len(results) 63 | if rows == 0 or isinstance(results, str): 64 | result = 'Query returned 0 rows' if rows == 0 else str(results) 65 | await ctx.send(f'`{result}`\n*Ran in {dt:.2f}ms*') 66 | 67 | else: 68 | table = tabulate(results, headers='keys', tablefmt='orgtbl') 69 | 70 | fmt = f'```\n{table}\n```*Returned {plural(rows):row} in {dt:.2f}ms*' 71 | if len(fmt) > 2000: 72 | fp = io.BytesIO(table.encode('utf-8')) 73 | await ctx.send( 74 | f'*Too many results...\nReturned {plural(rows):row} in {dt:.2f}ms*', file=File(fp, 'output.txt') 75 | ) 76 | else: 77 | await ctx.send(fmt) 78 | -------------------------------------------------------------------------------- /cogs/owner/test_shit.py: -------------------------------------------------------------------------------- 1 | from utils import DuckCog 2 | 3 | 4 | class TestingShit(DuckCog): ... 5 | -------------------------------------------------------------------------------- /cogs/owner/update.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import importlib 3 | import logging 4 | import re 5 | import traceback 6 | from typing import List, Optional 7 | from bot import DuckBot 8 | from utils import DuckCog, DuckContext, Shell, group 9 | from jishaku.paginators import WrappedPaginator 10 | from discord.ext.commands import ExtensionNotLoaded, ExtensionNotFound, NoEntryPointError, Paginator 11 | 12 | 13 | FILENAME_PATTERN = re.compile('\\s*(?P.+?)\\s*\\|\\s*[0-9]+\\s*[+-]+') 14 | COGS_PATTERN = re.compile('cogs\\/\\w+\\/|cogs\\/\\w+\\.py') 15 | 16 | 17 | @dataclass() 18 | # It's a class that represents a module to be reloaded. 19 | class Module: 20 | path: str 21 | exception: Optional[Exception] = None 22 | 23 | def is_extension(self, bot: DuckBot) -> bool: 24 | return self.name.startswith('cogs.') or self.name in bot.extensions 25 | 26 | @property 27 | def name(self) -> str: 28 | logging.info('matching path %s', self.path) 29 | match = COGS_PATTERN.match(self.path) 30 | if match: 31 | logging.info('got a match for %s', match.group()) 32 | ret = match.group().replace('/', '.').removesuffix('.py').strip('.') 33 | else: 34 | ret = self.path.replace('/', '.').removesuffix('.py').strip('.') 35 | logging.info('returning %s', ret) 36 | return ret 37 | 38 | @property 39 | def failed(self): 40 | return self.exception is not None 41 | 42 | 43 | def fmt(exc: Exception) -> str: 44 | lines = traceback.format_exception(type(exc), exc, exc.__traceback__) 45 | return ''.join(lines) 46 | 47 | 48 | def wrap(text: str, language: str = 'py') -> List[str]: 49 | paginator = WrappedPaginator(prefix=f'```{language}' + language, suffix='```', force_wrap=True) 50 | for line in text.splitlines(): 51 | paginator.add_line(line) 52 | return paginator.pages 53 | 54 | 55 | class ExtensionsManager(DuckCog): 56 | def find_modules_to_reload(self, output: str) -> List[Module]: 57 | """Returns a dictionary of filenames to module names to reload. 58 | 59 | Parameters 60 | ---------- 61 | output : str 62 | The output of the `git pull` command. 63 | 64 | Returns 65 | ------- 66 | List[Module] 67 | A list of modules to reload. 68 | """ 69 | return [Module(path=m) for m in FILENAME_PATTERN.findall(output)] 70 | 71 | async def try_reload(self, name: str) -> str: 72 | '''It tries to reload an extension, and if it fails, it loads it 73 | 74 | Parameters 75 | ---------- 76 | name : str 77 | The name of the extension to reload. 78 | 79 | Returns 80 | ------- 81 | The emoji representing the action taken. 82 | If it reloads it will be clockwise arrows, 83 | If it loads it will be an inbox tray. 84 | ''' 85 | try: 86 | await self.bot.reload_extension(name) 87 | return "\N{CLOCKWISE RIGHTWARDS AND LEFTWARDS OPEN CIRCLE ARROWS}" 88 | except ExtensionNotLoaded: 89 | await self.bot.load_extension(name) 90 | return "\N{INBOX TRAY}" 91 | 92 | async def reload_to_page(self, extension: str, *, paginator: Paginator) -> None: 93 | '''It reloads an extension and adds a line to a paginator 94 | 95 | Parameters 96 | ---------- 97 | extension 98 | The name of the extension to reload. 99 | paginator : Paginator 100 | Paginator 101 | ''' 102 | try: 103 | emoji = await self.try_reload(extension) 104 | paginator.add_line(f'{emoji} `{extension}`') 105 | except NoEntryPointError: 106 | paginator.add_line(f'\N{CROSS MARK} `{extension}` (has no `setup` function)') 107 | except ExtensionNotFound: 108 | paginator.add_line(f'\N{BLACK QUESTION MARK ORNAMENT} `{extension}`') 109 | except Exception as e: 110 | paginator.add_line(f'\N{CROSS MARK} `{extension}`') 111 | paginator.add_line(f"```py\n{fmt(e)}\n```") 112 | 113 | @group(invoke_without_command=True) 114 | async def reload(self, ctx: DuckContext, *extensions: str): 115 | '''It reloads extensions 116 | 117 | Parameters 118 | ---------- 119 | extensions: str 120 | The extensions to reload. 121 | ''' 122 | paginator = WrappedPaginator(prefix='', suffix='', force_wrap=True) 123 | for extension in extensions: 124 | await self.reload_to_page(extension, paginator=paginator) 125 | for page in paginator.pages: 126 | await ctx.send(page) 127 | 128 | @reload.command(name='git') 129 | async def reload_git(self, ctx: DuckContext): 130 | '''Updates the bot. 131 | 132 | This command will pull from github, and then reload the modules of the bot that have changed. 133 | ''' 134 | shell = Shell('git pull') 135 | stdout = (await shell.run()).stdout 136 | 137 | modules = self.find_modules_to_reload(stdout) 138 | 139 | for module in sorted(modules, key=lambda m: m.is_extension(ctx.bot), reverse=True): 140 | try: 141 | if module.is_extension(ctx.bot): 142 | emoji = await self.try_reload(module.name) 143 | stdout = stdout.replace(f' {module.path}', module.path).replace(module.path, f"{emoji}{module.path}") 144 | else: 145 | logging.info('module reload of %s - %s', module.path, module.name) 146 | m = importlib.import_module(module.name) 147 | importlib.reload(m) 148 | stdout = stdout.replace(f' {module.path}', module.path).replace( 149 | module.path, f"\N{WHITE HEAVY CHECK MARK}{module.path}" 150 | ) 151 | except Exception as e: 152 | stdout = stdout.replace(f' {module.path}', module.path).replace(module.path, f"\N{CROSS MARK}{module.path}") 153 | module.exception = e 154 | 155 | paginator = WrappedPaginator(prefix='', suffix='', force_wrap=True) 156 | for page in wrap(stdout, language='sh'): 157 | paginator.add_line(page) 158 | paginator.add_line() 159 | 160 | for module in filter(lambda m: m.failed, modules): 161 | assert module.exception is not None 162 | paginator.add_line(f"\N{WARNING SIGN} {module.path}") 163 | paginator.add_line(f"```py\n{fmt(module.exception)}\n```", empty=True) 164 | 165 | for page in paginator.pages: 166 | await ctx.send(page) 167 | 168 | @reload.command(name='all') 169 | async def reload_all(self, ctx: DuckContext): 170 | '''Reloads all extensions.''' 171 | paginator = WrappedPaginator(prefix='', suffix='', force_wrap=True) 172 | for extension in list(self.bot.extensions.keys()): 173 | await self.reload_to_page(extension, paginator=paginator) 174 | for page in paginator.pages: 175 | await ctx.send(page) 176 | 177 | @reload.command(name='module', aliases=['modules']) 178 | async def reload_module(self, ctx: DuckContext, *modules: str): 179 | '''Reloads a module. 180 | 181 | Parameters 182 | ---------- 183 | module : str 184 | The module to reload. 185 | 186 | ''' 187 | paginator = WrappedPaginator(prefix='', suffix='', force_wrap=True) 188 | for module in modules: 189 | try: 190 | m = importlib.import_module(module) 191 | importlib.reload(m) 192 | paginator.add_line(f"\N{CLOCKWISE RIGHTWARDS AND LEFTWARDS OPEN CIRCLE ARROWS} {module}") 193 | except Exception as e: 194 | paginator.add_line(f"\N{CROSS MARK} {module}") 195 | paginator.add_line(f"```py\n{fmt(e)}\n```", empty=True) 196 | for page in paginator.pages: 197 | await ctx.send(page) 198 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pyright] 2 | useLibraryCodeForTypes = true 3 | typeCheckingMode = "basic" 4 | pythonVersion = "3.11.2" 5 | strictListInference = true 6 | strictDictionaryInference = true 7 | strictSetInference = true 8 | strictParameterNoneValue = true 9 | reportMissingImports = "error" 10 | reportUnusedImport = "error" 11 | reportUnusedClass = "error" 12 | reportUnusedFunction = "error" 13 | reportUnusedVariable = "error" 14 | reportGeneralTypeIssues = "error" 15 | reportFunctionMemberAccess = "error" 16 | reportDuplicateImport = "error" 17 | reportUntypedFunctionDecorator = "error" 18 | reportUnnecessaryTypeIgnoreComment = "warning" 19 | reportShadowedImports = "none" 20 | reportPossiblyUnboundVariable = "error" 21 | 22 | [tool.black] 23 | line-length = 125 24 | skip-string-normalization = true 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/rapptz/discord.py 2 | git+https://github.com/rapptz/discord-ext-menus 3 | git+https://github.com/gorialis/jishaku 4 | numpydoc 5 | cachetools 6 | fuzzywuzzy 7 | python-dotenv 8 | parsedatetime 9 | python-dateutil 10 | humanize 11 | asyncpg 12 | asyncpg-stubs 13 | lru-dict 14 | aiofile 15 | typing_extensions 16 | python-Levenshtein 17 | psutil 18 | tabulate 19 | click -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | -- noinspection SpellCheckingInspectionForFile 2 | 3 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 4 | 5 | CREATE TABLE IF NOT EXISTS guilds ( 6 | guild_id BIGINT PRIMARY KEY, 7 | prefixes TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], 8 | muted_role_id BIGINT, 9 | muted_role_mode INT DEFAULT 0, 10 | min_join_age BIGINT, 11 | mutes BIGINT[] NOT NULL DEFAULT ARRAY[]::BIGINT[] 12 | ); 13 | 14 | CREATE TABLE IF NOT EXISTS news ( 15 | news_id BIGINT PRIMARY KEY, 16 | title VARCHAR(256) NOT NULL, 17 | content VARCHAR(1024) NOT NULL, 18 | author_id BIGINT NOT NULL 19 | ); 20 | 21 | CREATE TABLE IF NOT EXISTS timers ( 22 | id BIGSERIAL PRIMARY KEY, 23 | precise BOOLEAN DEFAULT TRUE, 24 | event TEXT, 25 | extra JSONB, 26 | created TIMESTAMP, 27 | expires TIMESTAMP 28 | ); 29 | 30 | CREATE TABLE IF NOT EXISTS blocks ( 31 | guild_id BIGINT, 32 | channel_id BIGINT, 33 | user_id BIGINT, 34 | PRIMARY KEY (guild_id, channel_id, user_id) 35 | ); 36 | 37 | DO $$ BEGIN 38 | CREATE TYPE blacklist_type AS ENUM ('guild', 'channel', 'user'); 39 | EXCEPTION 40 | WHEN duplicate_object THEN null; 41 | END$$; 42 | 43 | 44 | CREATE TABLE IF NOT EXISTS blacklist ( 45 | blacklist_type blacklist_type, 46 | entity_id bigint, 47 | guild_id bigint NOT NULL default 0, 48 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 49 | PRIMARY KEY (blacklist_type, entity_id, guild_id) 50 | ); 51 | 52 | 53 | CREATE TABLE IF NOT EXISTS badges ( 54 | badge_id BIGSERIAL PRIMARY KEY, 55 | name TEXT NOT NULL, 56 | emoji TEXT NOT NULL 57 | ); 58 | 59 | CREATE TABLE acknowledgements ( 60 | user_id BIGINT, 61 | badge_id BIGINT REFERENCES badges(badge_id) ON DELETE CASCADE, 62 | PRIMARY KEY (user_id, badge_id) 63 | ); 64 | 65 | -- Functions that are dispatched to a listener 66 | -- that updates the prefix cache automatically 67 | CREATE OR REPLACE FUNCTION update_prefixes_cache() 68 | RETURNS TRIGGER AS $$ 69 | BEGIN 70 | IF TG_OP = 'DELETE' THEN 71 | PERFORM pg_notify('delete_prefixes', NEW.guild_id::TEXT); 72 | ELSIF TG_OP = 'UPDATE' AND OLD.prefixes <> NEW.prefixes THEN 73 | PERFORM pg_notify('update_prefixes', 74 | JSON_BUILD_OBJECT( 75 | 'guild_id', NEW.guild_id, 76 | 'prefixes', NEW.prefixes 77 | )::TEXT 78 | ); 79 | ELSIF TG_OP = 'INSERT' AND NEW.prefixes <> ARRAY[]::TEXT[] THEN 80 | PERFORM pg_notify('update_prefixes', 81 | JSON_BUILD_OBJECT( 82 | 'guild_id', NEW.guild_id, 83 | 'prefixes', NEW.prefixes 84 | )::TEXT 85 | ); 86 | END IF; 87 | RETURN NEW; 88 | END; 89 | $$ LANGUAGE plpgsql; 90 | 91 | CREATE TRIGGER update_prefixes_cache_trigger 92 | AFTER INSERT OR UPDATE OR DELETE 93 | ON guilds 94 | FOR EACH ROW 95 | EXECUTE PROCEDURE update_prefixes_cache(); 96 | 97 | -- For tags. 98 | CREATE TABLE IF NOT EXISTS tags ( 99 | id BIGSERIAL, 100 | name VARCHAR(200), 101 | content VARCHAR(2000), 102 | owner_id BIGINT, 103 | guild_id BIGINT, 104 | uses INT DEFAULT 0, 105 | created_at TIMESTAMP WITH TIME ZONE 106 | NOT NULL DEFAULT NOW(), 107 | points_to BIGINT 108 | REFERENCES tags(id) 109 | ON DELETE CASCADE, 110 | embed JSONB, 111 | PRIMARY KEY (id), 112 | UNIQUE (name, guild_id), 113 | CONSTRAINT tags_mutually_excl_cnt_p_to CHECK ( 114 | ((content IS NOT NULL OR embed IS NOT NULL) and points_to IS NULL) 115 | OR (points_to IS NOT NULL and (content IS NULL AND embed IS NULL)) 116 | ) 117 | ); 118 | 119 | CREATE INDEX IF NOT EXISTS tags_name_ind ON tags (name); 120 | CREATE INDEX IF NOT EXISTS tags_location_id_ind ON tags (guild_id); 121 | -- noinspection SqlResolve 122 | CREATE INDEX IF NOT EXISTS tags_name_trgm_ind ON tags USING GIN (name gin_trgm_ops); 123 | CREATE INDEX IF NOT EXISTS tags_name_lower_ind ON tags (LOWER(name)); 124 | CREATE UNIQUE INDEX IF NOT EXISTS tags_uniq_ind ON tags (LOWER(name), guild_id); 125 | 126 | CREATE TABLE commands ( 127 | user_id BIGINT NOT NULL, 128 | guild_id BIGINT, 129 | command TEXT NOT NULL , 130 | timestamp TIMESTAMP WITH TIME ZONE 131 | NOT NULL DEFAULT NOW() 132 | ); 133 | 134 | CREATE TABLE auto_sync ( 135 | guild_id BIGINT, 136 | payload JSONB 137 | ); 138 | 139 | 140 | CREATE TABLE user_settings ( 141 | user_id BIGINT PRIMARY KEY, 142 | locale TEXT 143 | ); 144 | 145 | CREATE TABLE dm_flow ( 146 | user_id BIGINT PRIMARY KEY, 147 | dms_enabled BOOLEAN NOT NULL DEFAULT FALSE, 148 | dm_channel BIGINT NULL, 149 | dm_webhook TEXT NULL 150 | ); 151 | 152 | -- From https://github.com/Rapptz/RoboDanny/blob/rewrite/migrations/V1__Initial_migration.sql 153 | CREATE TABLE IF NOT EXISTS plonks ( 154 | id SERIAL PRIMARY KEY, 155 | guild_id BIGINT, 156 | entity_id BIGINT UNIQUE 157 | ); 158 | 159 | CREATE INDEX IF NOT EXISTS plonks_guild_id_idx ON plonks (guild_id); 160 | CREATE INDEX IF NOT EXISTS plonks_entity_id_idx ON plonks (entity_id); 161 | 162 | CREATE TABLE IF NOT EXISTS command_config ( 163 | id SERIAL PRIMARY KEY, 164 | guild_id BIGINT, 165 | channel_id BIGINT, 166 | name TEXT, 167 | whitelist BOOLEAN 168 | ); 169 | 170 | CREATE INDEX IF NOT EXISTS command_config_guild_id_idx ON command_config (guild_id); 171 | -- End -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | from utils.bases.base_cog import * 2 | from utils.bases.blacklist import * 3 | from utils.bases.command import * 4 | from utils.bases.context import * 5 | from utils.bases.errors import * 6 | from utils.bases.ipc_base import * 7 | from utils.bases.timer import * 8 | from .types import constants as constants 9 | 10 | from . import interactions as interactions 11 | from .cache import * 12 | from .converters import * 13 | from .errorhandler import * 14 | from .helpers import * 15 | from .logging import * 16 | from .paginators import * 17 | from .time import * 18 | from .checks import * 19 | -------------------------------------------------------------------------------- /utils/bases/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DuckBot-Discord/DuckBot/acf762485815e2298479ad3cb1ab8f290b35e2a2/utils/bases/__init__.py -------------------------------------------------------------------------------- /utils/bases/autocomplete.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from fuzzywuzzy import process 5 | from typing import Any, Callable, Iterable, List, Optional, Tuple, Union, Awaitable, TYPE_CHECKING 6 | 7 | import discord 8 | from discord.ext import commands 9 | 10 | from .context import DuckContext 11 | 12 | if TYPE_CHECKING: 13 | from bot import DuckBot 14 | 15 | RestrictedType = Union[Iterable[Any], Callable[[DuckContext], Union[Iterable[Any], Awaitable[Iterable[Any]]]]] 16 | 17 | 18 | class PromptSelect(discord.ui.Select): 19 | def __init__(self, parent: PromptView, matches: List[Tuple[int, str]]) -> None: 20 | super().__init__( 21 | placeholder='Select an option below...', 22 | options=[ 23 | discord.SelectOption(label=str(match), description=f'{probability}% chance.') 24 | for match, probability in matches 25 | ], 26 | ) 27 | self.parent: PromptView = parent 28 | 29 | async def callback(self, interaction: discord.Interaction[DuckBot]) -> None: 30 | assert interaction.message is not None 31 | 32 | await interaction.response.defer(thinking=True) 33 | selected = self.values 34 | if not selected: 35 | return 36 | 37 | self.parent.item = selected[0] 38 | await interaction.delete_original_response() 39 | await interaction.message.delete() 40 | 41 | self.parent.stop() 42 | 43 | 44 | class PromptView(discord.ui.View): 45 | def __init__( 46 | self, 47 | *, 48 | ctx: DuckContext, 49 | matches: List[Tuple[int, str]], 50 | param: inspect.Parameter, 51 | value: str, 52 | ) -> None: 53 | super().__init__() 54 | self.ctx: DuckContext = ctx 55 | self.matches: List[Tuple[int, str]] = matches 56 | self.param: inspect.Parameter = param 57 | self.value: str = value 58 | self.item: Optional[str] = None 59 | 60 | self.add_item(PromptSelect(self, matches)) 61 | 62 | async def interaction_check(self, interaction: discord.Interaction[DuckBot]) -> bool: 63 | return interaction.user == self.ctx.author 64 | 65 | @property 66 | def embed(self) -> discord.Embed: 67 | # NOTE: Leo add more here 68 | embed = discord.Embed(title='That\'s not quite right!') 69 | if self.value is not None: 70 | embed.description = f'`{self.value}` is not a valid response to the option named `{self.param.name}`, you need to select one of the following options below.' 71 | else: 72 | embed.description = f'You did not enter a value for the option named `{self.param.name}`, you need to select one of the following options below.' 73 | 74 | return embed 75 | 76 | 77 | class AutoComplete: 78 | def __init__(self, func: Callable[..., Any], param_name: str) -> None: 79 | self.callback: Callable[..., Any] = func 80 | self.param_name: str = param_name 81 | 82 | async def prompt_correct_input( 83 | self, ctx: DuckContext, param: inspect.Parameter, /, *, value: str, constricted: Iterable[Any] 84 | ) -> str: 85 | assert ctx.command is not None 86 | 87 | # The user did not enter a correct value 88 | # Find a suggestion 89 | if isinstance(value, (str, bytes)): 90 | result = await ctx.bot.wrap(process.extract, value, constricted) 91 | else: 92 | result = [(item, 0) for item in constricted] 93 | 94 | view = PromptView(ctx=ctx, matches=result, param=param, value=value) # type: ignore 95 | await ctx.send(embed=view.embed, view=view) 96 | await view.wait() 97 | 98 | if view.item is None: 99 | raise commands.CommandError('You took too long, you need to redo this command.') 100 | 101 | return view.item 102 | -------------------------------------------------------------------------------- /utils/bases/base_cog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | from typing_extensions import Self 4 | 5 | import uuid 6 | from typing import ( 7 | TYPE_CHECKING, 8 | Any, 9 | List, 10 | Optional, 11 | Type, 12 | Tuple, 13 | ) 14 | 15 | from discord.ext import commands 16 | 17 | from utils.bases.command import DuckCommand 18 | 19 | if TYPE_CHECKING: 20 | from bot import DuckBot 21 | 22 | __all__: Tuple[str, ...] = ("DuckCog",) 23 | 24 | 25 | class DuckCog(commands.Cog): 26 | """The base class for all DuckBot cogs. 27 | 28 | Attributes 29 | ---------- 30 | bot: DuckBot 31 | The bot instance. 32 | """ 33 | 34 | if TYPE_CHECKING: 35 | emoji: Optional[str] 36 | brief: Optional[str] 37 | hidden: bool 38 | 39 | __slots__: Tuple[str, ...] = ("bot", "hidden") 40 | 41 | def __init_subclass__(cls: Type[DuckCog], **kwargs: Any) -> None: 42 | """ 43 | This is called when a subclass is created. 44 | Its purpose is to add parameters to the cog 45 | that will later be used in the help command. 46 | """ 47 | cls.emoji = kwargs.pop("emoji", None) 48 | cls.brief = kwargs.pop("brief", None) 49 | cls.hidden = kwargs.pop("hidden", False) 50 | return super().__init_subclass__(**kwargs) 51 | 52 | def __init__(self, bot: DuckBot, *args: Any, **kwargs: Any) -> None: 53 | self.bot: DuckBot = bot 54 | self.id: int = int(str(int(uuid.uuid4()))[:20]) 55 | 56 | next_in_mro = next(iter(self.__class__.__mro__)) 57 | if hasattr(next_in_mro, "__is_jishaku__") or isinstance(next_in_mro, self.__class__): 58 | kwargs["bot"] = bot 59 | 60 | super().__init__(*args, **kwargs) 61 | 62 | @property 63 | def logger(self): 64 | return logging.getLogger(f"{__name__}.{self.__class__.__name__}") 65 | 66 | def get_commands(self) -> List[DuckCommand[Self, ..., Any]]: 67 | return super().get_commands() # type: ignore 68 | -------------------------------------------------------------------------------- /utils/bases/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import deepcopy 4 | from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union, overload, Literal 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | from .errors import SilentCommandError 10 | 11 | if TYPE_CHECKING: 12 | from bot import DuckBot 13 | from discord.message import Message 14 | 15 | 16 | __all__: Tuple[str, ...] = ( 17 | 'DuckContext', 18 | 'DuckGuildContext', 19 | 'tick', 20 | ) 21 | 22 | 23 | VALID_EDIT_KWARGS: Dict[str, Any] = { 24 | 'content': None, 25 | 'embeds': [], 26 | 'attachments': [], 27 | 'suppress': False, 28 | 'delete_after': None, 29 | 'allowed_mentions': None, 30 | 'view': None, 31 | } 32 | 33 | 34 | def tick(opt: Optional[bool], label: Optional[str] = None) -> str: 35 | """A function to convert a boolean value with label to an emoji with label. 36 | 37 | Parameters 38 | ---------- 39 | opt: Optional[:class:`bool`] 40 | The boolean value to convert. 41 | label: Optional[:class:`str`] 42 | The label to use for the emoji. 43 | 44 | Returns 45 | ------- 46 | :class:`str` 47 | The emoji with label. 48 | """ 49 | lookup = {True: '\N{WHITE HEAVY CHECK MARK}', False: '\N{CROSS MARK}', None: '\N{BLACK QUESTION MARK ORNAMENT}'} 50 | emoji = lookup.get(opt, '\N{CROSS MARK}') 51 | if label is not None: 52 | return f'{emoji} {label}' 53 | 54 | return emoji 55 | 56 | 57 | class ConfirmationView(discord.ui.View): 58 | def __init__(self, ctx: DuckContext, *, timeout: int = 60, labels: tuple[str, str] = ('Confirm', 'Cancel')) -> None: 59 | super().__init__(timeout=timeout) 60 | self.ctx = ctx 61 | self.value = None 62 | self.message: discord.Message | None = None 63 | self.ctx.bot.views.add(self) 64 | 65 | confirm, cancel = labels 66 | self.confirm.label = confirm 67 | self.cancel.label = cancel 68 | 69 | async def interaction_check(self, interaction: discord.Interaction[DuckBot]) -> bool: 70 | return interaction.user == self.ctx.author 71 | 72 | async def on_timeout(self) -> None: 73 | self.ctx.bot.views.discard(self) 74 | if self.message: 75 | for item in self.children: 76 | item.disabled = True # type: ignore 77 | 78 | await self.message.edit(content=f'Timed out waiting for a button press from {self.ctx.author}.', view=self) 79 | 80 | def stop(self) -> None: 81 | self.ctx.bot.views.discard(self) 82 | super().stop() 83 | 84 | @discord.ui.button(style=discord.ButtonStyle.primary) 85 | async def confirm(self, interaction: discord.Interaction[DuckBot], button: discord.ui.Button) -> None: 86 | assert interaction.message is not None 87 | 88 | self.value = True 89 | self.stop() 90 | await interaction.message.delete() 91 | 92 | @discord.ui.button(style=discord.ButtonStyle.danger) 93 | async def cancel(self, interaction: discord.Interaction[DuckBot], button: discord.ui.Button) -> None: 94 | assert interaction.message is not None 95 | 96 | self.value = False 97 | self.stop() 98 | await interaction.message.delete() 99 | 100 | 101 | class DuckContext(commands.Context['DuckBot']): 102 | """The subclassed Context to allow some extra functionality.""" 103 | 104 | if TYPE_CHECKING: 105 | bot: DuckBot 106 | guild: discord.Guild 107 | user: Optional[Union[discord.User, discord.Member]] 108 | 109 | def __init__(self, *args, **kwargs) -> None: 110 | super().__init__(*args, **kwargs) 111 | self.is_error_handled = False 112 | self._message_count: int = 0 113 | self.user = self.author 114 | self.client = self.bot 115 | 116 | @staticmethod 117 | @discord.utils.copy_doc(tick) 118 | def tick(opt: Optional[bool], label: Optional[str] = None) -> str: 119 | return tick(opt, label) 120 | 121 | @discord.utils.cached_property 122 | def color(self) -> discord.Color: 123 | """:class:`~discord.Color`: Returns DuckBot's color, or the author's color. Falls back to blurple""" 124 | 125 | def check(color): 126 | return color not in {discord.Color.default(), None} 127 | 128 | checks = ( 129 | me_color if check(me_color := self.me.color) else None, 130 | you_color if check(you_color := self.author.color) else None, 131 | self.bot.color, 132 | ) 133 | 134 | result = discord.utils.find(lambda e: e, checks) 135 | if not result: 136 | raise RuntimeError('Unreachable code has been reached') 137 | 138 | return result 139 | 140 | async def send(self, content: str | None = None, *args: Any, **kwargs: Any) -> Message: 141 | """Sends a message to the invoking context's channel. 142 | 143 | View :meth:`~discord.ext.commands.Context.send` for more information of parameters. 144 | 145 | Returns 146 | ------- 147 | :class:`~discord.Message` 148 | The message that was created. 149 | """ 150 | if kwargs.get('embed') and kwargs.get('embeds'): 151 | raise TypeError('Cannot mix embed and embeds keyword arguments.') 152 | 153 | embeds = kwargs.pop('embeds', []) or ([kwargs.pop('embed')] if kwargs.get('embed', None) else []) 154 | if embeds: 155 | for embed in embeds: 156 | if embed.color is None: 157 | # Made this the bot's vanity colour, although we'll 158 | # be keeping self.color for other stuff like userinfo 159 | embed.color = self.bot.color 160 | 161 | kwargs['embeds'] = embeds 162 | 163 | if self._previous_message: 164 | new_kwargs = deepcopy(VALID_EDIT_KWARGS) 165 | new_kwargs['content'] = content 166 | new_kwargs.update(kwargs) 167 | edit_kw = {k: v for k, v in new_kwargs.items() if k in VALID_EDIT_KWARGS} 168 | attachments = new_kwargs.pop('files', []) or ([new_kwargs.pop('file')] if new_kwargs.get('file', None) else []) 169 | if attachments: 170 | edit_kw['attachments'] = attachments 171 | new_kwargs['files'] = attachments 172 | 173 | try: 174 | m = await self._previous_message.edit(**edit_kw) 175 | self._previous_message = m 176 | self._message_count += 1 177 | return m 178 | except discord.HTTPException: 179 | self._previous_message = None 180 | self._previous_message = m = await super().send(content, **kwargs) 181 | return m 182 | 183 | self._previous_message = m = await super().send(content, **kwargs) 184 | self._message_count += 1 185 | return m 186 | 187 | @property 188 | def _previous_message(self) -> Optional[discord.Message]: 189 | if self.message: 190 | try: 191 | return self.bot.messages[repr(self)] 192 | except KeyError: 193 | return None 194 | 195 | @_previous_message.setter 196 | def _previous_message(self, message: Optional[discord.Message]) -> None: 197 | if isinstance(message, discord.Message): 198 | self.bot.messages[repr(self)] = message 199 | else: 200 | self.bot.messages.pop(repr(self), None) 201 | 202 | @overload 203 | async def confirm( 204 | self, content=None, /, *, timeout: int = 30, silent_on_timeout: Literal[False] = False, **kwargs 205 | ) -> bool | None: ... 206 | 207 | @overload 208 | async def confirm(self, content=None, /, *, timeout: int = 30, silent_on_timeout: Literal[True], **kwargs) -> bool: ... 209 | 210 | async def confirm( 211 | self, 212 | content=None, 213 | /, 214 | *, 215 | timeout: int = 30, 216 | silent_on_timeout: bool = False, 217 | labels: tuple[str, str] = ('Confirm', 'Cancel'), 218 | **kwargs, 219 | ) -> bool | None: 220 | """Prompts a confirmation message that users can confirm or deny. 221 | 222 | Parameters 223 | ---------- 224 | content: str | None 225 | The content of the message. Can be an embed. 226 | timeout: int | None 227 | The timeout for the confirmation. 228 | kwargs: 229 | Additional keyword arguments to pass to `self.send`. 230 | 231 | Returns 232 | ------- 233 | :class:`bool` 234 | Whether the user confirmed or not. 235 | None if the view timed out. 236 | """ 237 | view = ConfirmationView(self, timeout=timeout, labels=labels) 238 | try: 239 | view.message = await self.channel.send(content, **kwargs, view=view) 240 | await view.wait() 241 | value = view.value 242 | except discord.HTTPException: 243 | view.stop() 244 | value = None 245 | 246 | if silent_on_timeout and value is None: 247 | raise SilentCommandError('Timed out waiting for a response.') 248 | return value 249 | 250 | def __repr__(self) -> str: 251 | if self.message: 252 | return f'' 253 | elif self.interaction: 254 | return f'' 255 | return super().__repr__() 256 | 257 | 258 | class DuckGuildContext(DuckContext): 259 | author: discord.Member 260 | 261 | 262 | async def setup(bot: DuckBot) -> None: 263 | """Sets up the DuckContext class. 264 | 265 | Parameters 266 | ---------- 267 | bot: DuckBot 268 | The bot to set up the DuckContext class for. 269 | """ 270 | bot.messages.clear() 271 | bot._context_cls = DuckContext 272 | 273 | 274 | async def teardown(bot: DuckBot) -> None: 275 | """Tears down the DuckContext class. 276 | 277 | Parameters 278 | ---------- 279 | bot: DuckBot 280 | The bot to tear down the DuckContext class for. 281 | """ 282 | bot._context_cls = commands.Context 283 | -------------------------------------------------------------------------------- /utils/bases/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import typing 5 | from typing import Tuple, Type 6 | 7 | import discord 8 | from discord.ext.commands import BadArgument, CheckFailure, CommandError 9 | 10 | from utils.types import DiscordMedium 11 | 12 | log = logging.getLogger('Duckbot.utils.errors') 13 | 14 | __all__: Tuple[str, ...] = ( 15 | 'DuckBotException', 16 | 'DuckNotFound', 17 | 'DuckBotCommandError', 18 | 'DuckBotNotStarted', 19 | 'HierarchyException', 20 | 'ActionNotExecutable', 21 | 'TimerError', 22 | 'TimerNotFound', 23 | 'MuteException', 24 | 'MemberNotMuted', 25 | 'MemberAlreadyMuted', 26 | 'NoMutedRole', 27 | 'SilentCommandError', 28 | 'EntityBlacklisted', 29 | ) 30 | 31 | 32 | class DuckBotException(discord.ClientException): 33 | """The base exception for DuckBot. All other exceptions should inherit from this.""" 34 | 35 | __slots__: Tuple[str, ...] = () 36 | 37 | 38 | class DuckNotFound(DuckBotException): 39 | """An Exception raised when DuckBot couuld not be found.""" 40 | 41 | __slots__: Tuple[str, ...] = () 42 | 43 | 44 | class DuckBotCommandError(CommandError, DuckBotException): 45 | """The base exception for DuckBot command errors.""" 46 | 47 | __slots__: Tuple[str, ...] = () 48 | 49 | 50 | class DuckBotNotStarted(DuckBotException): 51 | """An exeption that gets raised when a method tries to use :attr:`Duckbot.user` before 52 | DuckBot is ready. 53 | """ 54 | 55 | __slots__: Tuple[str, ...] = () 56 | 57 | 58 | class HierarchyException(DuckBotCommandError): 59 | """Raised when DuckBot is requested to perform an operation on a member 60 | that is higher than them in the guild hierarchy. 61 | """ 62 | 63 | __slots__: Tuple[str, ...] = ( 64 | 'target', 65 | 'author_error', 66 | ) 67 | 68 | def __init__(self, target: discord.Member | discord.Role, *, author_error: bool = False) -> None: 69 | self.target: discord.Member | discord.Role = target 70 | self.author_error: bool = author_error 71 | if isinstance(target, discord.Member): 72 | if author_error is False: 73 | super().__init__(f'**{target}**\'s top role is higher than mine. I can\'t do that.') 74 | else: 75 | super().__init__(f'**{target}**\'s top role is higher than your top role. You can\'t do that.') 76 | else: 77 | if author_error: 78 | super().__init__(f'Role **{target}** is higher than your top role. You can\'t do that.') 79 | else: 80 | super().__init__(f'Role **{target}** is higher than my top role. I can\'t do that.') 81 | 82 | 83 | class ActionNotExecutable(DuckBotCommandError): 84 | def __init__(self, message): 85 | super().__init__(f'{message}') 86 | 87 | 88 | class TimerError(DuckBotException): 89 | """The base for all timer base exceptions. Every Timer based error should inherit 90 | from this. 91 | """ 92 | 93 | __slots__: Tuple[str, ...] = () 94 | 95 | 96 | class TimerNotFound(TimerError): 97 | """Raised when trying to fetch a timer that does not exist.""" 98 | 99 | __slots__: Tuple[str, ...] = ('id',) 100 | 101 | def __init__(self, id: int) -> None: 102 | self.id: int = id 103 | super().__init__(f'Timer with ID {id} not found.') 104 | 105 | 106 | class MuteException(DuckBotCommandError): 107 | """Raised whenever an operation related to a mute fails.""" 108 | 109 | pass 110 | 111 | 112 | class MemberNotMuted(MuteException): 113 | """Raised when trying to unmute a member that is not muted.""" 114 | 115 | __slots__: Tuple[str, ...] = ('member',) 116 | 117 | def __init__(self, member: discord.Member) -> None: 118 | self.member: discord.Member = member 119 | super().__init__(f'{member} is not muted.') 120 | 121 | 122 | class MemberAlreadyMuted(MuteException): 123 | """Raised when trying to mute a member that is already muted.""" 124 | 125 | __slots__: Tuple[str, ...] = ('member',) 126 | 127 | def __init__(self, member: discord.Member) -> None: 128 | self.member: discord.Member = member 129 | super().__init__(f'{member} is already muted.') 130 | 131 | 132 | class NoMutedRole(MuteException): 133 | """Raised when a guild does not have a muted role.""" 134 | 135 | __slots__: Tuple[str, ...] = tuple() 136 | 137 | def __init__(self) -> None: 138 | super().__init__('This server doesn\'t have a mute role configured. Run `db.muterole` for more info.') 139 | 140 | 141 | class SilentCommandError(DuckBotCommandError): 142 | """This exception will be purposely ignored by the error handler 143 | and will not be logged. Handy for stopping something that can't 144 | be stopped with a simple ``return`` statement. 145 | """ 146 | 147 | __slots__: Tuple[str, ...] = () 148 | 149 | 150 | class EntityBlacklisted(CheckFailure, DuckBotCommandError): 151 | """Raised when an entity is blacklisted.""" 152 | 153 | __slots__: Tuple[str, ...] = ('entity',) 154 | 155 | def __init__( 156 | self, 157 | entity: typing.Union[ 158 | discord.User, 159 | discord.Member, 160 | discord.Guild, 161 | discord.abc.GuildChannel, 162 | ], 163 | ) -> None: 164 | self.entity = entity 165 | super().__init__(f'{entity} is blacklisted.') 166 | 167 | 168 | class PartialMatchFailed(DuckBotCommandError, BadArgument): 169 | """Raised when the PartiallyMatch converter fails""" 170 | 171 | def __init__(self, argument: str, types: Tuple[Type[DiscordMedium]]): 172 | self.argument = argument 173 | self.converters = types 174 | 175 | types_as_str = ", ".join(_type.__name__ for _type in types) 176 | 177 | super().__init__(f'Could not find "{argument}" as: {types_as_str}') 178 | -------------------------------------------------------------------------------- /utils/bases/ipc.py: -------------------------------------------------------------------------------- 1 | from logging import getLogger 2 | from bot import DuckBot 3 | from utils.ipc_routes import DuckBotIPC 4 | 5 | 6 | log = getLogger('DuckBot.ipc') 7 | 8 | 9 | async def setup(bot: DuckBot): 10 | bot.ipc = DuckBotIPC(bot) 11 | try: 12 | await bot.ipc.start(port=4435) 13 | except: 14 | log.critical('failed to start IPC') 15 | raise 16 | else: 17 | log.info('Started IPC server.') 18 | 19 | 20 | async def teardown(bot: DuckBot): 21 | log.info('Stopping IPC server.') 22 | try: 23 | await bot.ipc.close() # type: ignore 24 | finally: 25 | bot.ipc = None 26 | -------------------------------------------------------------------------------- /utils/bases/ipc_base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import logging 3 | from typing import ( 4 | TYPE_CHECKING, 5 | Any, 6 | Callable, 7 | List, 8 | Literal, 9 | NamedTuple, 10 | Optional, 11 | Tuple, 12 | TypeVar, 13 | ) 14 | 15 | from aiohttp import web 16 | 17 | if TYPE_CHECKING: 18 | from bot import DuckBot 19 | else: 20 | from discord.ext.commands import Bot as DuckBot 21 | 22 | __all__: Tuple[str, ...] = ("IPCBase", "route") 23 | 24 | FuncT = TypeVar("FuncT", bound="Callable[..., Any]") 25 | 26 | 27 | class Route(NamedTuple): 28 | name: str 29 | method: str 30 | func: Callable[..., Any] 31 | 32 | 33 | def route(name: str, *, method: Literal["get", "post", "put", "patch", "delete"]) -> Callable[[FuncT], FuncT]: 34 | def decorator(func: FuncT) -> FuncT: 35 | actual = func 36 | if isinstance(actual, staticmethod): 37 | actual = actual.__func__ 38 | if not inspect.iscoroutinefunction(actual): 39 | raise TypeError("Route function must be a coroutine.") 40 | 41 | actual.__ipc_route_name__ = name # type: ignore 42 | actual.__ipc_method__ = method # type: ignore 43 | return func 44 | 45 | return decorator 46 | 47 | 48 | class IPCBase: 49 | @property 50 | def logger(self): 51 | return logging.getLogger(f"{__name__}.{self.__class__.__name__}") 52 | 53 | def __init__(self, bot: DuckBot): 54 | self.bot: DuckBot = bot 55 | self.routes: List[Route] = [] 56 | 57 | self.app: web.Application = web.Application() 58 | self._runner = web.AppRunner(self.app) 59 | self._webserver: Optional[web.TCPSite] = None 60 | 61 | for attr in map(lambda x: getattr(self, x, None), dir(self)): 62 | if attr is None: 63 | continue 64 | if (name := getattr(attr, "__ipc_route_name__", None)) is not None: 65 | route: str = attr.__ipc_method__ 66 | self.routes.append(Route(func=attr, name=name, method=route)) 67 | 68 | self.app.add_routes([web.route(x.method, x.name, x.func) for x in self.routes]) 69 | 70 | async def start(self, *, port: int): 71 | self.logger.debug('Starting IPC runner.') 72 | await self._runner.setup() 73 | self.logger.debug('Starting IPC webserver.') 74 | self._webserver = web.TCPSite(self._runner, "localhost", port=port) 75 | await self._webserver.start() 76 | 77 | async def close(self): 78 | self.logger.debug('Cleaning up after IPCBase.') 79 | await self._runner.cleanup() 80 | if self._webserver: 81 | self.logger.debug('Closing IPC webserver.') 82 | await self._webserver.stop() 83 | -------------------------------------------------------------------------------- /utils/bases/launcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import aiohttp 5 | import asyncio 6 | 7 | from bot import DuckBot 8 | from dotenv import load_dotenv 9 | 10 | load_dotenv('utils/.env') 11 | # (jsk flags are now in the .env) 12 | 13 | 14 | def _get_or_fail(env_var: str) -> str: 15 | val = os.environ.get(env_var) 16 | if not val: 17 | raise RuntimeError(f'{env_var!r} not set in .env file. Set it.') 18 | return val 19 | 20 | 21 | TOKEN = _get_or_fail('TOKEN') 22 | URI = _get_or_fail('POSTGRES') 23 | ERROR_WH = _get_or_fail('ERROR_WEBHOOK_URL') 24 | 25 | 26 | async def run_bot() -> None: 27 | async with aiohttp.ClientSession() as session, DuckBot.temporary_pool(uri=URI) as pool, DuckBot( 28 | session=session, pool=pool, error_wh=ERROR_WH 29 | ) as duck: 30 | await duck.start(TOKEN, reconnect=True, verbose=False) 31 | 32 | 33 | if __name__ == '__main__': 34 | asyncio.run(run_bot()) 35 | -------------------------------------------------------------------------------- /utils/cache.py: -------------------------------------------------------------------------------- 1 | # This Source Code Form is subject to the terms of the Mozilla Public 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this 3 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 | # Original author: Rapptz: 5 | # https://github.com/Rapptz/RoboDanny/blob/rewrite/cogs/utils/cache.py 6 | 7 | import inspect 8 | import asyncio 9 | import enum 10 | import time 11 | 12 | from functools import wraps 13 | from typing import Tuple 14 | 15 | from lru import LRU 16 | 17 | 18 | __all__: Tuple[str, ...] = ( 19 | 'cache', 20 | 'Strategy', 21 | 'ExpiringCache', 22 | ) 23 | 24 | 25 | # noinspection PyShadowingNames 26 | def _wrap_and_store_coroutine(cache, key, coro): 27 | async def func(): 28 | value = await coro 29 | cache[key] = value 30 | return value 31 | 32 | return func() 33 | 34 | 35 | def _wrap_new_coroutine(value): 36 | async def new_coroutine(): 37 | return value 38 | 39 | return new_coroutine() 40 | 41 | 42 | class ExpiringCache(dict): 43 | def __init__(self, seconds): 44 | self.__ttl = seconds 45 | super().__init__() 46 | 47 | def __verify_cache_integrity(self): 48 | # Have to do this in two steps... 49 | current_time = time.monotonic() 50 | to_remove = [k for (k, (_, t)) in self.items() if current_time > (t + self.__ttl)] 51 | for k in to_remove: 52 | del self[k] 53 | 54 | def __contains__(self, key): 55 | self.__verify_cache_integrity() 56 | return super().__contains__(key) 57 | 58 | def __getitem__(self, key): 59 | self.__verify_cache_integrity() 60 | return super().__getitem__(key) 61 | 62 | def __setitem__(self, key, value): 63 | super().__setitem__(key, (value, time.monotonic())) 64 | 65 | 66 | class Strategy(enum.Enum): 67 | lru = 1 68 | raw = 2 69 | timed = 3 70 | 71 | 72 | def cache(maxsize=128, strategy=Strategy.lru, ignore_kwargs=False): 73 | def decorator(func): 74 | if strategy is Strategy.lru: 75 | _internal_cache = LRU(maxsize) 76 | _stats = _internal_cache.get_stats 77 | elif strategy is Strategy.raw: 78 | _internal_cache = {} 79 | 80 | def _stats() -> Tuple[int, int]: 81 | return 0, 0 82 | 83 | elif strategy is Strategy.timed: 84 | _internal_cache = ExpiringCache(maxsize) 85 | 86 | def _stats() -> Tuple[int, int]: 87 | return 0, 0 88 | 89 | else: 90 | raise ValueError("Unknown strategy") 91 | 92 | def _make_key(args, kwargs): 93 | # this is a bit of a clusterfuck 94 | # we do care what 'self' parameter is when we __repr__ it 95 | def _true_repr(o): 96 | if o.__class__.__repr__ is object.__repr__: 97 | return f'<{o.__class__.__module__}.{o.__class__.__name__}>' 98 | return repr(o) 99 | 100 | key = [f'{func.__module__}.{func.__name__}'] 101 | key.extend(_true_repr(o) for o in args) 102 | if not ignore_kwargs: 103 | for k, v in kwargs.items(): 104 | # note: this only really works for this use case in particular 105 | # I want to pass asyncpg.Connection objects to the parameters 106 | # however, they use default __repr__ and I do not care what 107 | # connection is passed in, so I needed a bypass. 108 | if k == 'connection': 109 | continue 110 | 111 | key.append(_true_repr(k)) 112 | key.append(_true_repr(v)) 113 | 114 | return ':'.join(key) 115 | 116 | @wraps(func) 117 | def wrapper(*args, **kwargs): 118 | key = _make_key(args, kwargs) 119 | try: 120 | value = _internal_cache[key] 121 | except KeyError: 122 | value = func(*args, **kwargs) 123 | 124 | if inspect.isawaitable(value): 125 | return _wrap_and_store_coroutine(_internal_cache, key, value) 126 | 127 | _internal_cache[key] = value 128 | return value 129 | else: 130 | if asyncio.iscoroutinefunction(func): 131 | return _wrap_new_coroutine(value) 132 | return value 133 | 134 | def _invalidate(*args, **kwargs): 135 | try: 136 | del _internal_cache[_make_key(args, kwargs)] 137 | except KeyError: 138 | return False 139 | else: 140 | return True 141 | 142 | def _invalidate_containing(key): 143 | to_remove = [] 144 | for k in _internal_cache.keys(): 145 | if key in k: 146 | to_remove.append(k) 147 | for k in to_remove: 148 | try: 149 | del _internal_cache[k] 150 | except KeyError: 151 | continue 152 | 153 | wrapper.cache = _internal_cache # type: ignore 154 | wrapper.get_key = lambda *args, **kwargs: _make_key(args, kwargs) # type: ignore 155 | wrapper.invalidate = _invalidate # type: ignore 156 | wrapper.get_stats = _stats # type: ignore 157 | wrapper.invalidate_containing = _invalidate_containing # type: ignore 158 | return wrapper 159 | 160 | return decorator 161 | -------------------------------------------------------------------------------- /utils/checks.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | from discord import app_commands 4 | from discord.ext import commands 5 | 6 | T = TypeVar('T') 7 | 8 | __all__ = ('hybrid_permissions_check', 'ensure_chunked') 9 | 10 | 11 | def hybrid_permissions_check(guild: bool = False, **perms: bool): 12 | user_perms = {p: v for p, v in perms.items() if not p.startswith('bot_')} 13 | user_perms = {p[4:]: v for p, v in perms.items() if p.startswith('bot_')} 14 | commands_perms_check = commands.has_guild_permissions if guild else commands.has_permissions 15 | commands_bot_perms_check = commands.bot_has_guild_permissions if guild else commands.bot_has_permissions 16 | 17 | def decorator(func: T) -> T: 18 | commands_perms_check(**user_perms)(func) 19 | app_commands.default_permissions(**user_perms)(func) 20 | commands_bot_perms_check(**user_perms) 21 | return func 22 | 23 | return decorator 24 | 25 | 26 | def ensure_chunked(ephemeral: bool = False): 27 | async def decorator(ctx: commands.Context): 28 | if ctx.guild and not ctx.guild.chunked: 29 | await ctx.defer(ephemeral=ephemeral) 30 | await ctx.guild.chunk() 31 | return True 32 | 33 | return commands.check(decorator) 34 | -------------------------------------------------------------------------------- /utils/command_errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Tuple 4 | 5 | from discord.ext import commands 6 | 7 | from utils.bases import errors 8 | 9 | if TYPE_CHECKING: 10 | from utils.bases.context import DuckContext 11 | from bot import DuckBot 12 | 13 | __all__: Tuple[str, ...] = () 14 | 15 | 16 | async def on_command_error(ctx: DuckContext, error: Exception) -> None: 17 | """A handler called when an error is raised while invoking a command. 18 | 19 | Parameters 20 | ---------- 21 | ctx: :class:`DuckContext` 22 | The context for the command. 23 | error: :class:`commands.CommandError` 24 | The error that was raised. 25 | """ 26 | if ctx.is_error_handled is True: 27 | return 28 | 29 | error = getattr(error, 'original', error) 30 | 31 | ignored = ( 32 | commands.CommandNotFound, 33 | commands.CheckFailure, 34 | errors.SilentCommandError, 35 | errors.EntityBlacklisted, 36 | ) 37 | children_not_ignored = (commands.BotMissingPermissions,) 38 | try: 39 | ignored_errors: tuple[type[Exception]] = ctx.command.ignored_exceptions # type: ignore 40 | except AttributeError: 41 | ignored_errors: tuple[type[Exception]] = tuple() 42 | 43 | if isinstance(error, ignored + ignored_errors) and not isinstance(error, children_not_ignored): 44 | return 45 | elif isinstance(error, (commands.UserInputError, errors.DuckBotException, commands.BotMissingPermissions)): 46 | await ctx.send(str(error)) 47 | elif isinstance(error, commands.CommandInvokeError): 48 | return await on_command_error(ctx, error.original) 49 | elif isinstance(error, errors.DuckBotNotStarted): 50 | await ctx.send('Oop! Duckbot has not started yet, try again soon.') 51 | else: 52 | await ctx.bot.exceptions.add_error(error=error, ctx=ctx) 53 | 54 | 55 | async def setup(bot: DuckBot): 56 | """adds the event to the bot 57 | 58 | Parameters 59 | ---------- 60 | bot: :class:`DuckBot` 61 | The bot to add the event to. 62 | """ 63 | bot.add_listener(on_command_error) 64 | -------------------------------------------------------------------------------- /utils/errorhandler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import datetime 5 | import os 6 | import traceback 7 | from contextlib import AbstractAsyncContextManager, AbstractContextManager 8 | from types import TracebackType 9 | from typing import Tuple, Optional, Dict, List, Generator, Any, TYPE_CHECKING, Type 10 | 11 | import discord 12 | 13 | from utils.bases.context import DuckContext 14 | from utils.bases.errors import DuckBotException, SilentCommandError, log 15 | from utils.types.exception import DuckTraceback, _DuckTracebackOptional 16 | 17 | if TYPE_CHECKING: 18 | from bot import DuckBot 19 | 20 | 21 | __all__: Tuple[str, ...] = ('DuckExceptionManager', 'HandleHTTPException') 22 | 23 | 24 | class DuckExceptionManager: 25 | """A simple exception handler that sends all exceptions to a error 26 | Webhook and then logs them to the console. 27 | 28 | This class handles cooldowns with a simple lock, so you don't have to worry about 29 | rate limiting your webhook and getting banned :). 30 | 31 | .. note:: 32 | 33 | If some code is raising MANY errors VERY fast and you're not there to fix it, 34 | this will take care of things for you. 35 | 36 | Attributes 37 | ---------- 38 | bot: :class:`DuckBot` 39 | The bot instance. 40 | cooldown: :class:`datetime.timedelta` 41 | The cooldown between sending errors. This defaults to 5 seconds. 42 | errors: Dict[str, Dict[str, Any]] 43 | A mapping of tracebacks to their error information. 44 | code_blocker: :class:`str` 45 | The code blocker used to format Discord codeblocks. 46 | error_webhook: :class:`discord.Webhook` 47 | The error webhook used to send errors. 48 | """ 49 | 50 | __slots__: Tuple[str, ...] = ('bot', 'cooldown', '_lock', '_most_recent', 'errors', 'code_blocker', 'error_webhook') 51 | 52 | def __init__(self, bot: DuckBot, *, cooldown: datetime.timedelta = datetime.timedelta(seconds=5)) -> None: 53 | if not bot.error_webhook_url: 54 | raise DuckBotException('No error webhook set in .env!') 55 | 56 | self.bot: DuckBot = bot 57 | self.cooldown: datetime.timedelta = cooldown 58 | 59 | self._lock: asyncio.Lock = asyncio.Lock() 60 | self._most_recent: Optional[datetime.datetime] = None 61 | 62 | self.errors: Dict[str, List[DuckTraceback]] = {} 63 | self.code_blocker: str = '```py\n{}```' 64 | self.error_webhook: discord.Webhook = discord.Webhook.from_url( 65 | bot.error_webhook_url, session=bot.session, bot_token=bot.http.token 66 | ) 67 | 68 | def _yield_code_chunks(self, iterable: str, *, chunksize: int = 2000) -> Generator[str, None, None]: 69 | cbs = len(self.code_blocker) - 2 # code blocker size 70 | 71 | for i in range(0, len(iterable), chunksize - cbs): 72 | yield self.code_blocker.format(iterable[i : i + chunksize - cbs]) 73 | 74 | async def release_error(self, traceback: str, packet: DuckTraceback) -> None: 75 | """Releases an error to the webhook and logs it to the console. It is not recommended 76 | to call this yourself, call :meth:`add_error` instead. 77 | 78 | Parameters 79 | ---------- 80 | traceback: :class:`str` 81 | The traceback of the error. 82 | packet: :class:`dict` 83 | The additional information about the error. 84 | """ 85 | log.error('Releasing error to log', exc_info=None) 86 | 87 | if self.error_webhook.is_partial(): 88 | self.error_webhook = await self.error_webhook.fetch() 89 | 90 | fmt = { 91 | 'time': discord.utils.format_dt(packet['time']), 92 | } 93 | if author := packet.get('author'): 94 | fmt['author'] = f'<@{author}>' 95 | 96 | # This is a bit of a hack, but I do it here so guild_id 97 | # can be optional, and I wont get type errors. 98 | guild_id = packet.get('guild') 99 | guild = self.bot._connection._get_guild(guild_id) 100 | if guild: 101 | fmt['guild'] = f'{guild.name} ({guild.id})' 102 | else: 103 | log.warning('Ignoring error packet with unknown guild id %s', guild_id) 104 | 105 | if guild: 106 | channel_id = packet.get('channel') 107 | if channel_id and (channel := guild.get_channel(channel_id)): 108 | fmt['channel'] = f'{channel.name} - {channel.mention} - ({channel.id})' 109 | 110 | # Let's try and upgrade the author 111 | author_id = packet.get('author') 112 | if author_id: 113 | author = guild.get_member(author_id) or self.bot.get_user(author_id) 114 | if author: 115 | fmt['author'] = f'{str(author)} - {author.mention} ({author.id})' 116 | 117 | if not fmt.get('author') and (author_id := packet.get('author')): 118 | fmt['author'] = f' - <@{author_id}> ({author_id})' 119 | 120 | if command := packet.get('command'): 121 | fmt['command'] = command.qualified_name 122 | display = f'in command "{command.qualified_name}"' 123 | elif display := packet.get('display'): 124 | ... 125 | else: 126 | display = f'no command (in DuckBot)' 127 | 128 | embed = discord.Embed(title=f'An error has occurred in {display}', timestamp=packet['time']) 129 | embed.add_field( 130 | name='Metadata', 131 | value='\n'.join([f'**{k.title()}**: {v}' for k, v in fmt.items()]), 132 | ) 133 | 134 | kwargs: Dict[str, Any] = {} 135 | if self.bot.user: 136 | kwargs['username'] = self.bot.user.display_name 137 | kwargs['avatar_url'] = self.bot.user.display_avatar.url 138 | 139 | embed.set_author(name=str(self.bot.user), icon_url=self.bot.user.display_avatar.url) 140 | 141 | webhook = self.error_webhook 142 | if webhook.is_partial(): 143 | self.error_webhook = webhook = await self.error_webhook.fetch() 144 | 145 | code_chunks = list(self._yield_code_chunks(traceback)) 146 | 147 | embed.description = code_chunks.pop(0) 148 | await webhook.send(embed=embed, **kwargs) 149 | 150 | embeds: List[discord.Embed] = [] 151 | for entry in code_chunks: 152 | embed = discord.Embed(description=entry) 153 | if self.bot.user: 154 | embed.set_author(name=str(self.bot.user), icon_url=self.bot.user.display_avatar.url) 155 | 156 | embeds.append(embed) 157 | 158 | if len(embeds) == 10: 159 | await webhook.send(embeds=embeds, **kwargs) 160 | embeds = [] 161 | 162 | if embeds: 163 | await webhook.send(embeds=embeds, **kwargs) 164 | 165 | async def add_error( 166 | self, *, error: BaseException, ctx: Optional[DuckContext] = None, display: Optional[str] = None 167 | ) -> None: 168 | """Add an error to the error manager. This will handle all cooldowns and internal cache management 169 | for you. This is the recommended way to add errors. 170 | 171 | Parameters 172 | ---------- 173 | error: :class:`BaseException` 174 | The error to add. 175 | ctx: Optional[:class:`DuckContext`] 176 | The invocation context of the error, if any. 177 | display: Optional[:class:`str`] 178 | Overwritten display text. Defaults to the command name, or "no command" 179 | """ 180 | log.info('Adding error "%s" to log.', str(error)) 181 | 182 | packet: DuckTraceback = {'time': (ctx and ctx.message.created_at) or discord.utils.utcnow(), 'exception': error} 183 | 184 | if ctx is not None: 185 | addons: _DuckTracebackOptional = { 186 | 'command': ctx.command, 187 | 'author': ctx.author.id, 188 | 'guild': (ctx.guild and ctx.guild.id) or None, 189 | 'channel': ctx.channel.id, 190 | } 191 | if display: 192 | addons['display'] = display 193 | packet.update(addons) # type: ignore 194 | 195 | traceback_string = ''.join(traceback.format_exception(type(error), error, error.__traceback__)).replace( 196 | os.getcwd(), 'CWD' 197 | ) 198 | current = self.errors.get(traceback_string) 199 | 200 | if current: 201 | self.errors[traceback_string].append(packet) 202 | else: 203 | self.errors[traceback_string] = [packet] 204 | 205 | async with self._lock: 206 | # I want all other errors to be released after this one, which is why 207 | # lock is here. If you have code that calls MANY errors VERY fast, 208 | # this will ratelimit the webhook. We don't want that lmfao. 209 | 210 | if not self._most_recent: 211 | self._most_recent = discord.utils.utcnow() 212 | await self.release_error(traceback_string, packet) 213 | else: 214 | time_between = packet['time'] - self._most_recent 215 | 216 | if time_between > self.cooldown: 217 | self._most_recent = discord.utils.utcnow() 218 | return await self.release_error(traceback_string, packet) 219 | else: # We have to wait 220 | log.debug('Waiting %s seconds to release error', time_between.total_seconds()) 221 | await asyncio.sleep(time_between.total_seconds()) 222 | 223 | self._most_recent = discord.utils.utcnow() 224 | return await self.release_error(traceback_string, packet) 225 | 226 | 227 | class HandleHTTPException(AbstractAsyncContextManager, AbstractContextManager): 228 | """ 229 | A context manager that handles HTTP exceptions for them to be 230 | delivered to a destination channel without needing to create 231 | an embed and send every time. 232 | 233 | This is useful for handling errors that are not critical, but 234 | still need to be reported to the user. 235 | 236 | Parameters 237 | ---------- 238 | destination: :class:`discord.abc.Messageable` 239 | The destination channel to send the error to. 240 | title: Optional[:class:`str`] 241 | The title of the embed. Defaults to ``'An unexpected error occurred!'``. 242 | 243 | Attributes 244 | ---------- 245 | destination: :class:`discord.abc.Messageable` 246 | The destination channel to send the error to. 247 | message: Optional[:class:`str`] 248 | The string to put the embed title in. 249 | 250 | Raises 251 | ------ 252 | `SilentCommandError` 253 | Error raised if an HTTPException is encountered. This 254 | error is specifically ignored by the command error handler. 255 | """ 256 | 257 | __slots__ = ('destination', 'message') 258 | 259 | def __init__(self, destination: discord.abc.Messageable, *, title: Optional[str] = None): 260 | self.destination = destination 261 | self.message = title 262 | 263 | def __enter__(self): 264 | return self 265 | 266 | async def __aenter__(self): 267 | return self 268 | 269 | def __exit__( 270 | self, 271 | exc_type: Optional[Type[BaseException]] = None, 272 | exc_val: Optional[BaseException] = None, 273 | exc_tb: Optional[TracebackType] = None, 274 | ) -> bool: 275 | log.warning( 276 | 'Context manager HandleHTTPException was used with `with` statement.' 277 | '\nThis can be somewhat unreliable as it uses create_task, ' 278 | 'please use `async with` syntax instead.' 279 | ) 280 | 281 | if exc_val is not None and isinstance(exc_val, discord.HTTPException) and exc_type is not None: 282 | embed = discord.Embed( 283 | title=self.message or 'An unexpected error occurred!', 284 | description=f'{exc_type.__name__}: {exc_val.text}', 285 | colour=discord.Colour.red(), 286 | ) 287 | 288 | loop = asyncio.get_event_loop() 289 | loop.create_task(self.destination.send(embed=embed)) 290 | raise SilentCommandError 291 | return False 292 | 293 | async def __aexit__( 294 | self, 295 | exc_type: Optional[Type[BaseException]] = None, 296 | exc_val: Optional[BaseException] = None, 297 | exc_tb: Optional[TracebackType] = None, 298 | ) -> bool: 299 | if exc_val is not None and isinstance(exc_val, discord.HTTPException) and exc_type: 300 | embed = discord.Embed( 301 | title=self.message or 'An unexpected error occurred!', 302 | description=f'{exc_type.__name__}: {exc_val.text}', 303 | colour=discord.Colour.red(), 304 | ) 305 | 306 | await self.destination.send(embed=embed) 307 | raise SilentCommandError 308 | 309 | return False 310 | -------------------------------------------------------------------------------- /utils/example.env: -------------------------------------------------------------------------------- 1 | # -=-=- .env -=-=- 2 | TOKEN="" 3 | POSTGRES="" 4 | ERROR_WEBHOOK_URL="" 5 | 6 | # Jishaku debug tool environment variables: 7 | JISHAKU_RETAIN=True 8 | JISHAKU_HIDE=True 9 | JISHAKU_NO_UNDERSCORE=True 10 | JISHAKU_NO_DM_TRACEBACK=True -------------------------------------------------------------------------------- /utils/interactions/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities related to application commands 3 | """ 4 | 5 | from .checks import * 6 | from .errors import * 7 | from .errorhandler import * 8 | -------------------------------------------------------------------------------- /utils/interactions/checks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import discord 4 | from discord import ( 5 | Interaction, 6 | Member, 7 | User, 8 | ) 9 | 10 | from typing import ( 11 | Union, 12 | Tuple, 13 | TYPE_CHECKING, 14 | ) 15 | 16 | from . import errors 17 | from ..bases import errors as base_errors 18 | 19 | if TYPE_CHECKING: 20 | from bot import DuckBot 21 | 22 | 23 | __all__: Tuple[str, ...] = ( 24 | 'can_execute_action', 25 | 'has_permissions', 26 | 'bot_has_permissions', 27 | ) 28 | 29 | 30 | async def can_execute_action( 31 | interaction: Interaction, 32 | target: Union[Member, User], 33 | fail_if_not_upgrade: bool = False, 34 | ) -> None: 35 | """Checks if the user can execute the action. 36 | 37 | Parameters 38 | ---------- 39 | interaction: `Interaction` 40 | The interaction to check. 41 | target: Union[:class:`discord.Member`, :class:`discord.User`] 42 | The target of the action. 43 | fail_if_not_upgrade: Optional[:class:`bool`] 44 | Whether to fail if the user can't be upgraded to a member. 45 | 46 | Returns 47 | ------- 48 | Optional[:class:`bool`] 49 | Whether the action can be executed. 50 | 51 | Raises 52 | ------ 53 | """ 54 | bot: DuckBot = interaction.client # type: ignore 55 | guild: discord.Guild = interaction.guild # type: ignore 56 | user: discord.Member = interaction.user # type: ignore 57 | if not interaction.user: 58 | raise errors.ActionNotExecutable( 59 | 'Somehow, I think you don\'t exist. `Interaction.user` was None...\n' 60 | 'Join our support server to get help, or try again later.' 61 | ) 62 | if not target: 63 | raise errors.ActionNotExecutable('Somehow the target was not found.') 64 | if not guild: 65 | raise errors.ActionNotExecutable('This action cannot be executed in DM.') 66 | if not isinstance(target, Member): 67 | upgraded = await bot.get_or_fetch_member(guild, target) 68 | if upgraded is None: 69 | if fail_if_not_upgrade: 70 | raise errors.ActionNotExecutable('That user is not a member of this server.') 71 | else: 72 | target = upgraded 73 | 74 | if interaction.user == target: 75 | raise errors.ActionNotExecutable('You cannot execute this action on yourself!') 76 | if guild.owner == target: 77 | raise errors.ActionNotExecutable('I cannot execute any action on the server owner!') 78 | 79 | if isinstance(target, Member): 80 | if guild.me.top_role <= target.top_role: 81 | raise base_errors.HierarchyException(target) 82 | if guild.owner == interaction.user: 83 | return 84 | if user.top_role <= target.top_role: 85 | raise base_errors.HierarchyException(target, author_error=True) 86 | 87 | 88 | async def has_permissions( 89 | interaction: discord.Interaction[DuckBot], 90 | **perms: bool, 91 | ) -> None: 92 | """Checks permissions of the invoking interaction user.""" 93 | if interaction.channel and isinstance(interaction.user, discord.Member): 94 | permissions = interaction.channel.permissions_for(interaction.user) 95 | elif isinstance(interaction.user, discord.Member): 96 | permissions = interaction.user.guild_permissions 97 | else: 98 | permissions = discord.Permissions.general() 99 | 100 | missing_p = {perm: value for perm, value in perms.items() if getattr(permissions, perm) != value} 101 | 102 | needed = [p for p, v in missing_p.items() if v] 103 | missing = [p for p, v in missing_p.items() if not v] 104 | if any((missing, needed)): 105 | raise errors.PermissionsError(needed=needed, missing=missing) 106 | 107 | 108 | async def bot_has_permissions( 109 | interaction: discord.Interaction[DuckBot], 110 | **perms: bool, 111 | ) -> None: 112 | """Checks permissions of the invoking interaction user.""" 113 | if interaction.channel and interaction.guild: 114 | permissions = interaction.channel.permissions_for(interaction.guild.me) 115 | elif interaction.guild: 116 | permissions = interaction.guild.me.guild_permissions 117 | else: 118 | permissions = discord.Permissions.none() 119 | 120 | missing_p = {perm: value for perm, value in perms.items() if getattr(permissions, perm) != value} 121 | 122 | needed = [p for p, v in missing_p.items() if v] 123 | missing = [p for p, v in missing_p.items() if not v] 124 | if any((missing, needed)): 125 | raise errors.BotPermissionsError(needed=needed, missing=missing) 126 | -------------------------------------------------------------------------------- /utils/interactions/command_errors.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import traceback 3 | 4 | import discord 5 | from bot import DuckBot 6 | from discord import app_commands 7 | from discord.ext.commands import UserInputError 8 | from typing import Union, Optional 9 | from utils.bases.errors import SilentCommandError, DuckBotException 10 | from . import errors 11 | import logging 12 | 13 | 14 | async def on_app_command_error( 15 | interaction: discord.Interaction[DuckBot], 16 | command: Optional[Union[app_commands.ContextMenu, app_commands.Command]], 17 | error: Exception, 18 | ) -> None: 19 | bot: DuckBot = interaction.client 20 | 21 | if isinstance(error, app_commands.CommandInvokeError): 22 | error = error.original 23 | 24 | if isinstance(error, app_commands.CommandNotFound): 25 | if not interaction.response.is_done(): 26 | await interaction.response.send_message( 27 | '**Sorry, but somehow this application command does not exist anymore.**' 28 | '\nIf you think this command should exist, please ask about it in our ' 29 | '[support server](https://discord.gg/TdRfGKg8Wh)!' 30 | ' Application commands are still a work in progress ' 31 | 'and we are working hard to make them better.', 32 | ephemeral=True, 33 | ) 34 | elif isinstance(error, SilentCommandError): 35 | logging.debug(f'Ignoring silent command error raised in application command {command}', exc_info=False) 36 | return 37 | elif isinstance(error, (DuckBotException, UserInputError, errors.InteractionError)): 38 | if not interaction.response.is_done(): 39 | await interaction.response.send_message(str(error), ephemeral=True) 40 | else: 41 | webhook: discord.Webhook = interaction.followup 42 | with contextlib.suppress(discord.HTTPException): 43 | await webhook.send(content=str(error), ephemeral=True) 44 | elif isinstance(error, app_commands.CommandSignatureMismatch): 45 | if not interaction.response.is_done(): 46 | await bot.exceptions.add_error(error=error) 47 | try: 48 | await interaction.response.send_message( 49 | f"**\N{WARNING SIGN} This command's signature is out of date!**\n" 50 | f"i've warned the developers about this and it will " 51 | f"be fixed as soon as possible", 52 | ephemeral=True, 53 | ) 54 | except discord.HTTPException: 55 | pass 56 | 57 | else: 58 | webhook: discord.Webhook = interaction.followup 59 | try: 60 | await webhook.send( 61 | content=f"**\N{WARNING SIGN} This command's signature is out of date!**\n" 62 | f"i've warned the developers about this and it will " 63 | f"be fixed as soon as possible", 64 | ephemeral=True, 65 | ) 66 | except discord.HTTPException: 67 | pass 68 | 69 | else: 70 | await bot.exceptions.add_error(error=error) 71 | 72 | tb = ''.join(traceback.format_exception(type(error), error, error.__traceback__)) 73 | embed = discord.Embed(title='Error traceback:', description=f'```py\n{tb[0:4080]}\n```', color=discord.Color.red()) 74 | embed.set_footer( 75 | text='This error has bee succesfully reported to the developers.', 76 | icon_url='https://cdn.discordapp.com/emojis/912190496791728148.gif?size=60&quality=lossless', 77 | ) 78 | msg = ( 79 | '**Sorry, but something went wrong while executing this application command.**\n' 80 | '__You can also join our [support server](https://discord.gg/TdRfGKg8Wh) if you' 81 | ' want help with this error!__\n_ _' 82 | ) 83 | 84 | if not interaction.response.is_done(): 85 | await interaction.response.send_message(msg, ephemeral=True, embed=embed) 86 | else: 87 | await interaction.followup.send(msg, embed=embed, ephemeral=True) 88 | 89 | 90 | async def setup(bot: DuckBot) -> None: 91 | bot.add_listener(on_app_command_error) 92 | -------------------------------------------------------------------------------- /utils/interactions/errorhandler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from types import TracebackType 3 | from typing import Optional, Type, Tuple 4 | 5 | import discord 6 | from utils.bases.errors import SilentCommandError 7 | 8 | __all__: Tuple[str, ...] = ("HandleHTTPException",) 9 | 10 | 11 | class HandleHTTPException: 12 | __slots__: Tuple[str, ...] = ('webhook', 'message') 13 | 14 | def __init__(self, webhook: discord.Webhook, title: Optional[str] = None): 15 | self.webhook: discord.Webhook = webhook 16 | self.message: str = title or '...' 17 | 18 | async def __aenter__(self): 19 | return self 20 | 21 | async def __aexit__( 22 | self, 23 | exc_type: Optional[Type[BaseException]] = None, 24 | exc_val: Optional[BaseException] = None, 25 | exc_tb: Optional[TracebackType] = None, 26 | ) -> bool: 27 | if exc_val is not None and isinstance(exc_val, discord.HTTPException) and exc_type: 28 | embed = discord.Embed( 29 | title=self.message or 'An unexpected error occurred!', 30 | description=f'{exc_type.__name__}: {exc_val.text}', 31 | colour=discord.Colour.red(), 32 | ) 33 | 34 | try: 35 | asyncio.get_event_loop().create_task(self.webhook.send(embed=embed, ephemeral=True)) 36 | except discord.HTTPException: 37 | pass 38 | raise SilentCommandError 39 | 40 | return False 41 | 42 | def __enter__(self): 43 | return self 44 | 45 | def __exit__( 46 | self, 47 | exc_type: Optional[Type[BaseException]] = None, 48 | exc_val: Optional[BaseException] = None, 49 | exc_tb: Optional[TracebackType] = None, 50 | ) -> bool: 51 | if exc_val is not None and isinstance(exc_val, discord.HTTPException) and exc_type: 52 | embed = discord.Embed( 53 | title=self.message or 'An unexpected error occurred!', 54 | description=f'{exc_type.__name__}: {exc_val.text}', 55 | colour=discord.Colour.red(), 56 | ) 57 | 58 | try: 59 | asyncio.get_event_loop().create_task(self.webhook.send(embed=embed, ephemeral=True)) 60 | except discord.HTTPException: 61 | pass 62 | raise SilentCommandError 63 | 64 | return False 65 | -------------------------------------------------------------------------------- /utils/interactions/errors.py: -------------------------------------------------------------------------------- 1 | from discord.app_commands import AppCommandError 2 | from utils.bases.errors import DuckBotException 3 | from utils.time import human_join 4 | 5 | from typing import Tuple 6 | 7 | __all__: Tuple[str, ...] = ( 8 | 'InteractionError', 9 | 'ActionNotExecutable', 10 | 'PermissionsError', 11 | ) 12 | 13 | 14 | class InteractionError(DuckBotException, AppCommandError): 15 | """ 16 | Base class for all errors DuckBot errors. 17 | """ 18 | 19 | __all__: Tuple[str, ...] = () 20 | 21 | 22 | class ActionNotExecutable(InteractionError): 23 | """ 24 | The action is not executable. 25 | """ 26 | 27 | def __init__(self, message: str): 28 | super().__init__(f"{message}") 29 | 30 | 31 | class PermissionsError(InteractionError): 32 | """ 33 | The invoker does not have the required permissions. 34 | """ 35 | 36 | def __init__(self, missing: list[str] = [], needed: list[str] = []): 37 | self.missing: list = missing 38 | self.needed: list = needed 39 | message = "You" 40 | if missing: 41 | message += f"'re missing {human_join(missing, 'and')} permission(s)" 42 | if missing and needed: 43 | message += " and you" 44 | if needed: 45 | message += f" need {human_join(needed, 'and')} permission(s)" 46 | message += '.' 47 | super().__init__(message) 48 | 49 | 50 | class BotPermissionsError(InteractionError): 51 | """ 52 | The invoker does not have the required permissions. 53 | """ 54 | 55 | def __init__(self, missing: list[str] = [], needed: list[str] = []): 56 | self.missing: list = missing 57 | self.needed: list = needed 58 | message = "I" 59 | if missing: 60 | message += f"'m missing **{human_join(missing, 'and')}** permission(s)" 61 | if missing and needed: 62 | message += " and" 63 | if needed: 64 | message += f" need **{human_join(needed, 'and')}** permission(s)" 65 | message += '.' 66 | super().__init__(message) 67 | -------------------------------------------------------------------------------- /utils/ipc_routes.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from typing import Union 3 | from discord.ext.commands import Group, Command 4 | 5 | from utils.bases.command import DuckCommand, DuckGroup 6 | from utils.bases.ipc_base import IPCBase, route 7 | 8 | 9 | def command_dict(command: Union[DuckCommand, Command]) -> dict: 10 | return { 11 | "aliases": command.aliases, 12 | "help_mapping": getattr(command, "help_mapping", None), 13 | "help": command.help, 14 | "brief": command.brief, 15 | "children": {c.name: command_dict(c) for c in command.commands} if isinstance(command, (Group, DuckGroup)) else None, 16 | "qualified_name": command.qualified_name, 17 | "signature": command.signature, 18 | } 19 | 20 | 21 | class DuckBotIPC(IPCBase): 22 | @route("/stats", method="get") 23 | async def ping(self, request: web.Request): 24 | return web.json_response( 25 | { 26 | "guilds": len(self.bot.guilds), 27 | "users": { 28 | "total": sum(g.member_count or 0 for g in self.bot.guilds), 29 | "unique": len(self.bot.users), 30 | }, 31 | } 32 | ) 33 | 34 | @route('/topguilds', method='get') 35 | async def topguilds(self, request: web.Request): 36 | return web.json_response( 37 | [ 38 | { 39 | "guild_id": g.id, 40 | "name": g.name, 41 | "member_count": g.member_count, 42 | } 43 | for g in sorted(self.bot.guilds, key=lambda g: g.member_count or 0, reverse=True)[0:10] 44 | ] 45 | ) 46 | 47 | @route("/users/{id}", method="get") 48 | async def get_user(self, request: web.Request): 49 | id = int(request.match_info["id"]) 50 | user = await self.bot.get_or_fetch_user(int(id)) 51 | if not user: 52 | return web.json_response({"error": "User not found."}, status=404) 53 | return web.json_response(user._to_minimal_user_json()) 54 | 55 | @route("/commands", method="get") 56 | async def get_commands(self, request: web.Request): 57 | data = { 58 | name: { 59 | "description": cog.description, 60 | "brief": cog.emoji, 61 | "emoji": cog.emoji, 62 | "commands": {c.name: command_dict(c) for c in cog.get_commands()}, 63 | } 64 | for name, cog in self.bot.cogs.items() 65 | if not getattr(cog, 'hidden', True) 66 | } 67 | return web.json_response(data) 68 | -------------------------------------------------------------------------------- /utils/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from . import col 3 | from typing import Tuple 4 | 5 | __all__: Tuple[str, ...] = ('ColourFormatter',) 6 | 7 | 8 | class ColourFormatter(logging.Formatter): 9 | # ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher 10 | # It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands 11 | # The important ones here relate to colour. 12 | # 30-37 are black, red, green, yellow, blue, magenta, cyan and white in that order 13 | # 40-47 are the same except for the background 14 | # 90-97 are the same but "bright" foreground 15 | # 100-107 are the same as the bright ones but for the background. 16 | # 1 means bold, 2 means dim, 0 means reset, and 4 means underline. 17 | 18 | LEVEL_COLOURS = [ 19 | (logging.DEBUG, '\x1b[40m', f'|{col(4)}%(name)s'), 20 | (logging.INFO, '\x1b[32m', f'|{col(4)}%(name)s'), 21 | (logging.WARNING, '\x1b[33;1m', f'|{col(4)}%(name)s.%(funcName)s:%(lineno)s{col()}'), 22 | (logging.ERROR, '\x1b[31;1;4m', f'|{col(4)}%(name)s.%(funcName)s:%(lineno)s{col()}'), 23 | (logging.CRITICAL, '\x1b[41;1;4m', f'|{col(4)}%(name)s.%(funcName)s:%(lineno)s{col()}'), 24 | ] 25 | 26 | def __init__(self, brief: bool) -> None: 27 | super().__init__() 28 | 29 | '\x1b[30;1m%(asctime)s\x1b[0m {colour}%(levelname)-8s\x1b[0m \x1b[35m%(name)s\x1b[0m %(message)s' 30 | 31 | if brief: 32 | fmt = f'[{col(4)}%(name)s{col()}] %(message)s{col()}' 33 | else: 34 | fmt = f'{col()}[{col(7)}%(asctime)s{col()}|{{colour}}%(levelname)s{col()}{{extra}}] %(message)s{col()}' 35 | 36 | self.FORMATS = { 37 | level: logging.Formatter( 38 | fmt.format(colour=colour, extra=extra), 39 | ) 40 | for level, colour, extra in self.LEVEL_COLOURS 41 | } 42 | 43 | def format(self, record): 44 | formatter = self.FORMATS.get(record.levelno) 45 | if formatter is None: 46 | formatter = self.FORMATS[logging.DEBUG] 47 | 48 | # Override the traceback to always print in red 49 | if record.exc_info: 50 | text = formatter.formatException(record.exc_info) 51 | record.exc_text = f'\x1b[31m{text}\x1b[0m' 52 | 53 | output = formatter.format(record) 54 | 55 | # Remove the cache layer 56 | record.exc_text = None 57 | return output 58 | -------------------------------------------------------------------------------- /utils/types/__init__.py: -------------------------------------------------------------------------------- 1 | import discord 2 | 3 | from typing import TypeAlias 4 | 5 | DiscordMedium: TypeAlias = ( 6 | discord.User 7 | | discord.Member 8 | | discord.Role 9 | | discord.TextChannel 10 | | discord.VoiceChannel 11 | | discord.CategoryChannel 12 | | discord.Thread 13 | | discord.ForumChannel 14 | | discord.StageChannel 15 | | discord.abc.GuildChannel 16 | ) 17 | -------------------------------------------------------------------------------- /utils/types/constants.py: -------------------------------------------------------------------------------- 1 | # This file is bad, but all things that can be deleted at some point 2 | # should go here, so they're easily changeable at a later date. 3 | from __future__ import annotations 4 | 5 | from typing import Tuple 6 | from collections import namedtuple 7 | 8 | import discord 9 | 10 | __all__: Tuple[str, ...] = ( 11 | 'ARROW', 12 | 'ARROWBACK', 13 | 'ARROWBACKZ', 14 | 'ARROWFWD', 15 | 'ARROWFWDZ', 16 | 'ARROWZ', 17 | 'BLOB_STOP_SIGN', 18 | 'BOOST', 19 | 'BOT', 20 | 'BOTS_GG', 21 | 'CAG_DOWN', 22 | 'CAG_UP', 23 | 'CATEGORY_CHANNEL', 24 | 'COINS_STRING', 25 | 'CONTENT_FILTER', 26 | 'CUSTOM_TICKS', 27 | 'DEFAULT_TICKS', 28 | 'DICES', 29 | 'DONE', 30 | 'DOWNVOTE', 31 | 'EDIT_NICKNAME', 32 | 'EMOJI_GHOST', 33 | 'FULL_SPOTIFY', 34 | 'GET_SOME_HELP', 35 | 'GITHUB', 36 | 'GUILD_BOOST_LEVEL_EMOJI', 37 | 'GUILD_FEATURES', 38 | 'INFORMATION_SOURCE', 39 | 'INVITE', 40 | 'JOINED_SERVER', 41 | 'LEFT_SERVER', 42 | 'MINECRAFT_LOGO', 43 | 'MOVED_CHANNELS', 44 | 'NITRO', 45 | 'OWNER_CROWN', 46 | 'POSTGRE_LOGO', 47 | 'REDDIT_UPVOTE', 48 | 'REPLY_BUTTON', 49 | 'RICH_PRESENCE', 50 | 'ROLES_ICON', 51 | 'ROO_SLEEP', 52 | 'SERVERS_ICON', 53 | 'SHUT_SEAGULL', 54 | 'SPINNING_MAG_GLASS', 55 | 'SPOTIFY', 56 | 'SQUARE_TICKS', 57 | 'STAGE_CHANNEL', 58 | 'STORE_TAG', 59 | 'TEXT_CHANNEL', 60 | 'TEXT_CHANNEL_WITH_THREAD', 61 | 'TOGGLES', 62 | 'TOP_GG', 63 | 'TYPING_INDICATOR', 64 | 'UPVOTE', 65 | 'USER_FLAGS', 66 | 'VERIFICATION_LEVEL', 67 | 'VOICE_CHANNEL', 68 | 'WEBSITE', 69 | 'YOUTUBE_BARS', 70 | 'YOUTUBE_LOGO', 71 | 'st_nt', 72 | 'statuses', 73 | ) 74 | 75 | GET_SOME_HELP = '' 76 | BLOB_STOP_SIGN = '<:blobstop:895395252284850186>' 77 | REPLY_BUTTON = '<:reply:895394899728408597>' 78 | UPVOTE = '<:upvote:893588750242832424>' 79 | DOWNVOTE = '<:downvote:893588792164892692>' 80 | REDDIT_UPVOTE = '<:upvote:895395361634541628>' 81 | TOP_GG = '<:topgg:895395399043543091>' 82 | BOTS_GG = '<:botsgg:895395445608697967>' 83 | SERVERS_ICON = '<:servers:895395501934006292>' 84 | INVITE = '<:invite:895395547651907607>' 85 | MINECRAFT_LOGO = '<:minecraft:895395622272782356>' 86 | GITHUB = '<:github:895395664383598633>' 87 | WEBSITE = '<:open_site:895395700249075813>' 88 | TYPING_INDICATOR = '' 89 | POSTGRE_LOGO = '<:psql:895405698278649876>' 90 | SHUT_SEAGULL = '<:shut:895406986227761193>' 91 | EDIT_NICKNAME = '<:nickname:895407885339738123>' 92 | ROO_SLEEP = '<:RooSleep:895407927681253436>' 93 | INFORMATION_SOURCE = '<:info:895407958035431434>' 94 | STORE_TAG = '<:store_tag:895407986850271262>' 95 | JOINED_SERVER = '<:joined:895408141305540648>' 96 | MOVED_CHANNELS = '<:moved:895408170011332608>' 97 | LEFT_SERVER = '<:left:897315201156792371>' 98 | ROLES_ICON = '<:role:895408243076128819>' 99 | BOOST = '<:booster4:895413288219861032>' 100 | OWNER_CROWN = '<:owner_crown:895414001364762686>' 101 | RICH_PRESENCE = '<:rich_presence:895414264016306196>' 102 | VOICE_CHANNEL = '<:voice:895414328818274315>' 103 | TEXT_CHANNEL = '<:view_channel:895414354588082186>' 104 | CATEGORY_CHANNEL = '<:category:895414388528406549>' 105 | STAGE_CHANNEL = '<:stagechannel:895414409445380096>' 106 | TEXT_CHANNEL_WITH_THREAD = '<:threadnew:895414437916332062>' 107 | FORUM_CHANNEL = '<:thread:1005108988188311745>' 108 | EMOJI_GHOST = '<:emoji_ghost:895414463354785853>' 109 | SPOTIFY = '<:spotify:897661396022607913>' 110 | YOUTUBE_LOGO = '<:youtube:898052487989309460>' 111 | ARROW = ARROWFWD = '<:arrow:909672287849041940>' 112 | ARROWBACK = '<:arrow:909889493782376540>' 113 | ARROWZ = ARROWFWDZ = '<:arrow:909897129198231662>' 114 | ARROWBACKZ = '<:arrow:909897233833529345>' 115 | NITRO = '<:nitro:895392323519799306>' 116 | BOT = '<:bot:952905056657752155>' 117 | FULL_SPOTIFY = ( 118 | "<:spotify:897661396022607913>" 119 | "<:spotify1:953665420987072583>" 120 | "<:spotify2:953665449210544188>" 121 | "<:spotify3:953665460916850708>" 122 | "<:spotify4:953665475517231194>" 123 | ) 124 | 125 | CUSTOM_TICKS = { 126 | True: '<:greenTick:895390596599017493>', 127 | False: '<:redTick:895390643210305536>', 128 | None: '<:greyTick:895390690396229753>', 129 | } 130 | 131 | DEFAULT_TICKS = { 132 | True: '✅', 133 | False: '❌', 134 | None: '⬜', 135 | } 136 | 137 | GUILD_FEATURES = { 138 | 'COMMUNITY': 'Community Server', 139 | 'VERIFIED': 'Verified', 140 | 'DISCOVERABLE': 'Discoverable', 141 | 'PARTNERED': 'Partnered', 142 | 'FEATURABLE': 'Featured', 143 | 'COMMERCE': 'Commerce', 144 | 'MONETIZATION_ENABLED': 'Monetization', 145 | 'NEWS': 'News Channels', 146 | 'PREVIEW_ENABLED': 'Preview Enabled', 147 | 'INVITE_SPLASH': 'Invite Splash', 148 | 'VANITY_URL': 'Vanity Invite URL', 149 | 'ANIMATED_ICON': 'Animated Server Icon', 150 | 'BANNER': 'Server Banner', 151 | 'MORE_EMOJI': 'More Emoji', 152 | 'MORE_STICKERS': 'More Stickers', 153 | 'WELCOME_SCREEN_ENABLED': 'Welcome Screen', 154 | 'MEMBER_VERIFICATION_GATE_ENABLED': 'Membership Screening', 155 | 'TICKETED_EVENTS_ENABLED': 'Ticketed Events', 156 | 'VIP_REGIONS': 'VIP Voice Regions', 157 | 'PRIVATE_THREADS': 'Private Threads', 158 | 'THREE_DAY_THREAD_ARCHIVE': '3 Day Thread Archive', 159 | 'SEVEN_DAY_THREAD_ARCHIVE': '1 Week Thread Archive', 160 | } 161 | 162 | SQUARE_TICKS = { 163 | True: '🟩', 164 | False: '🟥', 165 | None: '⬜', 166 | } 167 | 168 | TOGGLES = { 169 | True: '<:toggle_on:895390746654412821>', 170 | False: '<:toggle_off:895390760344629319>', 171 | None: '<:toggle_off:895390760344629319>', 172 | } 173 | 174 | DICES = [ 175 | '<:dice_1:895391506158997575>', 176 | '<:dice_2:895391525259841547>', 177 | '<:dice_3:895391547003117628>', 178 | '<:dice_4:895391573670498344>', 179 | '<:dice_5:895391597108285440>', 180 | '<:dice_6:895391621728854056>', 181 | ] 182 | 183 | COINS_STRING = ['<:heads:895391679044005949> Heads!', '<:tails:895391716356522057> Tails!'] 184 | 185 | USER_FLAGS = { 186 | 'bot_http_interactions': f'{WEBSITE} Interaction-only Bot', 187 | 'bug_hunter': '<:bughunter:895392105386631249> Discord Bug Hunter', 188 | 'bug_hunter_level_2': '<:bughunter_gold:895392270369579078> Discord Bug Hunter', 189 | 'discord_certified_moderator': '<:certified_moderator:895393984308981930> Certified Moderator', 190 | 'early_supporter': '<:supporter:895392239356903465> Early Supporter', 191 | 'hypesquad': '<:hypesquad:895391957638070282> HypeSquad Events', 192 | 'hypesquad_balance': '<:balance:895392209564733492> HypeSquad Balance', 193 | 'hypesquad_bravery': '<:bravery:895392137225584651> HypeSquad Bravery', 194 | 'hypesquad_brilliance': '<:brilliance:895392183950131200> HypeSquad Brilliance', 195 | 'partner': '<:partnernew:895391927271309412> Partnered Server Owner', 196 | 'spammer': '\N{WARNING SIGN} Potential Spammer', 197 | 'staff': '<:staff:895391901778346045> Discord Staff', 198 | 'system': '\N{INFORMATION SOURCE} System', 199 | 'team_user': '\N{INFORMATION SOURCE} Team User', 200 | 'verified_bot': '<:verified_bot:897876151219912754> Verified Bot', 201 | 'verified_bot_developer': '<:earlybotdev:895392298895032364> Early Verified Bot Developer', 202 | 'active_developer': "<:active_developer:1345038215060390032> Active Developer", 203 | } 204 | 205 | CONTENT_FILTER = { 206 | discord.ContentFilter.disabled: "Don't scan any media content", 207 | discord.ContentFilter.no_role: "Scan media content from members without a role.", 208 | discord.ContentFilter.all_members: "Scan media content from all members.", 209 | } 210 | 211 | VERIFICATION_LEVEL = { 212 | discord.VerificationLevel.none: '<:none_verification:895818789919285268>', 213 | discord.VerificationLevel.low: '<:low_verification:895818719362699274>', 214 | discord.VerificationLevel.medium: '<:medium_verification:895818719362686976>', 215 | discord.VerificationLevel.high: '<:high_verification:895818719387865109>', 216 | discord.VerificationLevel.highest: '<:highest_verification:895818719530450984>', 217 | } 218 | 219 | YOUTUBE_BARS = ( 220 | ('', '', None), 221 | ( 222 | '', 223 | '', 224 | '', 225 | ), 226 | ('', '', ''), 227 | ) 228 | 229 | GUILD_BOOST_LEVEL_EMOJI = { 230 | '0': '<:Level0_guild:895394281559306240>', 231 | '1': '<:Level1_guild:895394308243464203>', 232 | '2': '<:Level2_guild:895394334164254780>', 233 | '3': '<:Level3_guild:895394362933006396>', 234 | } 235 | 236 | st_nt = namedtuple( 237 | 'statuses', 238 | [ 239 | 'ONLINE', 240 | 'IDLE', 241 | 'DND', 242 | 'OFFLINE', 243 | 'ONLINE_WEB', 244 | 'IDLE_WEB', 245 | 'DND_WEB', 246 | 'OFFLINE_WEB', 247 | 'ONLINE_MOBILE', 248 | 'IDLE_MOBILE', 249 | 'DND_MOBILE', 250 | 'OFFLINE_MOBILE', 251 | ], 252 | ) 253 | 254 | statuses = st_nt( 255 | ONLINE='<:desktop_online:897644406344130600>', 256 | ONLINE_WEB='<:web_online:897644406801313832>', 257 | ONLINE_MOBILE='<:mobile_online:897644405102616586>', 258 | IDLE='<:desktop_idle:897644406344130603>', 259 | IDLE_WEB='<:web_idle:897644403244544010>', 260 | IDLE_MOBILE='<:mobile_idle:897644402938347540>', 261 | DND='<:desktop_dnd:897644406675497061>', 262 | DND_WEB='<:web_dnd:897644405383643137>', 263 | DND_MOBILE='<:mobile_dnd:897644405014532107>', 264 | OFFLINE='<:desktop_offline:897644406792937532>', 265 | OFFLINE_WEB='<:web_offline:897644403395547208>', 266 | OFFLINE_MOBILE='<:mobile_offline:897644403345227776>', 267 | ) 268 | 269 | CAG_UP = 'https://cdn.discordapp.com/attachments/879251951714467840/896293818096291840/Sv6kz8f.png' 270 | CAG_DOWN = 'https://cdn.discordapp.com/attachments/879251951714467840/896297890396389377/wvUPp3d.png' 271 | SPINNING_MAG_GLASS = 'https://cdn.discordapp.com/attachments/879251951714467840/896903391085748234/DZhQwnD.gif' 272 | 273 | DONE = [ 274 | '<:done:912190157942308884>', 275 | '<:done:912190217102970941>', 276 | '', 277 | '', 278 | '<:done:912190445289877504>', 279 | '', 280 | '', 281 | '', 282 | '<:done:912190753084694558>', 283 | '<:done:912190821321814046>', 284 | '', 285 | '', 286 | '', 287 | '', 288 | '<:done:912191209919897700>', 289 | '<:done:912191260356407356>', 290 | '', 291 | '<:done:912191480351825920>', 292 | '<:done:912191682534047825>', 293 | '', 294 | '', 295 | ] 296 | -------------------------------------------------------------------------------- /utils/types/exception.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ( 4 | TYPE_CHECKING, 5 | TypedDict, 6 | Optional, 7 | ) 8 | 9 | if TYPE_CHECKING: 10 | from discord.ext import commands 11 | import datetime 12 | 13 | 14 | class _DuckTracebackOptional(TypedDict, total=False): 15 | author: int 16 | guild: Optional[int] 17 | channel: int 18 | command: Optional[commands.Command] 19 | display: str 20 | 21 | 22 | class DuckTraceback(_DuckTracebackOptional): 23 | time: datetime.datetime 24 | exception: BaseException 25 | --------------------------------------------------------------------------------