├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── autoroom ├── README.md ├── __init__.py ├── abc.py ├── autoroom.py ├── c_autoroom.py ├── c_autoroomset.py ├── info.json ├── pcx_lib.py ├── pcx_template.py └── pcx_template_test.py ├── bancheck ├── README.md ├── __init__.py ├── bancheck.py ├── info.json ├── pcx_lib.py └── services │ ├── __init__.py │ ├── antiraid.py │ └── dto │ ├── __init__.py │ └── lookup_result.py ├── bansync ├── __init__.py ├── bansync.py ├── info.json └── pcx_lib.py ├── decodebinary ├── __init__.py ├── decodebinary.py ├── info.json └── pcx_lib.py ├── dice ├── __init__.py ├── dice.py ├── info.json └── pcx_lib.py ├── heartbeat ├── __init__.py ├── heartbeat.py ├── info.json └── pcx_lib.py ├── info.json ├── netspeed ├── __init__.py ├── info.json └── netspeed.py ├── pyproject.toml ├── reactchannel ├── __init__.py ├── info.json ├── pcx_lib.py └── reactchannel.py ├── remindme ├── __init__.py ├── abc.py ├── c_reminder.py ├── c_remindmeset.py ├── info.json ├── pcx_lib.py ├── reminder_parse.py ├── reminder_parse_test.py └── remindme.py ├── updatenotify ├── __init__.py ├── info.json ├── pcx_lib.py └── updatenotify.py ├── uwu ├── __init__.py ├── info.json ├── pcx_lib.py └── uwu.py └── wikipedia ├── __init__.py ├── info.json └── wikipedia.py /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.11 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: v0.11.12 6 | hooks: 7 | - id: ruff 8 | args: 9 | - "--fix" 10 | - "--exit-non-zero-on-fix" 11 | - repo: https://github.com/psf/black 12 | rev: '25.1.0' 13 | hooks: 14 | - id: black 15 | - repo: https://github.com/Pierre-Sassoulas/black-disable-checker 16 | rev: 'v1.1.3' 17 | hooks: 18 | - id: black-disable-checker 19 | - repo: https://github.com/abravalheri/validate-pyproject 20 | rev: v0.24.1 21 | hooks: 22 | - id: validate-pyproject 23 | - repo: https://github.com/pre-commit/pre-commit-hooks 24 | rev: v5.0.0 25 | hooks: 26 | # JSON auto-formatter 27 | # needs to come before mixed-line-ending, see: 28 | # https://github.com/pre-commit/pre-commit-hooks/issues/622 29 | - id: pretty-format-json 30 | args: 31 | - "--autofix" 32 | - "--indent=4" 33 | - "--no-sort-keys" 34 | 35 | # all files should end with an empty line (for one, it minimizes the diffs) 36 | - id: end-of-file-fixer 37 | # po files are auto-generated so let's not touch them 38 | exclude_types: [pofile] 39 | # `.gitattributes` should technically already handle this 40 | # but autocrlf can result in local files keeping the CRLF 41 | # which is problematic for some tools 42 | - id: mixed-line-ending 43 | args: 44 | - "--fix=lf" 45 | 46 | # Trailing whitespace is evil 47 | - id: trailing-whitespace 48 | 49 | # Require literal syntax when initializing builtin types 50 | - id: check-builtin-literals 51 | 52 | # Ensure that links to code on GitHub use the permalinks 53 | - id: check-vcs-permalinks 54 | 55 | # Syntax validation 56 | - id: check-ast 57 | - id: check-json 58 | - id: check-toml 59 | # can be switched to yamllint when this issue gets resolved: 60 | # https://github.com/adrienverge/yamllint/issues/238 61 | - id: check-yaml 62 | 63 | # Checks for git-related issues 64 | - id: check-case-conflict 65 | - id: check-merge-conflict 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PCXCogs 2 | 3 | PhasecoreX's Cogs for [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot/releases). 4 | 5 | [![Red-DiscordBot](https://img.shields.io/badge/red--discordbot-v3-red)](https://github.com/Cog-Creators/Red-DiscordBot/releases) 6 | [![Code Style: Black](https://img.shields.io/badge/code%20style-black-000000)](https://github.com/ambv/black) 7 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/PhasecoreX/PCXCogs/master.svg)](https://results.pre-commit.ci/latest/github/PhasecoreX/PCXCogs/master) 8 | [![Chat Support](https://img.shields.io/discord/608057344487849989)](https://discord.gg/QzdPp2b) 9 | [![BuyMeACoffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-orange)](https://buymeacoff.ee/phasecorex) 10 | [![PayPal](https://img.shields.io/badge/paypal-donate-blue)](https://paypal.me/pcx) 11 | 12 | To add these wonderful cogs to your instance, run this command first (`[p]` is your bot prefix): 13 | 14 | ``` 15 | [p]repo add pcxcogs https://github.com/PhasecoreX/PCXCogs 16 | ``` 17 | 18 | Then install your cog(s) of choice: 19 | 20 | ``` 21 | [p]cog install pcxcogs 22 | ``` 23 | 24 | Finally, load your cog(s): 25 | 26 | ``` 27 | [p]load 28 | ``` 29 | 30 | If you don't have an instance, consider using my nice [docker image](https://hub.docker.com/r/phasecorex/red-discordbot)! 31 | 32 | If you'd like to contact me, test out my cogs, or stay up to date on my cogs, consider joining my [Discord server](https://discord.gg/QzdPp2b)! You can find me on the Red Cog Support server as well. 33 | 34 | ## The List of Cogs 35 | 36 | ### AutoRoom 37 | 38 | Automatic voice channel management. When a user joins an AutoRoom source channel, they will be moved into their own personal on-demand voice channel (AutoRoom). Once all users have left the AutoRoom, it is automatically deleted. 39 | 40 | ### BanCheck 41 | 42 | Automatically check users against multiple global ban lists on server join. Other features include automatic banning, manually checking users already on the server, and sending ban reports to supported services. 43 | 44 | ### BanSync 45 | 46 | Automatically sync bans between servers that the bot is in. Supports pulls (one way) and two way syncing of bans and unbans. 47 | 48 | ### DecodeBinary 49 | 50 | Automatically decode binary strings in chat. Any message that the bot thinks is binary will be decoded to regular text. Based on a Reddit bot, and was my first cog! 51 | 52 | ### Dice 53 | 54 | Perform complex dice rolling. Supports dice notation (such as 3d6+3), shows all roll results, and can be configured to limit the number of dice a user can roll at once. 55 | 56 | ### Heartbeat 57 | 58 | Monitor the uptime of your bot by sending heartbeat pings to a configurable URL (healthchecks.io for instance). 59 | 60 | ### NetSpeed 61 | 62 | Test the internet speed of the server your bot is hosted on. Runs an internet speedtest and prints the results. Only the owner can run this. 63 | 64 | ### ReactChannel 65 | 66 | Per-channel automatic reaction tools, where every message in a channel will have reactions added to them. Supports turning a channel into a checklist (checkmark will delete the message), an upvote-like system (affects a user's karma total), or a custom channel. 67 | 68 | ### RemindMe 69 | 70 | Set reminders for yourself. Ported from v2; originally by Twentysix26. I've made many enhancements to it as well. 71 | 72 | ### UpdateNotify 73 | 74 | Automatically check for updates to Red-DiscordBot, notifying the owner. Also checks for updates to [my docker image](https://hub.docker.com/r/phasecorex/red-discordbot) if you are using that. 75 | 76 | ### UwU 77 | 78 | Uwuize messages. Takes the pwevious mwessage and uwuizes it. Sowwy. 79 | 80 | ### Wikipedia 81 | 82 | Look up articles on Wikipedia. Ported from v2; originally by PaddoInWonderland. I've made some enhancements to it as well. 83 | -------------------------------------------------------------------------------- /autoroom/README.md: -------------------------------------------------------------------------------- 1 | # AutoRoom 2 | 3 | This cog facilitates automatic voice channel creation. When a member joins an AutoRoom Source (voice channel), this cog will move them to a brand new AutoRoom that they have control over. Once everyone leaves the AutoRoom, it is automatically deleted. 4 | 5 | ## For Members - `[p]autoroom` 6 | 7 | Once you join an AutoRoom Source, you will be moved into a brand new AutoRoom (voice channel). This is your AutoRoom, you can do whatever you want with it. Use the `[p]autoroom` command to check out all the different things you can do. Some examples include: 8 | 9 | - Check its current settings with `[p]autoroom settings` 10 | - Make it a public AutoRoom with `[p]autoroom public` (everyone can see and join your AutoRoom) 11 | - Make it a locked AutoRoom with `[p]autoroom locked` (everyone can see, but nobody can join your AutoRoom) 12 | - Make it a private AutoRoom with `[p]autoroom private` (nobody can see and join your AutoRoom) 13 | - Kick/ban users (or entire roles) from your AutoRoom with `[p]autoroom deny` (useful for public AutoRooms) 14 | - Allow users (or roles) into your AutoRoom with `[p]autoroom allow` (useful for locked and private AutoRooms) 15 | - You can manage the messages in your AutoRooms associated text channel 16 | 17 | When everyone leaves your AutoRoom, it will automatically be deleted. 18 | 19 | ## For Server Admins - `[p]autoroomset` 20 | 21 | Start by having a voice channel, and a category (the voice channel does not need to be in the category, but it can be if you want). The voice channel will be the "AutoRoom Source", where your members will join and then be moved into their own AutoRoom (voice channel). These AutoRooms will be created in the category that you choose. 22 | 23 | ``` 24 | [p]autoroomset create 25 | ``` 26 | 27 | This command will guide you through setting up an AutoRoom Source by asking some questions. If you get a warning about missing permissions, take a look at `[p]autoroomset permissions`, grant the missing permissions, and then run the command again. Otherwise, answer the questions, and you'll have a new AutoRoom Source. Give it a try by joining it: if all goes well, you will be moved to a new AutoRoom, where you can do all of the `[p]autoroom` commands. 28 | 29 | There are some additional configuration options for AutoRoom Sources that you can set by using `[p]autoroomset modify`. You can also check out `[p]autoroomset access`, which controls whether admins (default yes) or moderators (default no) can see and join private AutoRooms. For an overview of all of your settings, use `[p]autoroomset settings`. 30 | 31 | #### Member Roles and Hidden Sources 32 | 33 | The AutoRoom Source will behave in a certain way, depending on what permissions it has. If the `@everyone` role is denied the connect permission (and optionally the view channel permission), the AutoRoom Source and AutoRooms will behave in a member role style. Any roles that are allowed to connect on the AutoRoom Source will be considered the "member roles". Only members of the server with one or more of these roles will be able to utilize these AutoRooms and their Sources. 34 | 35 | For hidden AutoRoom Sources, you can deny the view channel permission for the `@everyone` role, but still allow the connect permission. The members won't be able to see the AutoRoom Source, but any AutoRooms it creates they will be able to see (depending on if it isn't a private AutoRoom). Ideally you would have a role that is allowed to see the AutoRoom Source, that role is allowed to create AutoRooms, but then can invite anyone to their AutoRooms. 36 | 37 | You can of course do both of these, where the `@everyone` role is denied view channel and connect, and your member role is denied the view channel permission, but is allowed the connect permission. Non-members will never see the AutoRoom Source and AutoRooms, and the members will not see the AutoRoom Source, but will see AutoRooms. 38 | 39 | #### Templates 40 | 41 | The default AutoRoom name format is based on the AutoRoom Owners username. Using `[p]autoroomset modify name`, you can choose a default format, or you can set a custom format. For custom formats, you have a couple of variables you can use in your template: 42 | 43 | - `{{username}}` - The AutoRoom Owners username 44 | - `{{game}}` - The AutoRoom Owners game they were playing when the AutoRoom was created, or blank if they were not plating a game. 45 | - `{{dupenum}}` - A number, starting at 1, that will increment when the generated AutoRoom name would be a duplicate of an already existing AutoRoom. 46 | 47 | You are also able to use `if`/`elif`/`else`/`endif` statements in order to conditionally show or hide parts of your template. Here are the templates for the two default formats included: 48 | 49 | - `username` - `{{username}}'s Room{% if dupenum > 1 %} ({{dupenum}}){% endif %}` 50 | - `game` - `{{game}}{% if not game %}{{username}}'s Room{% endif %}{% if dupenum > 1 %} ({{dupenum}}){% endif %}` 51 | 52 | The username format is pretty self-explanatory: put the username, along with "'s Room" after it. For the game format, we put the game name, and then if there isn't a game, show `{{username}}'s Room` instead. Remember, if no game is being played, `{{game}}` won't return anything. 53 | 54 | The last bit of both of these is `{% if dupenum > 1 %} ({{dupenum}}){% endif %}`. With this, we are checking if `dupenum` is greater than 1. If it is, we display ` ({{dupenum}})` at the end of our room name. This way, only duplicate named rooms will ever get a ` (2)`, ` (3)`, etc. appended to them, no ` (1)` will be shown. 55 | 56 | Finally, you can use filters in order to format your variables. They are specified by adding a pipe, and then the name of the filter. The following are the currently implemented filters: 57 | 58 | - `{{username | lower}}` - Will lowercase the variable, the username in this example 59 | - `{{game | upper}}` - Will uppercase the variable, the game name in this example 60 | 61 | This template format can also be used for the message hint sent to new AutoRooms built in text channels. For that, you can also use this variable: 62 | 63 | - `{{mention}}` - The AutoRoom Owners mention 64 | -------------------------------------------------------------------------------- /autoroom/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for AutoRoom cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .autoroom import AutoRoom 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load AutoRoom cog.""" 16 | cog = AutoRoom(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /autoroom/abc.py: -------------------------------------------------------------------------------- 1 | """ABC for the AutoRoom Cog.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import Any, ClassVar 5 | 6 | import discord 7 | from discord.ext.commands import CooldownMapping 8 | from redbot.core import Config 9 | from redbot.core.bot import Red 10 | 11 | from autoroom.pcx_template import Template 12 | 13 | 14 | class MixinMeta(ABC): 15 | """Base class for well-behaved type hint detection with composite class. 16 | 17 | Basically, to keep developers sane when not all attributes are defined in each mixin. 18 | """ 19 | 20 | bot: Red 21 | config: Config 22 | template: Template 23 | bucket_autoroom_name: CooldownMapping 24 | bucket_autoroom_owner_claim: CooldownMapping 25 | extra_channel_name_change_delay: int 26 | 27 | perms_legacy_text_allow: ClassVar[dict[str, bool]] 28 | perms_legacy_text_reset: ClassVar[dict[str, None]] 29 | perms_autoroom_owner_legacy_text: ClassVar[dict[str, bool]] 30 | 31 | @staticmethod 32 | @abstractmethod 33 | def get_template_data(member: discord.Member | discord.User) -> dict[str, str]: 34 | raise NotImplementedError 35 | 36 | @abstractmethod 37 | async def format_template_room_name( 38 | self, template: str, data: dict, num: int = 1 39 | ) -> str: 40 | raise NotImplementedError 41 | 42 | @abstractmethod 43 | async def is_admin_or_admin_role(self, who: discord.Role | discord.Member) -> bool: 44 | raise NotImplementedError 45 | 46 | @abstractmethod 47 | async def is_mod_or_mod_role(self, who: discord.Role | discord.Member) -> bool: 48 | raise NotImplementedError 49 | 50 | @abstractmethod 51 | def check_perms_source_dest( 52 | self, 53 | autoroom_source: discord.VoiceChannel, 54 | category_dest: discord.CategoryChannel, 55 | *, 56 | with_manage_roles_guild: bool = False, 57 | with_legacy_text_channel: bool = False, 58 | with_optional_clone_perms: bool = False, 59 | detailed: bool = False, 60 | ) -> tuple[bool, bool, str | None]: 61 | raise NotImplementedError 62 | 63 | @abstractmethod 64 | async def get_all_autoroom_source_configs( 65 | self, guild: discord.Guild 66 | ) -> dict[int, dict[str, Any]]: 67 | raise NotImplementedError 68 | 69 | @abstractmethod 70 | async def get_autoroom_source_config( 71 | self, autoroom_source: discord.VoiceChannel | discord.abc.GuildChannel | None 72 | ) -> dict[str, Any] | None: 73 | raise NotImplementedError 74 | 75 | @abstractmethod 76 | async def get_autoroom_info( 77 | self, autoroom: discord.VoiceChannel | None 78 | ) -> dict[str, Any] | None: 79 | raise NotImplementedError 80 | 81 | @abstractmethod 82 | async def get_autoroom_legacy_text_channel( 83 | self, autoroom: discord.VoiceChannel | int | None 84 | ) -> discord.TextChannel | None: 85 | raise NotImplementedError 86 | 87 | @staticmethod 88 | @abstractmethod 89 | def check_if_member_or_role_allowed( 90 | channel: discord.VoiceChannel, 91 | member_or_role: discord.Member | discord.Role, 92 | ) -> bool: 93 | raise NotImplementedError 94 | 95 | @abstractmethod 96 | def get_member_roles( 97 | self, autoroom_source: discord.VoiceChannel 98 | ) -> list[discord.Role]: 99 | raise NotImplementedError 100 | 101 | @abstractmethod 102 | async def get_bot_roles(self, guild: discord.Guild) -> list[discord.Role]: 103 | raise NotImplementedError 104 | -------------------------------------------------------------------------------- /autoroom/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "AutoRoom", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Automatic voice channel management.", 7 | "description": "This cog facilitates automatic voice channel creation. When a member joins an AutoRoom Source (voice channel), this cog will move them to a brand new AutoRoom that they have control over. Once everyone leaves the AutoRoom, it is automatically deleted.", 8 | "install_msg": "Thanks for installing AutoRoom! For a quick rundown on how to get started with this cog, check out [the readme](https://github.com/PhasecoreX/PCXCogs/tree/master/autoroom/README.md)", 9 | "requirements": [ 10 | "func-timeout", 11 | "jinja2" 12 | ], 13 | "tags": [ 14 | "audio", 15 | "auto", 16 | "automated", 17 | "automatic", 18 | "channel", 19 | "room", 20 | "voice" 21 | ], 22 | "min_bot_version": "3.5.0", 23 | "min_python_version": [ 24 | 3, 25 | 11, 26 | 0 27 | ], 28 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 29 | } 30 | -------------------------------------------------------------------------------- /autoroom/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /autoroom/pcx_template.py: -------------------------------------------------------------------------------- 1 | """Module for template engine using Jinja2, safe for untrusted user templates.""" 2 | 3 | import random 4 | from typing import Any 5 | 6 | from func_timeout import FunctionTimedOut, func_timeout 7 | from jinja2 import Undefined, pass_context 8 | from jinja2.exceptions import TemplateError 9 | from jinja2.runtime import Context 10 | from jinja2.sandbox import ImmutableSandboxedEnvironment 11 | 12 | TIMEOUT = 0.25 # Maximum runtime for template rendering in seconds / should be very low to avoid DoS attacks 13 | 14 | 15 | class TemplateTimeoutError(TemplateError): 16 | """Custom exception raised when template rendering exceeds maximum runtime.""" 17 | 18 | 19 | class SilentUndefined(Undefined): 20 | """Class that converts Undefined type to None.""" 21 | 22 | def _fail_with_undefined_error(self, *_args: Any, **_kwargs: Any) -> None: # type: ignore[incorrect-return-type] # noqa: ANN401 23 | return None 24 | 25 | 26 | class Template: 27 | """A template engine using Jinja2, safe for untrusted user templates with an immutable sandbox.""" 28 | 29 | def __init__(self) -> None: 30 | """Set up the Jinja2 environment with an immutable sandbox.""" 31 | self.env = ImmutableSandboxedEnvironment( 32 | finalize=self.finalize, 33 | undefined=SilentUndefined, 34 | keep_trailing_newline=True, 35 | trim_blocks=True, 36 | lstrip_blocks=True, 37 | ) 38 | 39 | # Override Jinja's built-in random filter with a deterministic version 40 | self.env.filters["random"] = self.deterministic_random 41 | 42 | @pass_context 43 | def deterministic_random(self, ctx: Context, seq: list) -> Any: # noqa: ANN401 44 | """Generate a deterministic random choice from a sequence based on the context's random_seed.""" 45 | seed = ctx.get( 46 | "random_seed", random.getrandbits(32) 47 | ) # Use seed from context or default 48 | random.seed(seed) 49 | return random.choice(seq) # Return a deterministic random choice # noqa: S311 50 | 51 | def finalize(self, element: Any) -> Any: # noqa: ANN401 52 | """Callable that converts None elements to an empty string.""" 53 | return element if element is not None else "" 54 | 55 | def _render_template(self, template_str: str, data: dict[str, Any]) -> str: 56 | """Render the template to a string.""" 57 | return self.env.from_string(template_str).render(data) 58 | 59 | async def render( 60 | self, 61 | template_str: str, 62 | data: dict[str, Any] | None = None, 63 | timeout: float = TIMEOUT, # noqa: ASYNC109 64 | ) -> str: 65 | """Render a template with the given data, enforcing a maximum runtime.""" 66 | if data is None: 67 | data = {} 68 | try: 69 | result: str = func_timeout( 70 | timeout, self._render_template, args=(template_str, data) 71 | ) # type: ignore[unknown-return-type] 72 | except FunctionTimedOut as err: 73 | msg = f"Template rendering exceeded {timeout} seconds." 74 | raise TemplateTimeoutError(msg) from err 75 | return result 76 | -------------------------------------------------------------------------------- /bancheck/README.md: -------------------------------------------------------------------------------- 1 | # BanCheck 2 | 3 | This cog allows server admins to check their members against multiple external ban lists. It can also automatically check new members that join the server, and optionally ban them if they appear in a list. This cog is decently complex to set up, so hopefully this document can help you out. 4 | 5 | ## For Bot Owners - `[p]banchecksetglobal` 6 | 7 | There are certain ban list APIs that can only be set up for the entire bot (instead of per server). Usually this is due to the fact that your bot needs to go through a verification process before you get an API key, and only one key per bot is issued. At the time of writing, [Ravy](https://ravy.org/api) is the only one that does this. Once you set the API key, the ban list checking functionality for that service will be available for use in all servers your bot is a part of. The admins of the servers will need to manually enable the service for checking, however. 8 | 9 | ``` 10 | [p]banchecksetglobal settings 11 | ``` 12 | 13 | Using this command will list all the bot-wide ban list services that are supported. Clicking the link will bring you to that services website, where you can apply for an API key. Once you have an API key, you can check `[p]banchecksetglobal api ` for info on how to set the API. Once you have set the API correctly, you can again check `[p]banchecksetglobal settings` and see that your service is set. 14 | 15 | That's all the setup you need to do for these services. To actually use these services, see below. 16 | 17 | ## For Server Admins - `[p]bancheckset` 18 | 19 | Your best friend for setting up BanCheck is the following command: 20 | 21 | ``` 22 | [p]bancheckset settings 23 | ``` 24 | 25 | Using that will give you a rundown of the current state of the BanCheck cog in your server (and I am quite proud of it myself!) 26 | 27 | ## Services 28 | 29 | Since you don't have any enabled services, the above command will instruct you to check out `[p]bancheckset service settings` for more information. This will give you an overview of which services you can enable or disable, and which ones need an API key (either from you or the bot owner). 30 | 31 | If a service is missing an API key, you can follow the link to the services website and obtain an API key. These API keys can be set with `[p]bancheckset service api [api_key]`. If a service is missing a global API key, the API key can only be set up by the bot owner (bot owners, see above section). 32 | 33 | You can enable or disable services at any time (even if their API key is missing) by using `[p]bancheckset service `. If enabled and their API key is missing, the service will begin working automatically once you (or the bot owner) supplies it. This can be useful, for example, by enabling a service that requires a global API key, and then once the bot owner gets around to verifying their bot and setting the global API key, it will automatically be used for ban checking in your server. 34 | 35 | At this point, you have some services enabled, and can verify this in `[p]bancheckset settings`. You are now able to use the `[p]bancheck` command to manually check other members (or yourself), either with their ID or their mention. 36 | 37 | ## AutoCheck 38 | 39 | If you want every joining member to automatically be checked with the enabled services, head on over to `[p]bancheckset autocheck`. From there, you can set the channel that the AutoCheck notifications will be sent to. Verify that you have set this up correctly with `[p]bancheckset settings`. 40 | 41 | ## AutoBan 42 | 43 | In addition to automatically checking each new member, you can set it so that anyone appearing on a services ban list will be banned on the spot, with the user getting a message explaining why they were banned (they were on a specific global ban list). Check out `[p]bancheckset autoban` to enable or disable AutoBan functionality for specific services. Again, verify that you have set this up correctly with `[p]bancheckset settings`. 44 | 45 | ## Finish! 46 | 47 | Once you have done the above, you can once again verify that you have set everything up correctly with `[p]bancheckset settings`. Enjoy! 48 | -------------------------------------------------------------------------------- /bancheck/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for BanCheck cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .bancheck import BanCheck 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load BanCheck cog.""" 16 | cog = BanCheck(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /bancheck/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BanCheck", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Automatically check users against multiple global ban lists.", 7 | "description": "This cog allows server admins to check their members against multiple external ban lists. It can also automatically check new members that join the server, and optionally ban them if they appear in a list.", 8 | "install_msg": "Thanks for installing BanCheck! For a quick rundown on how to get started with this cog, check out [the readme](https://github.com/PhasecoreX/PCXCogs/tree/master/bancheck/README.md)", 9 | "tags": [ 10 | "auto", 11 | "automated", 12 | "automatic", 13 | "ban", 14 | "check", 15 | "lookup", 16 | "moderation", 17 | "utility" 18 | ], 19 | "min_bot_version": "3.5.0", 20 | "min_python_version": [ 21 | 3, 22 | 11, 23 | 0 24 | ], 25 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 26 | } 27 | -------------------------------------------------------------------------------- /bancheck/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /bancheck/services/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for services.""" 2 | -------------------------------------------------------------------------------- /bancheck/services/antiraid.py: -------------------------------------------------------------------------------- 1 | """Ban lookup for Antiraid.""" 2 | 3 | import aiohttp 4 | from redbot.core import __version__ as redbot_version 5 | 6 | from .dto.lookup_result import LookupResult 7 | 8 | user_agent = ( 9 | f"Red-DiscordBot/{redbot_version} BanCheck (https://github.com/PhasecoreX/PCXCogs)" 10 | ) 11 | 12 | 13 | class Antiraid: 14 | """Ban lookup for Antiraid.""" 15 | 16 | SERVICE_NAME = "Antiraid" 17 | SERVICE_API_KEY_REQUIRED = False 18 | SERVICE_URL = "https://banapi.derpystown.com/" 19 | SERVICE_HINT = None 20 | BASE_URL = "https://banapi.derpystown.com" 21 | 22 | @staticmethod 23 | async def lookup(user_id: int, _api_key: str) -> LookupResult: 24 | """Perform user lookup on Antiraid.""" 25 | try: 26 | async with aiohttp.ClientSession() as session, session.get( 27 | f"{Antiraid.BASE_URL}/bans/{user_id}", 28 | headers={ 29 | "user-agent": user_agent, 30 | }, 31 | ) as resp: 32 | # Response 200 examples: 33 | # { 34 | # "banned": true, 35 | # "usertag": "PhasecoreX#0000", 36 | # "userid": "140926691442359926", 37 | # "caseid": "1", 38 | # "reason": "Being too cool", 39 | # "proof": "https://www.youtube.com/watch?v=I7Tps0M-l64", 40 | # "bandate": "11-08-2022 11:31 AM" 41 | # } 42 | # 43 | # { 44 | # "banned": false 45 | # } 46 | data = await resp.json() 47 | if "banned" in data: 48 | # "banned" will always be in a successful lookup 49 | if data["banned"]: 50 | return LookupResult( 51 | Antiraid.SERVICE_NAME, 52 | "ban", 53 | reason=data["reason"], 54 | proof_url=data.get("proof", None), 55 | ) 56 | return LookupResult(Antiraid.SERVICE_NAME, "clear") 57 | # Otherwise, failed lookup 58 | return LookupResult(Antiraid.SERVICE_NAME, "error") 59 | 60 | except aiohttp.ClientConnectionError: 61 | return LookupResult( 62 | Antiraid.SERVICE_NAME, 63 | "error", 64 | reason="Could not connect to host", 65 | ) 66 | except aiohttp.ClientError: 67 | pass # All non-ClientConnectionError aiohttp exceptions are treated as malformed data 68 | except TypeError: 69 | pass # resp.json() is None (malformed data) 70 | except KeyError: 71 | pass # json element does not exist (malformed data) 72 | return LookupResult( 73 | Antiraid.SERVICE_NAME, 74 | "error", 75 | reason="Response data malformed", 76 | ) 77 | -------------------------------------------------------------------------------- /bancheck/services/dto/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for dto.""" 2 | -------------------------------------------------------------------------------- /bancheck/services/dto/lookup_result.py: -------------------------------------------------------------------------------- 1 | """A user lookup result.""" 2 | 3 | 4 | class LookupResult: 5 | """A user lookup result.""" 6 | 7 | def __init__( 8 | self, 9 | service: str, 10 | result: str, 11 | *, 12 | reason: str | None = None, 13 | proof_url: str | None = None, 14 | ) -> None: 15 | """Create the base lookup result.""" 16 | self.service = service 17 | self.result = result 18 | self.reason = reason 19 | self.proof_url = proof_url 20 | -------------------------------------------------------------------------------- /bansync/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for BanSync cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .bansync import BanSync 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load BanSync cog.""" 16 | cog = BanSync(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /bansync/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BanSync", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Automatically sync moderation actions across servers.", 7 | "description": "This cog allows server admins to have moderation actions automatically applied to members on their server when those actions are performed on another server that the bot is in.", 8 | "install_msg": "Thanks for installing BanSync!", 9 | "tags": [ 10 | "auto", 11 | "automated", 12 | "automatic", 13 | "ban", 14 | "moderation", 15 | "pull", 16 | "sync", 17 | "timeout", 18 | "utility" 19 | ], 20 | "min_bot_version": "3.5.0", 21 | "min_python_version": [ 22 | 3, 23 | 11, 24 | 0 25 | ], 26 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 27 | } 28 | -------------------------------------------------------------------------------- /bansync/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /decodebinary/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for DecodeBinary cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .decodebinary import DecodeBinary 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load DecodeBinary cog.""" 16 | cog = DecodeBinary(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /decodebinary/decodebinary.py: -------------------------------------------------------------------------------- 1 | """DecodeBinary cog for Red-DiscordBot by PhasecoreX.""" 2 | 3 | import re 4 | from typing import ClassVar 5 | 6 | import discord 7 | from redbot.core import Config, checks, commands 8 | from redbot.core.bot import Red 9 | from redbot.core.utils.chat_formatting import info, success 10 | 11 | from .pcx_lib import SettingDisplay, type_message 12 | 13 | 14 | class DecodeBinary(commands.Cog): 15 | """Decodes binary strings to human-readable ones. 16 | 17 | The bot will check every message sent by users for binary and try to 18 | convert it to human-readable text. You can check that it is working 19 | by sending this message in a channel: 20 | 21 | 01011001011000010111100100100001 22 | """ 23 | 24 | __author__ = "PhasecoreX" 25 | __version__ = "1.2.1" 26 | 27 | default_global_settings: ClassVar[dict[str, int]] = {"schema_version": 0} 28 | default_guild_settings: ClassVar[dict[str, list[int]]] = {"ignored_channels": []} 29 | 30 | def __init__(self, bot: Red) -> None: 31 | """Set up the cog.""" 32 | super().__init__() 33 | self.bot = bot 34 | self.config = Config.get_conf( 35 | self, identifier=1224364860, force_registration=True 36 | ) 37 | self.config.register_global(**self.default_global_settings) 38 | self.config.register_guild(**self.default_guild_settings) 39 | 40 | # 41 | # Red methods 42 | # 43 | 44 | def format_help_for_context(self, ctx: commands.Context) -> str: 45 | """Show version in help.""" 46 | pre_processed = super().format_help_for_context(ctx) 47 | return f"{pre_processed}\n\nCog Version: {self.__version__}" 48 | 49 | async def red_delete_data_for_user(self, *, _requester: str, _user_id: int) -> None: 50 | """Nothing to delete.""" 51 | return 52 | 53 | # 54 | # Initialization methods 55 | # 56 | 57 | async def initialize(self) -> None: 58 | """Perform setup actions before loading cog.""" 59 | await self._migrate_config() 60 | 61 | async def _migrate_config(self) -> None: 62 | """Perform some configuration migrations.""" 63 | schema_version = await self.config.schema_version() 64 | 65 | if schema_version < 1: 66 | # Remove "ignore_guild" 67 | guild_dict = await self.config.all_guilds() 68 | for guild_id in guild_dict: 69 | await self.config.guild_from_id(guild_id).clear_raw("ignore_guild") 70 | await self.config.schema_version.set(1) 71 | 72 | # 73 | # Command methods: decodebinaryset 74 | # 75 | 76 | @commands.group() 77 | @commands.guild_only() 78 | @checks.admin_or_permissions(manage_guild=True) 79 | async def decodebinaryset(self, ctx: commands.Context) -> None: 80 | """Change DecodeBinary settings.""" 81 | 82 | @decodebinaryset.command() 83 | async def settings(self, ctx: commands.Context) -> None: 84 | """Display current settings.""" 85 | if not ctx.guild: 86 | return 87 | ignored_channels = await self.config.guild(ctx.guild).ignored_channels() 88 | channel_section = SettingDisplay("Channel Settings") 89 | channel_section.add( 90 | "Enabled in this channel", 91 | "No" if ctx.message.channel.id in ignored_channels else "Yes", 92 | ) 93 | await ctx.send(str(channel_section)) 94 | 95 | @decodebinaryset.group() 96 | async def ignore(self, ctx: commands.Context) -> None: 97 | """Change DecodeBinary ignore settings.""" 98 | 99 | @ignore.command() 100 | async def server(self, ctx: commands.Context) -> None: 101 | """Ignore/Unignore the current server.""" 102 | await ctx.send( 103 | info( 104 | "Use the `[p]command enablecog` and `[p]command disablecog` to enable or disable this cog." 105 | ) 106 | ) 107 | 108 | @ignore.command() 109 | async def channel(self, ctx: commands.Context) -> None: 110 | """Ignore/Unignore the current channel.""" 111 | if not ctx.guild: 112 | return 113 | async with self.config.guild(ctx.guild).ignored_channels() as ignored_channels: 114 | if ctx.channel.id in ignored_channels: 115 | ignored_channels.remove(ctx.channel.id) 116 | await ctx.send(success("I will no longer ignore this channel.")) 117 | else: 118 | ignored_channels.append(ctx.channel.id) 119 | await ctx.send(success("I will ignore this channel.")) 120 | 121 | # 122 | # Listener methods 123 | # 124 | 125 | @commands.Cog.listener() 126 | async def on_message_without_command(self, message: discord.Message) -> None: 127 | """Grab messages and see if we can decode them from binary.""" 128 | if message.guild is None: 129 | return 130 | if await self.bot.cog_disabled_in_guild(self, message.guild): 131 | return 132 | if message.author.bot: 133 | return 134 | if ( 135 | message.channel.id 136 | in await self.config.guild(message.guild).ignored_channels() 137 | ): 138 | return 139 | 140 | pattern = re.compile(r"[01]{7}[01 ]*[01]") 141 | found = pattern.findall(message.content) 142 | if found: 143 | await self.do_translation(message, found) 144 | 145 | # 146 | # Public methods 147 | # 148 | 149 | async def do_translation( 150 | self, orig_message: discord.Message, found: list[str] 151 | ) -> None: 152 | """Translate each found string and sends a message.""" 153 | translated_messages = [self.decode_binary_string(encoded) for encoded in found] 154 | 155 | if len(translated_messages) == 1 and translated_messages[0]: 156 | await type_message( 157 | orig_message.channel, 158 | f'{orig_message.author.display_name}\'s message said:\n"{translated_messages[0]}"', 159 | allowed_mentions=discord.AllowedMentions( 160 | everyone=False, users=False, roles=False 161 | ), 162 | ) 163 | 164 | elif len(translated_messages) > 1: 165 | one_was_translated = False 166 | msg = f"{orig_message.author.display_name}'s {len(translated_messages)} messages said:" 167 | for translated_counter, translated_message in enumerate( 168 | translated_messages 169 | ): 170 | if translated_message: 171 | one_was_translated = True 172 | msg += f'\n{translated_counter + 1}. "{translated_message}"' 173 | else: 174 | msg += ( 175 | f"\n{translated_counter + 1}. (Couldn't translate this one...)" 176 | ) 177 | if one_was_translated: 178 | await type_message( 179 | orig_message.channel, 180 | msg, 181 | allowed_mentions=discord.AllowedMentions( 182 | everyone=False, users=False, roles=False 183 | ), 184 | ) 185 | 186 | @staticmethod 187 | def decode_binary_string(string: str) -> str: 188 | """Convert a string of 1's, 0's, and spaces into an ascii string.""" 189 | string = string.replace(" ", "") 190 | if len(string) % 8 != 0: 191 | return "" 192 | result = "".join( 193 | chr(int(string[i * 8 : i * 8 + 8], 2)) for i in range(len(string) // 8) 194 | ) 195 | if DecodeBinary.is_ascii(result): 196 | return result 197 | return "" 198 | 199 | @staticmethod 200 | def is_ascii(string: str) -> bool: 201 | """Check if a string is fully ascii characters.""" 202 | try: 203 | string.encode("ascii") 204 | except UnicodeEncodeError: 205 | return False 206 | else: 207 | return True 208 | -------------------------------------------------------------------------------- /decodebinary/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DecodeBinary", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Automatically decode binary strings in chat.", 7 | "description": "Automatically decodes binary strings to human readable ones when detected in chat.", 8 | "install_msg": "01011001011000010111100100100001", 9 | "tags": [ 10 | "auto", 11 | "automated", 12 | "automatic", 13 | "binary", 14 | "convert", 15 | "decode" 16 | ], 17 | "min_bot_version": "3.5.0", 18 | "min_python_version": [ 19 | 3, 20 | 11, 21 | 0 22 | ], 23 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 24 | } 25 | -------------------------------------------------------------------------------- /decodebinary/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /dice/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for Dice cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .dice import Dice 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load Dice cog.""" 16 | cog = Dice(bot) 17 | await bot.add_cog(cog) 18 | -------------------------------------------------------------------------------- /dice/dice.py: -------------------------------------------------------------------------------- 1 | """Dice cog for Red-DiscordBot by PhasecoreX.""" 2 | 3 | import asyncio 4 | import re 5 | from contextlib import suppress 6 | from typing import ClassVar 7 | 8 | import pyhedrals 9 | from redbot.core import Config, checks, commands 10 | from redbot.core.bot import Red 11 | from redbot.core.utils.chat_formatting import error, question, success 12 | from redbot.core.utils.predicates import MessagePredicate 13 | 14 | from .pcx_lib import SettingDisplay 15 | 16 | MAX_ROLLS_NOTIFY = 1000000 17 | MAX_MESSAGE_LENGTH = 2000 18 | 19 | 20 | class Dice(commands.Cog): 21 | """Perform complex dice rolling.""" 22 | 23 | __author__ = "PhasecoreX" 24 | __version__ = "2.1.0" 25 | 26 | default_global_settings: ClassVar[dict[str, int]] = { 27 | "max_dice_rolls": 10000, 28 | "max_die_sides": 10000, 29 | } 30 | DROPPED_EXPLODED_RE = re.compile(r"-\*(\d+)\*-") 31 | EXPLODED_RE = re.compile(r"\*(\d+)\*") 32 | DROPPED_RE = re.compile(r"-(\d+)-") 33 | 34 | def __init__(self, bot: Red) -> None: 35 | """Set up the cog.""" 36 | super().__init__() 37 | self.bot = bot 38 | self.config = Config.get_conf( 39 | self, identifier=1224364860, force_registration=True 40 | ) 41 | self.config.register_global(**self.default_global_settings) 42 | 43 | # 44 | # Red methods 45 | # 46 | 47 | def format_help_for_context(self, ctx: commands.Context) -> str: 48 | """Show version in help.""" 49 | pre_processed = super().format_help_for_context(ctx) 50 | return f"{pre_processed}\n\nCog Version: {self.__version__}" 51 | 52 | async def red_delete_data_for_user(self, *, _requester: str, _user_id: int) -> None: 53 | """Nothing to delete.""" 54 | return 55 | 56 | # 57 | # Command methods: diceset 58 | # 59 | 60 | @commands.group() 61 | @checks.is_owner() 62 | async def diceset(self, ctx: commands.Context) -> None: 63 | """Manage Dice settings.""" 64 | 65 | @diceset.command() 66 | async def settings(self, ctx: commands.Context) -> None: 67 | """Display current settings.""" 68 | global_section = SettingDisplay("Global Settings") 69 | global_section.add( 70 | "Maximum number of dice to roll at once", await self.config.max_dice_rolls() 71 | ) 72 | global_section.add("Maximum sides per die", await self.config.max_die_sides()) 73 | await ctx.send(str(global_section)) 74 | 75 | @diceset.command() 76 | async def rolls(self, ctx: commands.Context, maximum: int) -> None: 77 | """Set the maximum number of dice a user can roll at one time. 78 | 79 | More formally, the maximum number of random numbers the bot will generate for any one dice calculation. 80 | 81 | Warning: 82 | ------- 83 | Setting this too high will allow other users to slow down/freeze/crash your bot! 84 | Generating random numbers is easily the most CPU consuming process here, 85 | so keep this number low (less than one million, and way less than that on a Pi) 86 | 87 | """ 88 | action = "is already set at" 89 | if maximum == await self.config.max_dice_rolls(): 90 | pass 91 | elif maximum > MAX_ROLLS_NOTIFY: 92 | pred = MessagePredicate.yes_or_no(ctx) 93 | await ctx.send( 94 | question( 95 | f"Are you **sure** you want to set the maximum rolls to {maximum}? (yes/no)\n" 96 | "Setting this over one million will allow other users to slow down/freeze/crash your bot!" 97 | ) 98 | ) 99 | with suppress(asyncio.TimeoutError): 100 | await ctx.bot.wait_for("message", check=pred, timeout=30) 101 | if pred.result: 102 | await self.config.max_dice_rolls.set(maximum) 103 | action = "is now set to" 104 | else: 105 | await ctx.send( 106 | error( 107 | f"Maximum dice rolls per user has been left at {await self.config.max_dice_rolls()}" 108 | ) 109 | ) 110 | return 111 | else: 112 | await self.config.max_dice_rolls.set(maximum) 113 | action = "is now set to" 114 | 115 | await ctx.send( 116 | success( 117 | f"Maximum dice rolls per user {action} {await self.config.max_dice_rolls()}" 118 | ) 119 | ) 120 | 121 | @diceset.command() 122 | async def sides(self, ctx: commands.Context, maximum: int) -> None: 123 | """Set the maximum number of sides a die can have. 124 | 125 | Python seems to be pretty good at generating huge random numbers and doing math on them. 126 | There should be sufficient safety checks in place to mitigate anything getting too crazy. 127 | But be honest, do you really need to roll multiple five trillion sided dice at once? 128 | """ 129 | await self.config.max_die_sides.set(maximum) 130 | await ctx.send( 131 | success( 132 | f"Maximum die sides is now set to {await self.config.max_die_sides()}" 133 | ) 134 | ) 135 | 136 | # 137 | # Command methods 138 | # 139 | 140 | @commands.command() 141 | async def dice(self, ctx: commands.Context, *, roll: str) -> None: 142 | """Perform die roll based on a dice formula. 143 | 144 | The [PyHedrals](https://github.com/StarlitGhost/pyhedrals) library is used for dice formula parsing. 145 | Use the link above to learn the notation allowed. Below are a few examples: 146 | 147 | `2d20kh` - Roll 2d20, keep highest die (e.g. initiative advantage) 148 | `4d4!+2` - Roll 4d4, explode on any 4s, add 2 to result 149 | `4d6rdl` - Roll 4d6, reroll all 1s, then drop the lowest die 150 | `6d6c>4` - Roll 6d6, count all dice greater than 4 as successes 151 | `10d10r<=2kh6` - Roll 10d10, reroll all dice less than or equal to 2, then keep the highest 6 dice 152 | 153 | Modifier order does matter, and usually they allow for specifying a specific number or number ranges after them. 154 | """ 155 | try: 156 | dice_roller = pyhedrals.DiceRoller( 157 | maxDice=await self.config.max_dice_rolls(), 158 | maxSides=await self.config.max_die_sides(), 159 | ) 160 | result = dice_roller.parse(roll) 161 | roll_message = f"\N{GAME DIE} {ctx.message.author.mention} rolled {roll} and got **{result.result}**" 162 | if len(roll_message) > MAX_MESSAGE_LENGTH: 163 | roll_message = f"\N{GAME DIE} {ctx.message.author.mention} rolled that and got **{result.result}**" 164 | if len(roll_message) > MAX_MESSAGE_LENGTH: 165 | await ctx.send( 166 | error( 167 | f"{ctx.message.author.mention}, I can't give you the result of that roll as it doesn't fit in a Discord message" 168 | ) 169 | ) 170 | return 171 | roll_log = "\n".join(result.strings()) 172 | roll_log = self.DROPPED_EXPLODED_RE.sub(r"~~**\1!**~~", roll_log) 173 | roll_log = self.EXPLODED_RE.sub(r"**\1!**", roll_log) 174 | roll_log = self.DROPPED_RE.sub(r"~~\1~~", roll_log) 175 | roll_log = roll_log.replace(",", ", ") 176 | if len(roll_message) + len(roll_log) > MAX_MESSAGE_LENGTH: 177 | roll_log = "*(Roll log too long to display)*" 178 | await ctx.send(f"{roll_message}\n{roll_log}") 179 | except ( 180 | ValueError, 181 | NotImplementedError, 182 | pyhedrals.InvalidOperandsException, 183 | pyhedrals.SyntaxErrorException, 184 | pyhedrals.UnknownCharacterException, 185 | ) as exception: 186 | await ctx.send( 187 | error( 188 | f"{ctx.message.author.mention}, I couldn't parse your dice formula:\n`{exception!s}`" 189 | ) 190 | ) 191 | -------------------------------------------------------------------------------- /dice/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dice", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Perform complex dice rolling.", 7 | "description": "This cog allows for rolling complex dice, such as 3d8+4 or (4d6+3)*2.", 8 | "install_msg": "Thanks for installing Dice! As far as I can tell, this cog is safe to use. I have put many checks in place so that users cannot perform CPU-pegging calculations. In the event that you do find some sort of dice notation that pegs the CPU, PLEASE let me know.", 9 | "requirements": [ 10 | "pyhedrals" 11 | ], 12 | "tags": [ 13 | "chance", 14 | "d20", 15 | "dnd", 16 | "random", 17 | "roll", 18 | "utility" 19 | ], 20 | "min_bot_version": "3.5.0", 21 | "min_python_version": [ 22 | 3, 23 | 11, 24 | 0 25 | ], 26 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 27 | } 28 | -------------------------------------------------------------------------------- /dice/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /heartbeat/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for Heartbeat cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .heartbeat import Heartbeat 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load Heartbeat cog.""" 16 | cog = Heartbeat(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /heartbeat/heartbeat.py: -------------------------------------------------------------------------------- 1 | """Heartbeat cog for Red-DiscordBot by PhasecoreX.""" 2 | 3 | import asyncio 4 | import datetime 5 | import logging 6 | import math 7 | from contextlib import suppress 8 | from datetime import timedelta 9 | from typing import Any, ClassVar 10 | from urllib.parse import urlparse 11 | 12 | import aiohttp 13 | from redbot.core import Config, checks, commands 14 | from redbot.core import __version__ as redbot_version 15 | from redbot.core.bot import Red 16 | from redbot.core.utils.chat_formatting import error, humanize_timedelta, success 17 | 18 | from .pcx_lib import SettingDisplay, delete 19 | 20 | user_agent = ( 21 | f"Red-DiscordBot/{redbot_version} Heartbeat (https://github.com/PhasecoreX/PCXCogs)" 22 | ) 23 | log = logging.getLogger("red.pcxcogs.heartbeat") 24 | 25 | MIN_HEARTBEAT_SECONDS = 60 26 | 27 | 28 | class Heartbeat(commands.Cog): 29 | """Monitor the uptime of your bot. 30 | 31 | The bot owner can specify a URL that the bot will ping (send a GET request) 32 | at a configurable frequency. Using this with an uptime tracking service can 33 | warn you when your bot isn't connected to the internet (and thus usually 34 | not connected to Discord). 35 | """ 36 | 37 | __author__ = "PhasecoreX" 38 | __version__ = "2.0.1" 39 | 40 | default_global_settings: ClassVar[dict[str, int]] = { 41 | "schema_version": 0, 42 | "frequency": MIN_HEARTBEAT_SECONDS, 43 | } 44 | 45 | default_endpoint_settings: ClassVar[dict[str, bool | str]] = { 46 | "url": "", 47 | "ssl_verify": True, 48 | } 49 | 50 | def __init__(self, bot: Red) -> None: 51 | """Set up the cog.""" 52 | super().__init__() 53 | self.bot = bot 54 | self.config = Config.get_conf( 55 | self, identifier=1224364860, force_registration=True 56 | ) 57 | self.config.register_global(**self.default_global_settings) 58 | self.config.init_custom("ENDPOINT", 1) 59 | self.config.register_custom("ENDPOINT", **self.default_endpoint_settings) 60 | self.session = aiohttp.ClientSession() 61 | self.current_errors: dict[str, str] = {} 62 | self.next_heartbeat = datetime.datetime.now(datetime.UTC) 63 | self.bg_loop_task = None 64 | self.background_tasks = set() 65 | 66 | # 67 | # Red methods 68 | # 69 | 70 | def cog_unload(self) -> None: 71 | """Clean up when cog shuts down.""" 72 | if self.bg_loop_task: 73 | self.bg_loop_task.cancel() 74 | task = asyncio.create_task(self.session.close()) 75 | self.background_tasks.add(task) 76 | task.add_done_callback(self.background_tasks.discard) 77 | 78 | def format_help_for_context(self, ctx: commands.Context) -> str: 79 | """Show version in help.""" 80 | pre_processed = super().format_help_for_context(ctx) 81 | return f"{pre_processed}\n\nCog Version: {self.__version__}" 82 | 83 | async def red_delete_data_for_user(self, *, _requester: str, _user_id: int) -> None: 84 | """Nothing to delete.""" 85 | return 86 | 87 | # 88 | # Initialization methods 89 | # 90 | 91 | async def initialize(self) -> None: 92 | """Perform setup actions before loading cog.""" 93 | await self._migrate_config() 94 | self.enable_bg_loop() 95 | 96 | async def _migrate_config(self) -> None: 97 | """Perform some configuration migrations.""" 98 | schema_version = await self.config.schema_version() 99 | 100 | if schema_version < 1: 101 | # Support multiple URLs 102 | url = await self.config.get_raw("url", default="") 103 | if url: 104 | await self.config.custom( 105 | "ENDPOINT", 106 | await self.get_new_endpoint_id(url), 107 | ).set({"url": url}) 108 | await self.config.clear_raw("url") 109 | await self.config.schema_version.set(1) 110 | 111 | # 112 | # Background loop methods 113 | # 114 | 115 | def enable_bg_loop(self) -> None: 116 | """Set up the background loop task.""" 117 | 118 | def error_handler(fut: asyncio.Future) -> None: 119 | try: 120 | fut.result() 121 | except asyncio.CancelledError: 122 | pass 123 | except Exception as exc: 124 | log.exception( 125 | "Unexpected exception occurred in background loop of Heartbeat: ", 126 | exc_info=exc, 127 | ) 128 | task = asyncio.create_task( 129 | self.bot.send_to_owners( 130 | "An unexpected exception occurred in the background loop of Heartbeat:\n" 131 | f"```{exc!s}```" 132 | "Heartbeat pings will not be sent until Heartbeat is reloaded.\n" 133 | "Check your console or logs for more details, and consider opening a bug report for this." 134 | ) 135 | ) 136 | self.background_tasks.add(task) 137 | task.add_done_callback(self.background_tasks.discard) 138 | 139 | if self.bg_loop_task: 140 | self.bg_loop_task.cancel() 141 | self.bg_loop_task = self.bot.loop.create_task(self.bg_loop()) 142 | self.bg_loop_task.add_done_callback(error_handler) 143 | 144 | async def bg_loop(self) -> None: 145 | """Background loop.""" 146 | await self.bot.wait_until_ready() 147 | endpoints_raw = await self.config.custom( 148 | "ENDPOINT" 149 | ).all() # Does NOT return default values 150 | if not endpoints_raw: 151 | return 152 | endpoints = {} 153 | for endpoint in endpoints_raw: 154 | endpoints[endpoint] = await self.config.custom( 155 | "ENDPOINT", endpoint 156 | ).all() # Returns default values 157 | frequency = await self.config.frequency() 158 | frequency = float(max(frequency, MIN_HEARTBEAT_SECONDS)) 159 | while True: 160 | errors: dict[str, str] = {} 161 | for endpoint_id, config in endpoints.items(): 162 | error = await self.send_heartbeat(config) 163 | if error: 164 | errors[endpoint_id] = error 165 | self.current_errors = errors 166 | self.next_heartbeat = datetime.datetime.now( 167 | datetime.UTC 168 | ) + datetime.timedelta(0, frequency) 169 | await asyncio.sleep(frequency) 170 | 171 | async def send_heartbeat(self, config: dict) -> str | None: 172 | """Send a heartbeat ping. 173 | 174 | Returns error message if error, None otherwise 175 | """ 176 | if not config["url"]: 177 | return "No URL supplied" 178 | url = config["url"] 179 | if "{{ping}}" in url: 180 | ping: float = self.bot.latency 181 | if ping is None or math.isnan(ping): 182 | ping = 0 183 | url = url.replace("{{ping}}", f"{ping*1000:.2f}") 184 | last_exception = None 185 | retries = 3 186 | while retries > 0: 187 | try: 188 | await self.session.get( 189 | url, 190 | headers={"user-agent": user_agent}, 191 | ssl=config["ssl_verify"], 192 | ) 193 | except (TimeoutError, aiohttp.ClientConnectionError) as exc: 194 | last_exception = exc 195 | else: 196 | return None 197 | retries -= 1 198 | if last_exception: 199 | return str(last_exception) 200 | return None 201 | 202 | # 203 | # Command methods: heartbeat 204 | # 205 | 206 | @commands.group() 207 | @checks.is_owner() 208 | async def heartbeat(self, ctx: commands.Context) -> None: 209 | """Manage Heartbeat settings.""" 210 | 211 | @heartbeat.command() 212 | async def settings(self, ctx: commands.Context) -> None: 213 | """Display current settings.""" 214 | endpoints = await self.config.custom("ENDPOINT").all() 215 | global_section = SettingDisplay("Global Settings") 216 | heartbeat_status = "Disabled (no URLs set)" 217 | if self.bg_loop_task and not self.bg_loop_task.done(): 218 | heartbeat_status = "Enabled" 219 | elif endpoints: 220 | heartbeat_status = "Disabled (error occured)" 221 | global_section.add("Heartbeat", heartbeat_status) 222 | global_section.add( 223 | "Frequency", humanize_timedelta(seconds=await self.config.frequency()) 224 | ) 225 | if self.bg_loop_task and not self.bg_loop_task.done(): 226 | global_section.add( 227 | "Next heartbeat in", 228 | humanize_timedelta( 229 | timedelta=self.next_heartbeat - datetime.datetime.now(datetime.UTC) 230 | ) 231 | or "<1 second", 232 | ) 233 | status = SettingDisplay("Heartbeat Status") 234 | for endpoint in endpoints: 235 | status.add(endpoint, self.current_errors.get(endpoint, "OK")) 236 | await ctx.send(global_section.display(status)) 237 | 238 | @heartbeat.command() 239 | async def add(self, ctx: commands.Context, url: str) -> None: 240 | """Add a new endpoint to send pings to.""" 241 | await delete(ctx.message) 242 | endpoint_id = await self.get_new_endpoint_id(url) 243 | await self.config.custom( 244 | "ENDPOINT", 245 | endpoint_id, 246 | ).set({"url": url}) 247 | self.enable_bg_loop() 248 | await ctx.send( 249 | success(f"Endpoint `{endpoint_id}` has been added to Heartbeat.") 250 | ) 251 | 252 | @heartbeat.command() 253 | async def remove(self, ctx: commands.Context, endpoint_id: str) -> None: 254 | """Remove and disable Heartbeat pings for a given endpoint ID.""" 255 | await self.config.custom("ENDPOINT", endpoint_id).clear() 256 | self.enable_bg_loop() 257 | await ctx.send( 258 | success(f"Endpoint `{endpoint_id}` has been removed from Heartbeat.") 259 | ) 260 | 261 | @heartbeat.group() 262 | async def modify(self, ctx: commands.Context) -> None: 263 | """Modify configuration for a given endpoint.""" 264 | 265 | @modify.command(name="ssl_verify") 266 | async def modify_ssl_verify( 267 | self, ctx: commands.Context, endpoint_id: str, *, true_false: bool 268 | ) -> None: 269 | """Configure if we should verify SSL when performing ping.""" 270 | endpoint = await self.get_endpoint_config(endpoint_id) 271 | if not endpoint: 272 | await ctx.send( 273 | error( 274 | f"Could not find endpoint ID `{endpoint_id}`. Check `[p]heartbeat settings` for a list of valid endpoint IDs." 275 | ) 276 | ) 277 | return 278 | await self.config.custom("ENDPOINT", endpoint_id).ssl_verify.set(true_false) 279 | self.enable_bg_loop() 280 | await ctx.send( 281 | success( 282 | f"Endpoint `{endpoint_id}` will {'now' if true_false else 'no longer'} verify SSL." 283 | ) 284 | ) 285 | 286 | @heartbeat.command() 287 | async def rename( 288 | self, ctx: commands.Context, endpoint_id: str, new_endpoint_id: str 289 | ) -> None: 290 | """Rename an endpoint ID.""" 291 | current_config = await self.config.custom("ENDPOINT").all() 292 | if endpoint_id in current_config: 293 | await self.config.custom("ENDPOINT", new_endpoint_id).set( 294 | current_config[endpoint_id] 295 | ) 296 | await self.config.custom("ENDPOINT", endpoint_id).clear() 297 | self.enable_bg_loop() 298 | await ctx.send( 299 | success( 300 | f"Endpoint `{endpoint_id}` has been renamed to `{new_endpoint_id}`." 301 | ) 302 | ) 303 | else: 304 | await ctx.send( 305 | error( 306 | f"Could not find endpoint ID `{endpoint_id}`. Check `[p]heartbeat settings` for a list of valid endpoint IDs." 307 | ) 308 | ) 309 | 310 | @heartbeat.command() 311 | async def reset(self, ctx: commands.Context) -> None: 312 | """Remove all endpoints and disable Heartbeat pings.""" 313 | await self.config.custom("ENDPOINT").clear() 314 | self.enable_bg_loop() 315 | await ctx.send(success("Heartbeat has been disabled completely.")) 316 | 317 | @heartbeat.command() 318 | async def frequency( 319 | self, 320 | ctx: commands.Context, 321 | frequency: commands.TimedeltaConverter( 322 | minimum=timedelta(seconds=60), 323 | maximum=timedelta(days=30), 324 | default_unit="seconds", 325 | ), 326 | ) -> None: 327 | """Set the frequency Heartbeat will send pings.""" 328 | await self.config.frequency.set(frequency.total_seconds()) 329 | await ctx.send( 330 | success( 331 | f"Heartbeat frequency has been set to {humanize_timedelta(timedelta=frequency)}." 332 | ) 333 | ) 334 | self.enable_bg_loop() 335 | 336 | # 337 | # Private methods 338 | # 339 | 340 | async def get_new_endpoint_id(self, url: str) -> str: 341 | """Generate an ID from a given URL.""" 342 | endpoint_id = "default" 343 | with suppress(Exception): 344 | endpoint_id = urlparse(url).netloc or endpoint_id 345 | endpoint_id_result = endpoint_id 346 | config_check = await self.config.custom( 347 | "ENDPOINT", endpoint_id_result 348 | ).all() # Returns default values 349 | count = 1 350 | while config_check["url"]: 351 | count += 1 352 | endpoint_id_result = f"{endpoint_id}_{count}" 353 | config_check = await self.config.custom( 354 | "ENDPOINT", endpoint_id_result 355 | ).all() # Returns default values 356 | return endpoint_id_result 357 | 358 | async def get_endpoint_config(self, endpoint_id: str) -> dict[str, Any] | None: 359 | """Get an endpoint config from the DB, ignoring invalid ones.""" 360 | endpoint = await self.config.custom("ENDPOINT", endpoint_id).all() 361 | if not endpoint["url"]: 362 | return None 363 | return endpoint 364 | 365 | # 366 | # Public methods 367 | # 368 | -------------------------------------------------------------------------------- /heartbeat/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Heartbeat", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "A heartbeat uptime client.", 7 | "description": "Monitor the uptime of your bot by having it send heartbeat pings to a configurable URL (healthchecks.io for instance).", 8 | "install_msg": "Thanks for installing Heartbeat!", 9 | "tags": [ 10 | "auto", 11 | "automated", 12 | "automatic", 13 | "health", 14 | "healthcheck", 15 | "ping", 16 | "schedule", 17 | "uptime", 18 | "utility" 19 | ], 20 | "min_bot_version": "3.5.0", 21 | "min_python_version": [ 22 | 3, 23 | 11, 24 | 0 25 | ], 26 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 27 | } 28 | -------------------------------------------------------------------------------- /heartbeat/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PCXCogs", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "PhasecoreX's Cogs for Red-DiscordBot.", 7 | "description": "My cogs focus on automation, where the bot will automatically respond or perform actions when needed, even if commands are not used. There's also other stuff thrown in there for fun.", 8 | "install_msg": "Thank you for installing my repo!\nFor more information on my cogs, or to report any bugs/suggestions, go to my GitHub here: .\nYou can also find me in the Red Cog Support Discord server, or in my own PCXSupport Discord server here: https://discord.gg/QzdPp2b" 9 | } 10 | -------------------------------------------------------------------------------- /netspeed/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for NetSpeed cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .netspeed import NetSpeed 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load NetSpeed cog.""" 16 | cog = NetSpeed() 17 | await bot.add_cog(cog) 18 | -------------------------------------------------------------------------------- /netspeed/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "NetSpeed", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Test your servers internet speed.", 7 | "description": "Runs an internet speedtest and prints the results. Only the owner can run this.", 8 | "install_msg": "Thanks for installing NetSpeed!", 9 | "requirements": [ 10 | "speedtest-cli>=2.1.3" 11 | ], 12 | "tags": [ 13 | "download", 14 | "latency", 15 | "network", 16 | "ping", 17 | "upload", 18 | "utility", 19 | "system", 20 | "test" 21 | ], 22 | "min_bot_version": "3.5.0", 23 | "min_python_version": [ 24 | 3, 25 | 11, 26 | 0 27 | ], 28 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 29 | } 30 | -------------------------------------------------------------------------------- /netspeed/netspeed.py: -------------------------------------------------------------------------------- 1 | """NetSpeed cog for Red-DiscordBot by PhasecoreX.""" 2 | 3 | import asyncio 4 | from concurrent.futures import ThreadPoolExecutor 5 | from typing import Any 6 | 7 | import discord 8 | import speedtest 9 | from redbot.core import checks, commands 10 | 11 | 12 | class NetSpeed(commands.Cog): 13 | """Test the internet speed of the server your bot is hosted on.""" 14 | 15 | __author__ = "PhasecoreX" 16 | __version__ = "1.1.0" 17 | 18 | # 19 | # Red methods 20 | # 21 | 22 | def format_help_for_context(self, ctx: commands.Context) -> str: 23 | """Show version in help.""" 24 | pre_processed = super().format_help_for_context(ctx) 25 | return f"{pre_processed}\n\nCog Version: {self.__version__}" 26 | 27 | async def red_delete_data_for_user(self, *, _requester: str, _user_id: int) -> None: 28 | """Nothing to delete.""" 29 | return 30 | 31 | # 32 | # Command methods: netspeed 33 | # 34 | 35 | @commands.command(aliases=["speedtest"]) 36 | @checks.is_owner() 37 | async def netspeed(self, ctx: commands.Context) -> None: 38 | """Test the internet speed of the server your bot is hosted on.""" 39 | loop = asyncio.get_event_loop() 40 | speed_test = speedtest.Speedtest(secure=True) 41 | the_embed = await ctx.send(embed=self.generate_embed(speed_test.results.dict())) 42 | with ThreadPoolExecutor(max_workers=1) as executor: 43 | await loop.run_in_executor(executor, speed_test.get_servers) 44 | await loop.run_in_executor(executor, speed_test.get_best_server) 45 | await the_embed.edit(embed=self.generate_embed(speed_test.results.dict())) 46 | await loop.run_in_executor(executor, speed_test.download) 47 | await the_embed.edit(embed=self.generate_embed(speed_test.results.dict())) 48 | await loop.run_in_executor(executor, speed_test.upload) 49 | await the_embed.edit(embed=self.generate_embed(speed_test.results.dict())) 50 | 51 | @staticmethod 52 | def generate_embed(results_dict: dict[str, Any]) -> discord.Embed: 53 | """Generate the embed.""" 54 | measuring = ":mag: Measuring..." 55 | waiting = ":hourglass: Waiting..." 56 | 57 | color = discord.Color.red() 58 | title = "Measuring internet speed..." 59 | message_ping = measuring 60 | message_down = waiting 61 | message_up = waiting 62 | if results_dict["ping"]: 63 | message_ping = f"**{results_dict['ping']}** ms" 64 | message_down = measuring 65 | if results_dict["download"]: 66 | message_down = f"**{results_dict['download'] / 1_000_000:.2f}** mbps" 67 | message_up = measuring 68 | if results_dict["upload"]: 69 | message_up = f"**{results_dict['upload'] / 1_000_000:.2f}** mbps" 70 | title = "NetSpeed Results" 71 | color = discord.Color.green() 72 | embed = discord.Embed(title=title, color=color) 73 | embed.add_field(name="Ping", value=message_ping) 74 | embed.add_field(name="Download", value=message_down) 75 | embed.add_field(name="Upload", value=message_up) 76 | return embed 77 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py311" 3 | 4 | [tool.ruff.lint] 5 | select = ["ALL"] 6 | ignore = [ 7 | "C901", 8 | "COM812", 9 | "D107", 10 | "D203", 11 | "D213", 12 | "D415", 13 | "E501", 14 | "ERA001", 15 | "PLR09", 16 | "PLR1722", 17 | "T20", 18 | ] 19 | 20 | [tool.ruff.lint.per-file-ignores] 21 | "*_test.py" = ["S101", "D101", "D102", "D103", "ANN201"] 22 | "abc.py" = ["D102"] 23 | 24 | [tool.isort] 25 | profile = "black" 26 | -------------------------------------------------------------------------------- /reactchannel/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for ReactChannel cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .reactchannel import ReactChannel 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load ReactChannel cog.""" 16 | cog = ReactChannel(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /reactchannel/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ReactChannel", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Per-channel automatic reaction tools.", 7 | "description": "Allows for channels to be set up to have reactions automatically added to messages. This can facilitate things such as an upvote/downvote system (with user karma), a checklist system (checked messages are deleted), or any custom emoji reaction systems (no actions performed).", 8 | "install_msg": "Thanks for installing ReactChannel!", 9 | "tags": [ 10 | "auto", 11 | "automated", 12 | "automatic", 13 | "checklist", 14 | "downvote", 15 | "karma", 16 | "starboard", 17 | "upvote", 18 | "utility", 19 | "vote" 20 | ], 21 | "min_bot_version": "3.5.0", 22 | "min_python_version": [ 23 | 3, 24 | 11, 25 | 0 26 | ], 27 | "end_user_data_statement": "This cog stores Discord IDs along with a karma value based on total upvotes and downvotes on the users messages. Users may reset/remove their own karma total by making a data removal request." 28 | } 29 | -------------------------------------------------------------------------------- /reactchannel/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /remindme/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for RemindMe cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .remindme import RemindMe 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load RemindMe cog.""" 16 | cog = RemindMe(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /remindme/abc.py: -------------------------------------------------------------------------------- 1 | """ABC for the RemindMe Cog.""" 2 | 3 | from abc import ABC, abstractmethod 4 | 5 | import discord 6 | from dateutil.relativedelta import relativedelta 7 | from redbot.core import Config, commands 8 | 9 | from .reminder_parse import ReminderParser 10 | 11 | 12 | class MixinMeta(ABC): 13 | """Base class for well-behaved type hint detection with composite class. 14 | 15 | Basically, to keep developers sane when not all attributes are defined in each mixin. 16 | """ 17 | 18 | config: Config 19 | reminder_parser: ReminderParser 20 | me_too_reminders: dict[int, dict] 21 | clicked_me_too_reminder: dict[int, set[int]] 22 | reminder_emoji: str 23 | MAX_REMINDER_LENGTH: int 24 | 25 | @staticmethod 26 | @abstractmethod 27 | def humanize_relativedelta(relative_delta: relativedelta | dict) -> str: 28 | raise NotImplementedError 29 | 30 | @abstractmethod 31 | async def insert_reminder(self, user_id: int, reminder: dict) -> bool: 32 | raise NotImplementedError 33 | 34 | @staticmethod 35 | @abstractmethod 36 | def relativedelta_to_dict(relative_delta: relativedelta) -> dict[str, int]: 37 | raise NotImplementedError 38 | 39 | @abstractmethod 40 | async def send_too_many_message( 41 | self, ctx_or_user: commands.Context | discord.User, maximum: int = -1 42 | ) -> None: 43 | raise NotImplementedError 44 | 45 | @abstractmethod 46 | async def update_bg_task( 47 | self, 48 | user_id: int, 49 | user_reminder_id: int | None = None, 50 | partial_reminder: dict | None = None, 51 | ) -> None: 52 | raise NotImplementedError 53 | -------------------------------------------------------------------------------- /remindme/c_remindmeset.py: -------------------------------------------------------------------------------- 1 | """Commands for [p]remindmeset.""" 2 | 3 | from abc import ABC 4 | 5 | from redbot.core import checks, commands 6 | from redbot.core.utils.chat_formatting import success 7 | 8 | from .abc import MixinMeta 9 | from .pcx_lib import SettingDisplay 10 | 11 | 12 | class RemindMeSetCommands(MixinMeta, ABC): 13 | """Commands for [p]remindmeset.""" 14 | 15 | @commands.group() 16 | @checks.admin_or_permissions(manage_guild=True) 17 | async def remindmeset(self, ctx: commands.Context) -> None: 18 | """Manage RemindMe settings.""" 19 | 20 | @remindmeset.command() 21 | async def settings(self, ctx: commands.Context) -> None: 22 | """Display current settings.""" 23 | server_section = SettingDisplay("Server Settings") 24 | if ctx.guild: 25 | server_section.add( 26 | "Me too", 27 | ( 28 | "Enabled" 29 | if await self.config.guild(ctx.guild).me_too() 30 | else "Disabled" 31 | ), 32 | ) 33 | 34 | if await ctx.bot.is_owner(ctx.author): 35 | global_section = SettingDisplay("Global Settings") 36 | global_section.add( 37 | "Maximum reminders per user", await self.config.max_user_reminders() 38 | ) 39 | 40 | non_repeating_reminders = 0 41 | repeating_reminders = 0 42 | all_reminders = await self.config.custom( 43 | "REMINDER" 44 | ).all() # Does NOT return default values 45 | for users_reminders in all_reminders.values(): 46 | for reminder in users_reminders.values(): 47 | if reminder.get("repeat"): 48 | repeating_reminders += 1 49 | else: 50 | non_repeating_reminders += 1 51 | pending_reminders_message = ( 52 | f"{non_repeating_reminders + repeating_reminders}" 53 | ) 54 | if repeating_reminders: 55 | pending_reminders_message += ( 56 | f" ({repeating_reminders} " 57 | f"{'is' if repeating_reminders == 1 else 'are'} repeating)" 58 | ) 59 | 60 | stats_section = SettingDisplay("Stats") 61 | stats_section.add( 62 | "Pending reminders", 63 | pending_reminders_message, 64 | ) 65 | stats_section.add("Total reminders sent", await self.config.total_sent()) 66 | 67 | await ctx.send(server_section.display(global_section, stats_section)) 68 | 69 | else: 70 | await ctx.send(str(server_section)) 71 | 72 | @remindmeset.command() 73 | @commands.guild_only() 74 | async def metoo(self, ctx: commands.Context) -> None: 75 | """Toggle the bot asking if others want to be reminded in this server. 76 | 77 | If the bot doesn't have the Add Reactions permission in the channel, it won't ask regardless. 78 | """ 79 | if not ctx.guild: 80 | return 81 | me_too = not await self.config.guild(ctx.guild).me_too() 82 | await self.config.guild(ctx.guild).me_too.set(me_too) 83 | await ctx.send( 84 | success( 85 | f"I will {'now' if me_too else 'no longer'} ask if others want to be reminded." 86 | ) 87 | ) 88 | 89 | @remindmeset.command(name="max") 90 | @checks.is_owner() 91 | async def set_max(self, ctx: commands.Context, maximum: int) -> None: 92 | """Global: Set the maximum number of reminders a user can create at one time.""" 93 | await self.config.max_user_reminders.set(maximum) 94 | await ctx.send( 95 | success( 96 | f"Maximum reminders per user is now set to {await self.config.max_user_reminders()}" 97 | ) 98 | ) 99 | -------------------------------------------------------------------------------- /remindme/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RemindMe", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Set reminders for yourself.", 7 | "description": "Allows for users to set reminders for themselves.", 8 | "install_msg": "Thanks for installing RemindMe!", 9 | "requirements": [ 10 | "pyparsing", 11 | "python-dateutil" 12 | ], 13 | "tags": [ 14 | "reminder", 15 | "schedule", 16 | "utility" 17 | ], 18 | "min_bot_version": "3.5.0", 19 | "min_python_version": [ 20 | 3, 21 | 11, 22 | 0 23 | ], 24 | "end_user_data_statement": "This cog stores data provided by users for the express purpose of re-displaying. It does not store user data which was not provided through a command. Users may delete their own data with or without making a data request." 25 | } 26 | -------------------------------------------------------------------------------- /remindme/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /remindme/reminder_parse.py: -------------------------------------------------------------------------------- 1 | """A parser for remindme commands.""" 2 | 3 | from typing import Any 4 | 5 | from pyparsing import ( 6 | CaselessLiteral, 7 | Group, 8 | Literal, 9 | Optional, 10 | ParserElement, 11 | SkipTo, 12 | StringEnd, 13 | Suppress, 14 | Word, 15 | ZeroOrMore, 16 | nums, 17 | tokenMap, 18 | ) 19 | 20 | __author__ = "PhasecoreX" 21 | 22 | 23 | class ReminderParser: 24 | """A parser for remindme commands.""" 25 | 26 | def __init__(self) -> None: 27 | """Set up the parser.""" 28 | ParserElement.enablePackrat() 29 | 30 | unit_years = ( 31 | CaselessLiteral("years") | CaselessLiteral("year") | CaselessLiteral("y") 32 | ) 33 | years = ( 34 | Word(nums).setParseAction(lambda token_list: [int(str(token_list[0]))])( 35 | "years" 36 | ) 37 | + unit_years 38 | ) 39 | unit_months = ( 40 | CaselessLiteral("months") | CaselessLiteral("month") | CaselessLiteral("mo") 41 | ) 42 | months = ( 43 | Word(nums).setParseAction(lambda token_list: [int(str(token_list[0]))])( 44 | "months" 45 | ) 46 | + unit_months 47 | ) 48 | unit_weeks = ( 49 | CaselessLiteral("weeks") | CaselessLiteral("week") | CaselessLiteral("w") 50 | ) 51 | weeks = ( 52 | Word(nums).setParseAction(lambda token_list: [int(str(token_list[0]))])( 53 | "weeks" 54 | ) 55 | + unit_weeks 56 | ) 57 | unit_days = ( 58 | CaselessLiteral("days") | CaselessLiteral("day") | CaselessLiteral("d") 59 | ) 60 | days = ( 61 | Word(nums).setParseAction(lambda token_list: [int(str(token_list[0]))])( 62 | "days" 63 | ) 64 | + unit_days 65 | ) 66 | unit_hours = ( 67 | CaselessLiteral("hours") 68 | | CaselessLiteral("hour") 69 | | CaselessLiteral("hrs") 70 | | CaselessLiteral("hr") 71 | | CaselessLiteral("h") 72 | ) 73 | hours = ( 74 | Word(nums).setParseAction(lambda token_list: [int(str(token_list[0]))])( 75 | "hours" 76 | ) 77 | + unit_hours 78 | ) 79 | unit_minutes = ( 80 | CaselessLiteral("minutes") 81 | | CaselessLiteral("minute") 82 | | CaselessLiteral("mins") 83 | | CaselessLiteral("min") 84 | | CaselessLiteral("m") 85 | ) 86 | minutes = ( 87 | Word(nums).setParseAction(lambda token_list: [int(str(token_list[0]))])( 88 | "minutes" 89 | ) 90 | + unit_minutes 91 | ) 92 | unit_seconds = ( 93 | CaselessLiteral("seconds") 94 | | CaselessLiteral("second") 95 | | CaselessLiteral("secs") 96 | | CaselessLiteral("sec") 97 | | CaselessLiteral("s") 98 | ) 99 | seconds = ( 100 | Word(nums).setParseAction(lambda token_list: [int(str(token_list[0]))])( 101 | "seconds" 102 | ) 103 | + unit_seconds 104 | ) 105 | 106 | time_unit = years | months | weeks | days | hours | minutes | seconds 107 | time_unit_separators = Optional(Literal(",")) + Optional(CaselessLiteral("and")) 108 | full_time = time_unit + ZeroOrMore( 109 | Suppress(Optional(time_unit_separators)) + time_unit 110 | ) 111 | 112 | every_time = Group(CaselessLiteral("every") + full_time)("every") 113 | in_opt_time = Group(Optional(CaselessLiteral("in")) + full_time)("in") 114 | in_req_time = Group(CaselessLiteral("in") + full_time)("in") 115 | 116 | reminder_text_capture = SkipTo( 117 | every_time | in_req_time | StringEnd() 118 | ).setParseAction(tokenMap(str.strip)) 119 | reminder_text_optional_prefix = Optional(Suppress(CaselessLiteral("to"))) 120 | reminder_text = reminder_text_optional_prefix + reminder_text_capture("text") 121 | 122 | in_every_text = in_opt_time + every_time + reminder_text 123 | every_in_text = every_time + in_req_time + reminder_text 124 | in_text_every = in_opt_time + reminder_text + every_time 125 | every_text_in = every_time + reminder_text + in_req_time 126 | text_in_every = reminder_text + in_req_time + every_time 127 | text_every_in = reminder_text + every_time + in_req_time 128 | 129 | in_text = in_opt_time + reminder_text 130 | text_in = reminder_text + in_req_time 131 | every_text = every_time + reminder_text 132 | text_every = reminder_text + every_time 133 | 134 | template = ( 135 | in_every_text 136 | | every_in_text 137 | | in_text_every 138 | | every_text_in 139 | | text_in_every 140 | | text_every_in 141 | | in_text 142 | | text_in 143 | | every_text 144 | | text_every 145 | ) 146 | 147 | self.parser = template 148 | 149 | def parse(self, text: str) -> dict[str, Any]: 150 | """Parse text into a reminder config dict.""" 151 | parsed = self.parser.parseString(text, parseAll=True) 152 | return parsed.asDict() 153 | -------------------------------------------------------------------------------- /remindme/reminder_parse_test.py: -------------------------------------------------------------------------------- 1 | """Unit tests for the reminder parser.""" 2 | 3 | import unittest 4 | 5 | import reminder_parse 6 | 7 | parser = reminder_parse.ReminderParser() 8 | 9 | 10 | class TestCases(unittest.TestCase): 11 | def test_og(self): 12 | reminder = "2h reminder!" 13 | result = parser.parse(reminder) 14 | expected = { 15 | "in": { 16 | "hours": 2, 17 | }, 18 | "text": "reminder!", 19 | } 20 | assert expected == result 21 | 22 | def test_og_long(self): 23 | reminder = "1y2mo3w4d5h6m7s reminder!" 24 | result = parser.parse(reminder) 25 | expected = { 26 | "in": { 27 | "years": 1, 28 | "months": 2, 29 | "weeks": 3, 30 | "days": 4, 31 | "hours": 5, 32 | "minutes": 6, 33 | "seconds": 7, 34 | }, 35 | "text": "reminder!", 36 | } 37 | assert expected == result 38 | 39 | def test_in_og_long(self): 40 | reminder = "in 1y2mo3w4d5h6m7s reminder!" 41 | result = parser.parse(reminder) 42 | expected = { 43 | "in": { 44 | "years": 1, 45 | "months": 2, 46 | "weeks": 3, 47 | "days": 4, 48 | "hours": 5, 49 | "minutes": 6, 50 | "seconds": 7, 51 | }, 52 | "text": "reminder!", 53 | } 54 | assert expected == result 55 | 56 | def test_in_english(self): 57 | reminder = "in 1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, and 7 seconds reminder!" 58 | result = parser.parse(reminder) 59 | expected = { 60 | "in": { 61 | "years": 1, 62 | "months": 2, 63 | "weeks": 3, 64 | "days": 4, 65 | "hours": 5, 66 | "minutes": 6, 67 | "seconds": 7, 68 | }, 69 | "text": "reminder!", 70 | } 71 | assert expected == result 72 | 73 | def test_in_broken_english(self): 74 | reminder = "in 1year2 mo, 3w4 day5hour6 mins , and 7 s reminder!" 75 | result = parser.parse(reminder) 76 | expected = { 77 | "in": { 78 | "years": 1, 79 | "months": 2, 80 | "weeks": 3, 81 | "days": 4, 82 | "hours": 5, 83 | "minutes": 6, 84 | "seconds": 7, 85 | }, 86 | "text": "reminder!", 87 | } 88 | assert expected == result 89 | 90 | def test_optional_to(self): 91 | reminder = "to eat in 3 hours" 92 | result = parser.parse(reminder) 93 | expected = { 94 | "in": { 95 | "hours": 3, 96 | }, 97 | "text": "eat", 98 | } 99 | assert expected == result 100 | 101 | def test_only_in(self): 102 | reminder = "in 1 year" 103 | result = parser.parse(reminder) 104 | expected = { 105 | "in": { 106 | "years": 1, 107 | }, 108 | "text": "", 109 | } 110 | assert expected == result 111 | 112 | def test_only_every(self): 113 | reminder = "every 1 year" 114 | result = parser.parse(reminder) 115 | expected = { 116 | "every": { 117 | "years": 1, 118 | }, 119 | "text": "", 120 | } 121 | assert expected == result 122 | 123 | def test_in_every(self): 124 | reminder = "2w every 1 year" 125 | result = parser.parse(reminder) 126 | expected = { 127 | "every": { 128 | "years": 1, 129 | }, 130 | "in": { 131 | "weeks": 2, 132 | }, 133 | "text": "", 134 | } 135 | assert expected == result 136 | 137 | def test_every_in(self): 138 | reminder = "every 1 year in 3 weeks" 139 | result = parser.parse(reminder) 140 | expected = { 141 | "every": { 142 | "years": 1, 143 | }, 144 | "in": { 145 | "weeks": 3, 146 | }, 147 | "text": "", 148 | } 149 | assert expected == result 150 | 151 | def test_in_text(self): 152 | reminder = "in 3 weeks to keep coding" 153 | result = parser.parse(reminder) 154 | expected = { 155 | "in": { 156 | "weeks": 3, 157 | }, 158 | "text": "keep coding", 159 | } 160 | assert expected == result 161 | 162 | def test_text_in(self): 163 | reminder = "to keep coding in 2 hours" 164 | result = parser.parse(reminder) 165 | expected = { 166 | "in": { 167 | "hours": 2, 168 | }, 169 | "text": "keep coding", 170 | } 171 | assert expected == result 172 | 173 | def test_every_text(self): 174 | reminder = "every 3 weeks to keep coding" 175 | result = parser.parse(reminder) 176 | expected = { 177 | "every": { 178 | "weeks": 3, 179 | }, 180 | "text": "keep coding", 181 | } 182 | assert expected == result 183 | 184 | def test_text_every(self): 185 | reminder = "to keep coding every 2 hours" 186 | result = parser.parse(reminder) 187 | expected = { 188 | "every": { 189 | "hours": 2, 190 | }, 191 | "text": "keep coding", 192 | } 193 | assert expected == result 194 | 195 | def test_in_every_text(self): 196 | reminder = "2w every 1 year to write more code" 197 | result = parser.parse(reminder) 198 | expected = { 199 | "every": { 200 | "years": 1, 201 | }, 202 | "in": { 203 | "weeks": 2, 204 | }, 205 | "text": "write more code", 206 | } 207 | assert expected == result 208 | 209 | def test_every_in_text(self): 210 | reminder = "every 1 year in 3 weeks to write more code" 211 | result = parser.parse(reminder) 212 | expected = { 213 | "every": { 214 | "years": 1, 215 | }, 216 | "in": { 217 | "weeks": 3, 218 | }, 219 | "text": "write more code", 220 | } 221 | assert expected == result 222 | 223 | def test_in_text_every(self): 224 | reminder = "12 hrs write more code every 1 month" 225 | result = parser.parse(reminder) 226 | expected = { 227 | "every": { 228 | "months": 1, 229 | }, 230 | "in": { 231 | "hours": 12, 232 | }, 233 | "text": "write more code", 234 | } 235 | assert expected == result 236 | 237 | def test_every_text_in(self): 238 | reminder = "every 1 month to write more code in 4 hours" 239 | result = parser.parse(reminder) 240 | expected = { 241 | "every": { 242 | "months": 1, 243 | }, 244 | "in": { 245 | "hours": 4, 246 | }, 247 | "text": "write more code", 248 | } 249 | assert expected == result 250 | 251 | def test_text_in_every(self): 252 | reminder = "to write more unit tests in 8 days and 1 month every 1 week" 253 | result = parser.parse(reminder) 254 | expected = { 255 | "every": { 256 | "weeks": 1, 257 | }, 258 | "in": { 259 | "months": 1, 260 | "days": 8, 261 | }, 262 | "text": "write more unit tests", 263 | } 264 | assert expected == result 265 | 266 | def test_text_every_in(self): 267 | reminder = "to write more unit tests every 1 week 3 days in 2 months and 1 day" 268 | result = parser.parse(reminder) 269 | expected = { 270 | "every": { 271 | "weeks": 1, 272 | "days": 3, 273 | }, 274 | "in": { 275 | "months": 2, 276 | "days": 1, 277 | }, 278 | "text": "write more unit tests", 279 | } 280 | assert expected == result 281 | 282 | 283 | # Run unit tests from command line 284 | if __name__ == "__main__": 285 | unittest.main() 286 | -------------------------------------------------------------------------------- /updatenotify/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for UpdateNotify cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .updatenotify import UpdateNotify 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load UpdateNotify cog.""" 16 | cog = UpdateNotify(bot) 17 | await cog.initialize() 18 | await bot.add_cog(cog) 19 | -------------------------------------------------------------------------------- /updatenotify/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "UpdateNotify", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Automatically check for updates to Red-DiscordBot.", 7 | "description": "This cog will periodically check if there are updates to the Red-DiscordBot code. If you are using the phasecorex/red-discordbot Docker image, it will check for any updates to that as well.", 8 | "install_msg": "Thanks for installing UpdateNotify! If you also happen to be running the bot in the `phasecorex/red-discordbot` Docker image, this plugin will check for updates to that as well.", 9 | "tags": [ 10 | "auto", 11 | "automated", 12 | "automatic", 13 | "check", 14 | "update", 15 | "utility" 16 | ], 17 | "min_bot_version": "3.5.0", 18 | "min_python_version": [ 19 | 3, 20 | 11, 21 | 0 22 | ], 23 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 24 | } 25 | -------------------------------------------------------------------------------- /updatenotify/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /uwu/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for UwU cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .uwu import UwU 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load UwU cog.""" 16 | cog = UwU() 17 | await bot.add_cog(cog) 18 | -------------------------------------------------------------------------------- /uwu/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "UwU", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Uwuize messages.", 7 | "description": "Takes the pwevious mwessage and uwuizes it. Sowwy.", 8 | "install_msg": "Hewwo!! OwO", 9 | "tags": [ 10 | "fun", 11 | "utility" 12 | ], 13 | "min_bot_version": "3.5.0", 14 | "min_python_version": [ 15 | 3, 16 | 11, 17 | 0 18 | ], 19 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 20 | } 21 | -------------------------------------------------------------------------------- /uwu/pcx_lib.py: -------------------------------------------------------------------------------- 1 | """Shared code across multiple cogs.""" 2 | 3 | import asyncio 4 | from collections.abc import Mapping 5 | from contextlib import suppress 6 | from typing import Any 7 | 8 | import discord 9 | from redbot.core import __version__ as redbot_version 10 | from redbot.core import commands 11 | from redbot.core.utils import common_filters 12 | from redbot.core.utils.chat_formatting import box 13 | 14 | headers = {"user-agent": "Red-DiscordBot/" + redbot_version} 15 | 16 | MAX_EMBED_SIZE = 5900 17 | MAX_EMBED_FIELDS = 20 18 | MAX_EMBED_FIELD_SIZE = 1024 19 | 20 | 21 | async def delete(message: discord.Message, *, delay: float | None = None) -> bool: 22 | """Attempt to delete a message. 23 | 24 | Returns True if successful, False otherwise. 25 | """ 26 | try: 27 | await message.delete(delay=delay) 28 | except discord.NotFound: 29 | return True # Already deleted 30 | except discord.HTTPException: 31 | return False 32 | return True 33 | 34 | 35 | async def reply( 36 | ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401 37 | ) -> None: 38 | """Safely reply to a command message. 39 | 40 | If the command is in a guild, will reply, otherwise will send a message like normal. 41 | Pre discord.py 1.6, replies are just messages sent with the users mention prepended. 42 | """ 43 | if ctx.guild: 44 | if ( 45 | hasattr(ctx, "reply") 46 | and ctx.channel.permissions_for(ctx.guild.me).read_message_history 47 | ): 48 | mention_author = kwargs.pop("mention_author", False) 49 | kwargs.update(mention_author=mention_author) 50 | with suppress(discord.HTTPException): 51 | await ctx.reply(content=content, **kwargs) 52 | return 53 | allowed_mentions = kwargs.pop( 54 | "allowed_mentions", 55 | discord.AllowedMentions(users=False), 56 | ) 57 | kwargs.update(allowed_mentions=allowed_mentions) 58 | await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs) 59 | else: 60 | await ctx.send(content=content, **kwargs) 61 | 62 | 63 | async def type_message( 64 | destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401 65 | ) -> discord.Message | None: 66 | """Simulate typing and sending a message to a destination. 67 | 68 | Will send a typing indicator, wait a variable amount of time based on the length 69 | of the text (to simulate typing speed), then send the message. 70 | """ 71 | content = common_filters.filter_urls(content) 72 | with suppress(discord.HTTPException): 73 | async with destination.typing(): 74 | await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01))) 75 | return await destination.send(content=content, **kwargs) 76 | 77 | 78 | async def embed_splitter( 79 | embed: discord.Embed, destination: discord.abc.Messageable | None = None 80 | ) -> list[discord.Embed]: 81 | """Take an embed and split it so that each embed has at most 20 fields and a length of 5900. 82 | 83 | Each field value will also be checked to have a length no greater than 1024. 84 | 85 | If supplied with a destination, will also send those embeds to the destination. 86 | """ 87 | embed_dict = embed.to_dict() 88 | 89 | # Check and fix field value lengths 90 | modified = False 91 | if "fields" in embed_dict: 92 | for field in embed_dict["fields"]: 93 | if len(field["value"]) > MAX_EMBED_FIELD_SIZE: 94 | field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..." 95 | modified = True 96 | if modified: 97 | embed = discord.Embed.from_dict(embed_dict) 98 | 99 | # Short circuit 100 | if len(embed) <= MAX_EMBED_SIZE and ( 101 | "fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS 102 | ): 103 | if destination: 104 | await destination.send(embed=embed) 105 | return [embed] 106 | 107 | # Nah, we're really doing this 108 | split_embeds: list[discord.Embed] = [] 109 | fields = embed_dict.get("fields", []) 110 | embed_dict["fields"] = [] 111 | 112 | for field in fields: 113 | embed_dict["fields"].append(field) 114 | current_embed = discord.Embed.from_dict(embed_dict) 115 | if ( 116 | len(current_embed) > MAX_EMBED_SIZE 117 | or len(embed_dict["fields"]) > MAX_EMBED_FIELDS 118 | ): 119 | embed_dict["fields"].pop() 120 | current_embed = discord.Embed.from_dict(embed_dict) 121 | split_embeds.append(current_embed.copy()) 122 | embed_dict["fields"] = [field] 123 | 124 | current_embed = discord.Embed.from_dict(embed_dict) 125 | split_embeds.append(current_embed.copy()) 126 | 127 | if destination: 128 | for split_embed in split_embeds: 129 | await destination.send(embed=split_embed) 130 | return split_embeds 131 | 132 | 133 | class SettingDisplay: 134 | """A formatted list of settings.""" 135 | 136 | def __init__(self, header: str | None = None) -> None: 137 | """Init.""" 138 | self.header = header 139 | self._length = 0 140 | self._settings: list[tuple] = [] 141 | 142 | def add(self, setting: str, value: Any) -> None: # noqa: ANN401 143 | """Add a setting.""" 144 | setting_colon = setting + ":" 145 | self._settings.append((setting_colon, value)) 146 | self._length = max(len(setting_colon), self._length) 147 | 148 | def raw(self) -> str: 149 | """Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later.""" 150 | msg = "" 151 | if not self._settings: 152 | return msg 153 | if self.header: 154 | msg += f"--- {self.header} ---\n" 155 | for setting in self._settings: 156 | msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n" 157 | return msg.strip() 158 | 159 | def display(self, *additional) -> str: # noqa: ANN002 (Self) 160 | """Generate a ready-to-send formatted box of settings. 161 | 162 | If additional SettingDisplays are provided, merges their output into one. 163 | """ 164 | msg = self.raw() 165 | for section in additional: 166 | msg += "\n\n" + section.raw() 167 | return box(msg, lang="ini") 168 | 169 | def __str__(self) -> str: 170 | """Generate a ready-to-send formatted box of settings.""" 171 | return self.display() 172 | 173 | def __len__(self) -> int: 174 | """Count of how many settings there are to display.""" 175 | return len(self._settings) 176 | 177 | 178 | class Perms: 179 | """Helper class for dealing with a dictionary of discord.PermissionOverwrite.""" 180 | 181 | def __init__( 182 | self, 183 | overwrites: ( 184 | dict[ 185 | discord.Role | discord.Member | discord.Object, 186 | discord.PermissionOverwrite, 187 | ] 188 | | None 189 | ) = None, 190 | ) -> None: 191 | """Init.""" 192 | self.__overwrites: dict[ 193 | discord.Role | discord.Member, 194 | discord.PermissionOverwrite, 195 | ] = {} 196 | self.__original: dict[ 197 | discord.Role | discord.Member, 198 | discord.PermissionOverwrite, 199 | ] = {} 200 | if overwrites: 201 | for key, value in overwrites.items(): 202 | if isinstance(key, discord.Role | discord.Member): 203 | pair = value.pair() 204 | self.__overwrites[key] = discord.PermissionOverwrite().from_pair( 205 | *pair 206 | ) 207 | self.__original[key] = discord.PermissionOverwrite().from_pair( 208 | *pair 209 | ) 210 | 211 | def overwrite( 212 | self, 213 | target: discord.Role | discord.Member | discord.Object, 214 | permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite, 215 | ) -> None: 216 | """Set the permissions for a target.""" 217 | if not isinstance(target, discord.Role | discord.Member): 218 | return 219 | if isinstance(permission_overwrite, discord.PermissionOverwrite): 220 | if permission_overwrite.is_empty(): 221 | self.__overwrites[target] = discord.PermissionOverwrite() 222 | return 223 | self.__overwrites[target] = discord.PermissionOverwrite().from_pair( 224 | *permission_overwrite.pair() 225 | ) 226 | else: 227 | self.__overwrites[target] = discord.PermissionOverwrite() 228 | self.update(target, permission_overwrite) 229 | 230 | def update( 231 | self, 232 | target: discord.Role | discord.Member, 233 | perm: Mapping[str, bool | None], 234 | ) -> None: 235 | """Update the permissions for a target.""" 236 | if target not in self.__overwrites: 237 | self.__overwrites[target] = discord.PermissionOverwrite() 238 | self.__overwrites[target].update(**perm) 239 | if self.__overwrites[target].is_empty(): 240 | del self.__overwrites[target] 241 | 242 | @property 243 | def modified(self) -> bool: 244 | """Check if current overwrites are different from when this object was first initialized.""" 245 | return self.__overwrites != self.__original 246 | 247 | @property 248 | def overwrites( 249 | self, 250 | ) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None: 251 | """Get current overwrites.""" 252 | return self.__overwrites 253 | -------------------------------------------------------------------------------- /uwu/uwu.py: -------------------------------------------------------------------------------- 1 | """UwU cog for Red-DiscordBot by PhasecoreX.""" 2 | 3 | # ruff: noqa: S311 4 | import random 5 | from contextlib import suppress 6 | from typing import ClassVar 7 | 8 | import discord 9 | from redbot.core import commands 10 | 11 | from .pcx_lib import type_message 12 | 13 | 14 | class UwU(commands.Cog): 15 | """UwU.""" 16 | 17 | __author__ = "PhasecoreX" 18 | __version__ = "2.1.1" 19 | 20 | KAOMOJI_JOY: ClassVar[list[str]] = [ 21 | " (\\* ^ ω ^)", 22 | " (o^▽^o)", 23 | " (≧◡≦)", 24 | ' ☆⌒ヽ(\\*"、^\\*)chu', 25 | " ( ˘⌣˘)♡(˘⌣˘ )", 26 | " xD", 27 | ] 28 | KAOMOJI_EMBARRASSED: ClassVar[list[str]] = [ 29 | " (/ />/ ▽ / str: 57 | """Show version in help.""" 58 | pre_processed = super().format_help_for_context(ctx) 59 | return f"{pre_processed}\n\nCog Version: {self.__version__}" 60 | 61 | async def red_delete_data_for_user(self, *, _requester: str, _user_id: int) -> None: 62 | """Nothing to delete.""" 63 | return 64 | 65 | # 66 | # Command methods 67 | # 68 | 69 | @commands.command(aliases=["owo"]) 70 | async def uwu(self, ctx: commands.Context, *, text: str | None = None) -> None: 71 | """Uwuize the replied to message, previous message, or your own text.""" 72 | if not text: 73 | if hasattr(ctx.message, "reference") and ctx.message.reference: 74 | with suppress( 75 | discord.Forbidden, discord.NotFound, discord.HTTPException 76 | ): 77 | message_id = ctx.message.reference.message_id 78 | if message_id: 79 | text = (await ctx.fetch_message(message_id)).content 80 | if not text: 81 | messages = [message async for message in ctx.channel.history(limit=2)] 82 | # [0] is the command, [1] is the message before the command 83 | text = messages[1].content or "I can't translate that!" 84 | await type_message( 85 | ctx.channel, 86 | self.uwuize_string(text), 87 | allowed_mentions=discord.AllowedMentions( 88 | everyone=False, users=False, roles=False 89 | ), 90 | ) 91 | 92 | # 93 | # Public methods 94 | # 95 | 96 | def uwuize_string(self, string: str) -> str: 97 | """Uwuize and return a string.""" 98 | converted = "" 99 | current_word = "" 100 | for letter in string: 101 | if letter.isprintable() and not letter.isspace(): 102 | current_word += letter 103 | elif current_word: 104 | converted += self.uwuize_word(current_word) + letter 105 | current_word = "" 106 | else: 107 | converted += letter 108 | if current_word: 109 | converted += self.uwuize_word(current_word) 110 | return converted 111 | 112 | def uwuize_word(self, word: str) -> str: 113 | """Uwuize and return a word. 114 | 115 | Thank you to the following for inspiration: 116 | https://github.com/senguyen1011/UwUinator 117 | """ 118 | word = word.lower() 119 | uwu = word.rstrip(".?!,") 120 | punctuations = word[len(uwu) :] 121 | final_punctuation = punctuations[-1] if punctuations else "" 122 | extra_punctuation = punctuations[:-1] if punctuations else "" 123 | 124 | # Process punctuation 125 | if final_punctuation == "." and not random.randint(0, 3): 126 | final_punctuation = random.choice(self.KAOMOJI_JOY) 127 | if final_punctuation == "?" and not random.randint(0, 2): 128 | final_punctuation = random.choice(self.KAOMOJI_CONFUSE) 129 | if final_punctuation == "!" and not random.randint(0, 2): 130 | final_punctuation = random.choice(self.KAOMOJI_JOY) 131 | if final_punctuation == "," and not random.randint(0, 3): 132 | final_punctuation = random.choice(self.KAOMOJI_EMBARRASSED) 133 | if final_punctuation and not random.randint(0, 4): 134 | final_punctuation = random.choice(self.KAOMOJI_SPARKLES) 135 | 136 | # Full word exceptions 137 | if uwu in ("you're", "youre"): 138 | uwu = "ur" 139 | elif uwu == "fuck": 140 | uwu = "fwickk" 141 | elif uwu == "shit": 142 | uwu = "poopoo" 143 | elif uwu == "bitch": 144 | uwu = "meanie" 145 | elif uwu == "asshole": 146 | uwu = "b-butthole" 147 | elif uwu in ("dick", "penis"): 148 | uwu = "peenie" 149 | elif uwu in ("cum", "semen"): 150 | uwu = "cummies" 151 | elif uwu == "ass": 152 | uwu = "b-butt" 153 | elif uwu in ("dad", "father"): 154 | uwu = "daddy" 155 | # Normal word conversion 156 | else: 157 | # Protect specific word endings from changes 158 | protected = "" 159 | if uwu.endswith(("le", "ll", "er", "re")): 160 | protected = uwu[-2:] 161 | uwu = uwu[:-2] 162 | elif uwu.endswith(("les", "lls", "ers", "res")): 163 | protected = uwu[-3:] 164 | uwu = uwu[:-3] 165 | # l -> w, r -> w, n -> ny, ove -> uv 166 | uwu = ( 167 | uwu.replace("l", "w") 168 | .replace("r", "w") 169 | .replace("na", "nya") 170 | .replace("ne", "nye") 171 | .replace("ni", "nyi") 172 | .replace("no", "nyo") 173 | .replace("nu", "nyu") 174 | .replace("ove", "uv") 175 | + protected 176 | ) 177 | 178 | # Add occasional stutter 179 | if ( 180 | len(uwu) > 2 # noqa: PLR2004 181 | and uwu[0].isalpha() 182 | and "-" not in uwu 183 | and not random.randint(0, 6) 184 | ): 185 | uwu = f"{uwu[0]}-{uwu}" 186 | 187 | # Add back punctuations and return 188 | return uwu + extra_punctuation + final_punctuation 189 | -------------------------------------------------------------------------------- /wikipedia/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for Wikipedia cog.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from redbot.core.bot import Red 7 | 8 | from .wikipedia import Wikipedia 9 | 10 | with Path(__file__).parent.joinpath("info.json").open() as fp: 11 | __red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"] 12 | 13 | 14 | async def setup(bot: Red) -> None: 15 | """Load Wikipedia cog.""" 16 | cog = Wikipedia() 17 | await bot.add_cog(cog) 18 | -------------------------------------------------------------------------------- /wikipedia/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Wikipedia", 3 | "author": [ 4 | "PhasecoreX (PhasecoreX#0635)" 5 | ], 6 | "short": "Look up articles on Wikipedia.", 7 | "description": "Allows for looking up terms on Wikipedia, and having the results displayed in chat.", 8 | "install_msg": "Thanks for installing Wikipedia!", 9 | "requirements": [ 10 | "python-dateutil" 11 | ], 12 | "tags": [ 13 | "information", 14 | "lookup", 15 | "reference", 16 | "search", 17 | "utility", 18 | "wiki" 19 | ], 20 | "min_bot_version": "3.5.0", 21 | "min_python_version": [ 22 | 3, 23 | 11, 24 | 0 25 | ], 26 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 27 | } 28 | -------------------------------------------------------------------------------- /wikipedia/wikipedia.py: -------------------------------------------------------------------------------- 1 | """Wikipedia cog for Red-DiscordBot ported by PhasecoreX.""" 2 | 3 | import re 4 | from contextlib import suppress 5 | from typing import Any 6 | 7 | import aiohttp 8 | import discord 9 | from dateutil.parser import isoparse 10 | from redbot.core import __version__ as redbot_version 11 | from redbot.core import commands 12 | from redbot.core.utils.chat_formatting import error, warning 13 | from redbot.core.utils.menus import DEFAULT_CONTROLS, menu 14 | 15 | MAX_DESCRIPTION_LENGTH = 1000 16 | 17 | 18 | class Wikipedia(commands.Cog): 19 | """Look up stuff on Wikipedia.""" 20 | 21 | __author__ = "PhasecoreX" 22 | __version__ = "3.1.0" 23 | 24 | DISAMBIGUATION_CAT = "Category:All disambiguation pages" 25 | WHITESPACE = re.compile(r"[\n\s]{4,}") 26 | NEWLINES = re.compile(r"\n+") 27 | 28 | # 29 | # Red methods 30 | # 31 | 32 | def format_help_for_context(self, ctx: commands.Context) -> str: 33 | """Show version in help.""" 34 | pre_processed = super().format_help_for_context(ctx) 35 | return f"{pre_processed}\n\nCog Version: {self.__version__}" 36 | 37 | async def red_delete_data_for_user(self, *, _requester: str, _user_id: int) -> None: 38 | """Nothing to delete.""" 39 | return 40 | 41 | # 42 | # Command methods 43 | # 44 | 45 | @commands.command(aliases=["wiki"]) 46 | async def wikipedia(self, ctx: commands.Context, *, query: str) -> None: 47 | """Get information from Wikipedia.""" 48 | can_not_embed_links = False 49 | can_not_add_reactions = False 50 | can_not_read_history = False 51 | if isinstance(ctx.me, discord.Member): 52 | can_not_embed_links = not ctx.channel.permissions_for(ctx.me).embed_links 53 | can_not_add_reactions = not ctx.channel.permissions_for( 54 | ctx.me 55 | ).add_reactions 56 | can_not_read_history = not ctx.channel.permissions_for( 57 | ctx.me 58 | ).read_message_history 59 | only_first_result = ( 60 | can_not_embed_links or can_not_add_reactions or can_not_read_history 61 | ) 62 | async with ctx.typing(): 63 | embeds, url = await self.perform_search( 64 | query, only_first_result=only_first_result 65 | ) 66 | 67 | if not embeds: 68 | await ctx.send( 69 | error(f"I'm sorry, I couldn't find \"{query}\" on Wikipedia") 70 | ) 71 | elif can_not_embed_links: 72 | await ctx.send( 73 | warning( 74 | f"I'm not allowed to do embeds here, so here's the first result:\n{url}" 75 | ) 76 | ) 77 | elif can_not_add_reactions: 78 | embeds[0].set_author( 79 | name="Result 1 (I need add reactions permission to show more)" 80 | ) 81 | await ctx.send(embed=embeds[0]) 82 | elif can_not_read_history: 83 | embeds[0].set_author( 84 | name="Result 1 (I need read message history permission to show more)" 85 | ) 86 | await ctx.send(embed=embeds[0]) 87 | elif len(embeds) == 1: 88 | embeds[0].set_author(name="Result 1 of 1") 89 | await ctx.send(embed=embeds[0]) 90 | else: 91 | for count, embed in enumerate(embeds): 92 | embed.set_author(name=f"Result {count + 1} of {len(embeds)}") 93 | await menu(ctx, embeds, DEFAULT_CONTROLS, timeout=60.0) 94 | 95 | # 96 | # Public methods 97 | # 98 | 99 | def generate_payload(self, query: str) -> dict[str, str]: 100 | """Generate the payload for Wikipedia based on a query string.""" 101 | query_tokens = query.split() 102 | return { 103 | # Main module 104 | "action": "query", # Fetch data from and about MediaWiki 105 | "format": "json", # Output data in JSON format 106 | # format:json options 107 | "formatversion": "2", # Modern format 108 | # action:query options 109 | "generator": "search", # Get list of pages by executing a query module 110 | "redirects": "1", # Automatically resolve redirects 111 | "prop": "extracts|info|pageimages|revisions|categories", # Which properties to get 112 | # action:query/generator:search options 113 | "gsrsearch": f"intitle:{' intitle:'.join(query_tokens)}", # Search for page titles 114 | # action:query/prop:extracts options 115 | "exintro": "1", # Return only content before the first section 116 | "explaintext": "1", # Return extracts as plain text 117 | # action:query/prop:info options 118 | "inprop": "url", # Gives a full URL for each page 119 | # action:query/prop:pageimages options 120 | "piprop": "original", # Return URL of page image, if any 121 | # action:query/prop:revisions options 122 | "rvprop": "timestamp", # Return timestamp of last revision 123 | # action:query/prop:revisions options 124 | "clcategories": self.DISAMBIGUATION_CAT, # Only list this category 125 | } 126 | 127 | async def perform_search( 128 | self, query: str, *, only_first_result: bool = False 129 | ) -> tuple[list[discord.Embed], str | None]: 130 | """Query Wikipedia.""" 131 | payload = self.generate_payload(query) 132 | async with aiohttp.ClientSession() as session, session.get( 133 | "https://en.wikipedia.org/w/api.php", 134 | params=payload, 135 | headers={"user-agent": "Red-DiscordBot/" + redbot_version}, 136 | ) as res: 137 | result = await res.json() 138 | 139 | embeds = [] 140 | if "query" in result and "pages" in result["query"]: 141 | result["query"]["pages"].sort( 142 | key=lambda unsorted_page: unsorted_page["index"] 143 | ) 144 | for page in result["query"]["pages"]: 145 | with suppress(KeyError): 146 | if ( 147 | "categories" in page 148 | and page["categories"] 149 | and "title" in page["categories"][0] 150 | and page["categories"][0]["title"] == self.DISAMBIGUATION_CAT 151 | ): 152 | continue # Skip disambiguation pages 153 | embeds.append(self.generate_embed(page)) 154 | if only_first_result: 155 | return embeds, page["fullurl"] 156 | return embeds, None 157 | 158 | def generate_embed(self, page_json: dict[str, Any]) -> discord.Embed: 159 | """Generate the embed for the json page.""" 160 | title = page_json["title"] 161 | description: str = page_json["extract"].strip() 162 | image = ( 163 | page_json["original"]["source"] 164 | if "original" in page_json and "source" in page_json["original"] 165 | else None 166 | ) 167 | url = page_json["fullurl"] 168 | timestamp = ( 169 | isoparse(page_json["revisions"][0]["timestamp"]) 170 | if "revisions" in page_json 171 | and page_json["revisions"] 172 | and "timestamp" in page_json["revisions"][0] 173 | else None 174 | ) 175 | 176 | whitespace_location = None 177 | whitespace_check_result = self.WHITESPACE.search(description) 178 | if whitespace_check_result: 179 | whitespace_location = whitespace_check_result.start() 180 | if whitespace_location: 181 | description = description[:whitespace_location].strip() 182 | description = self.NEWLINES.sub("\n\n", description) 183 | if len(description) > MAX_DESCRIPTION_LENGTH or whitespace_location: 184 | description = description[:MAX_DESCRIPTION_LENGTH].strip() 185 | description += f"... [(read more)]({url})" 186 | 187 | embed = discord.Embed( 188 | title=f"Wikipedia: {title}", 189 | description=description, 190 | color=discord.Color.blue(), 191 | url=url, 192 | timestamp=timestamp, 193 | ) 194 | if image: 195 | embed.set_image(url=image) 196 | text = "Information provided by Wikimedia" 197 | if timestamp: 198 | text += "\nArticle last updated" 199 | embed.set_footer( 200 | text=text, 201 | icon_url=( 202 | "https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/Wikimedia-logo.png" 203 | "/600px-Wikimedia-logo.png" 204 | ), 205 | ) 206 | return embed 207 | --------------------------------------------------------------------------------