├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── act ├── __init__.py ├── act.py ├── helpers.py └── info.json ├── anticrashvid ├── __init__.py ├── anticrashvid.py ├── data │ └── known_hashes └── info.json ├── antigifv ├── __init__.py ├── antigifv.py └── info.json ├── autodisconnect ├── __init__.py ├── autodisconnect.py └── info.json ├── avatar ├── __init__.py └── info.json ├── clocks ├── __init__.py ├── chart.py ├── clocks.py └── info.json ├── cmdreplier ├── __init__.py └── info.json ├── info.json ├── invoice ├── __init__.py ├── info.json └── invoice.py ├── logsfrom ├── __init__.py ├── info.json └── logsfrom.py ├── nationstates ├── __init__.py ├── info.json └── nationstates.py ├── onedit ├── __init__.py ├── info.json └── onedit.py ├── onetrueslash ├── __init__.py ├── channel.py ├── commands.py ├── context.py ├── events.py ├── info.json ├── message.py └── utils.py ├── pyproject.toml ├── rift ├── __init__.py ├── converter.py ├── graph.py ├── info.json └── rift.py ├── rtfs ├── __init__.py ├── info.json ├── pages.py └── rtfs.py ├── secureinv ├── __init__.py ├── info.json └── secureinv.py ├── skyrim ├── __init__.py ├── data │ └── lines.txt ├── info.json └── skyrim.py ├── spoilerer ├── __init__.py ├── info.json └── spoilerer.py ├── theme ├── __init__.py ├── info.json └── theme.py └── turn ├── __init__.py ├── info.json ├── namedlist.py └── turn.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-toml 11 | - id: check-json 12 | - id: check-added-large-files 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | rev: v0.8.6 15 | hooks: 16 | - id: ruff 17 | args: [ --fix, --exit-non-zero-on-fix ] 18 | - id: ruff-format 19 | ci: 20 | autoupdate_schedule: quarterly 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FluffyCogs 2 | 3 | [![Red](https://img.shields.io/badge/Red-DiscordBot-red.svg)](https://github.com/Cog-Creators/Red-DiscordBot/tree/V3/develop) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 5 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/Zephyrkul/FluffyCogs/master.svg)](https://results.pre-commit.ci/latest/github/Zephyrkul/FluffyCogs/master) 6 | 7 | The fluffiest of cogs for utility, requests, or proof-of-concepts. Or memes, as is the case with the Skyrim cog. 8 | 9 | ## Installation 10 | 11 | To install: 12 | 13 | > [p]repo add fluffycogs 14 | > 15 | > [p]cog list fluffycogs 16 | > 17 | > [p]cog install fluffycogs <cog> 18 | > 19 | > [p]load <cog> 20 | > 21 | > [p]help <Cog> 22 | 23 | ## Support 24 | 25 | You can find support for these cogs in [#support-fluffycogs](https://discord.com/channels/240154543684321280/902011844439457824) at the [Red cog support server](https://discord.gg/GET4DVk). 26 | 27 | ## Credits 28 | 29 | [Twentysix26](https://github.com/Twentysix26) - Developer of Red and initial creator of [the Rift cog](https://github.com/Twentysix26/26-Cogs/blob/master/rift/) 30 | 31 | [Cog-Creators](https://github.com/Cog-Creators) - Other Red core developers 32 | -------------------------------------------------------------------------------- /act/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .act import Act 7 | 8 | 9 | async def setup(bot: Red): 10 | act = Act(bot) 11 | await act.initialize(bot) 12 | await bot.add_cog(act) 13 | -------------------------------------------------------------------------------- /act/act.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import random 3 | import re 4 | from datetime import datetime, timedelta, timezone 5 | from typing import Match, Optional, Union 6 | 7 | import aiohttp 8 | import discord 9 | import inflection 10 | from redbot.core import Config, bot, commands, i18n 11 | from redbot.core.utils.chat_formatting import italics 12 | 13 | from .helpers import CONJ, LY_VERBS, NOLY_ADV, SOFT_VERBS 14 | 15 | fmt_re = re.compile(r"{(?:0|user)(?:\.([^\{]+))?}") 16 | cmd_re = re.compile(r"[a-zA-Z_]+") 17 | 18 | 19 | def guild_only_without_subcommand(): 20 | def predicate(ctx: commands.Context): 21 | if ctx.guild is None and ctx.invoked_subcommand is None: 22 | raise commands.NoPrivateMessage() 23 | return True 24 | 25 | return commands.check(predicate) 26 | 27 | 28 | class Act(commands.Cog): 29 | """ 30 | This cog makes all commands, e.g. [p]fluff, into valid commands if 31 | you command the bot to act on a user, e.g. [p]fluff [botname]. 32 | """ 33 | 34 | __author__ = "Zephyrkul" 35 | 36 | async def red_get_data_for_user(self, *, user_id): 37 | return {} # No data to get 38 | 39 | async def red_delete_data_for_user(self, *, requester, user_id): 40 | pass # No data to delete 41 | 42 | def __init__(self, bot: bot.Red): 43 | super().__init__() 44 | self.bot = bot 45 | self.config = Config.get_conf(self, identifier=2_113_674_295, force_registration=True) 46 | self.config.register_global(custom={}, tenorkey=None) 47 | self.config.register_guild(custom={}) 48 | self.try_after = None 49 | 50 | async def initialize(self, bot: bot.Red): 51 | # temporary backwards compatibility 52 | key = await self.config.tenorkey() 53 | if not key: 54 | return 55 | await bot.set_shared_api_tokens("tenor", api_key=key) 56 | await self.config.tenorkey.clear() 57 | 58 | @staticmethod 59 | def repl(target: discord.Member, match: Match[str]) -> str: 60 | if attr := match.group(1): 61 | if attr.startswith("_") or "." in attr: 62 | return str(target) 63 | try: 64 | return str(getattr(target, attr)) 65 | except AttributeError: 66 | return str(target) 67 | return str(target) 68 | 69 | @commands.command(hidden=True) 70 | async def act(self, ctx: commands.Context, *, target: Union[discord.Member, str] = None): 71 | if not target or isinstance(target, str): 72 | return # no help text 73 | 74 | try: 75 | if not ctx.guild: 76 | raise KeyError() 77 | message = await self.config.guild(ctx.guild).get_raw("custom", ctx.invoked_with) 78 | except KeyError: 79 | try: 80 | message = await self.config.get_raw("custom", ctx.invoked_with) 81 | except KeyError: 82 | message = NotImplemented 83 | 84 | humanized: Optional[str] = None 85 | if message is None: # ignored command 86 | return 87 | elif message is NotImplemented: # default 88 | # humanize action text 89 | humanized = inflection.humanize(ctx.invoked_with) 90 | action = humanized.split() 91 | iverb = -1 92 | 93 | for cycle in range(2): 94 | if iverb > -1: 95 | break 96 | for i, act in enumerate(action): 97 | act = act.lower() 98 | if ( 99 | act in NOLY_ADV 100 | or act in CONJ 101 | or (act.endswith("ly") and act not in LY_VERBS) 102 | or (not cycle and act in SOFT_VERBS) 103 | ): 104 | continue 105 | action[i] = inflection.pluralize(action[i]) 106 | iverb = max(iverb, i) 107 | 108 | if iverb < 0: 109 | return 110 | action.insert(iverb + 1, target.mention) 111 | message = italics(" ".join(action)) 112 | else: 113 | assert isinstance(message, str) 114 | message = fmt_re.sub(functools.partial(self.repl, target), message) 115 | 116 | send = functools.partial( 117 | ctx.send, 118 | allowed_mentions=discord.AllowedMentions( 119 | users=False if target in ctx.message.mentions else [target] 120 | ), 121 | ) 122 | 123 | # add reaction gif 124 | if self.try_after and ctx.message.created_at < self.try_after: 125 | return await send(message) 126 | if not await ctx.embed_requested(): 127 | return await send(message) 128 | key = (await ctx.bot.get_shared_api_tokens("tenor")).get("api_key") 129 | if not key: 130 | return await send(message) 131 | if humanized is None: 132 | humanized = inflection.humanize(ctx.invoked_with) 133 | async with aiohttp.request( 134 | "GET", 135 | "https://g.tenor.com/v1/search", 136 | params={ 137 | "q": humanized, 138 | "key": key, 139 | "anon_id": str(ctx.author.id ^ ctx.me.id), 140 | "media_filter": "minimal", 141 | "contentfilter": "off" if getattr(ctx.channel, "nsfw", False) else "low", 142 | "ar_range": "wide", 143 | "limit": "20", 144 | "locale": i18n.get_locale(), 145 | }, 146 | ) as response: 147 | json: dict 148 | if response.status == 429: 149 | self.try_after = ctx.message.created_at + timedelta(seconds=30) 150 | json = {} 151 | elif response.status >= 400: 152 | json = {} 153 | else: 154 | json = await response.json() 155 | if not json.get("results"): 156 | return await send(message) 157 | # Try to keep gifs more relevant by only grabbing from the top 50% + 1 of results, 158 | # in case there are only a few results. 159 | # math.ceiling() is not used since it would be too limiting for smaller lists. 160 | choice = json["results"][random.randrange(len(json["results"]) // 2 + 1)] 161 | choice = random.choice(json["results"]) 162 | embed = discord.Embed( 163 | color=await ctx.embed_color(), 164 | timestamp=datetime.fromtimestamp(choice["created"], timezone.utc), 165 | url=choice["itemurl"], 166 | ) 167 | # This footer is required by Tenor's API: https://tenor.com/gifapi/documentation#attribution 168 | embed.set_footer(text="Via Tenor") 169 | embed.set_image(url=choice["media"][0]["gif"]["url"]) 170 | await send(message, embed=embed) 171 | 172 | # because people keep using [p]help act instead of [p]help Act 173 | act.callback.__doc__ = __doc__ 174 | 175 | @commands.group() 176 | @commands.admin_or_permissions(manage_guild=True) 177 | async def actset(self, ctx: commands.Context): 178 | """ 179 | Configure various settings for the act cog. 180 | """ 181 | 182 | @actset.group(aliases=["custom", "customise"], invoke_without_command=True) 183 | @commands.admin_or_permissions(manage_guild=True) 184 | @guild_only_without_subcommand() 185 | async def customize(self, ctx: commands.GuildContext, command: str, *, response: str = None): 186 | """ 187 | Customize the response to an action. 188 | 189 | You can use {0} or {user} to dynamically replace with the specified target of the action. 190 | Formats like {0.name} or {0.mention} can also be used. 191 | """ 192 | if not response: 193 | await self.config.guild(ctx.guild).clear_raw("custom", command) 194 | await ctx.tick() 195 | else: 196 | await self.config.guild(ctx.guild).set_raw("custom", command, value=response) 197 | await ctx.send( 198 | fmt_re.sub(functools.partial(self.repl, ctx.author), response), 199 | allowed_mentions=discord.AllowedMentions(users=False), 200 | ) 201 | 202 | @customize.command(name="global") 203 | @commands.is_owner() 204 | async def customize_global(self, ctx: commands.Context, command: str, *, response: str = None): 205 | """ 206 | Globally customize the response to an action. 207 | 208 | You can use {0} or {user} to dynamically replace with the specified target of the action. 209 | Formats like {0.name} or {0.mention} can also be used. 210 | """ 211 | if not response: 212 | await self.config.clear_raw("custom", command) 213 | else: 214 | await self.config.set_raw("custom", command, value=response) 215 | await ctx.tick() 216 | 217 | @actset.group(invoke_without_command=True) 218 | @commands.admin_or_permissions(manage_guild=True) 219 | @guild_only_without_subcommand() 220 | async def ignore(self, ctx: commands.GuildContext, command: str): 221 | """ 222 | Ignore or unignore the specified action. 223 | 224 | The bot will no longer respond to these actions. 225 | """ 226 | try: 227 | custom = await self.config.guild(ctx.guild).get_raw("custom", command) 228 | except KeyError: 229 | custom = NotImplemented 230 | if custom is None: 231 | await self.config.guild(ctx.guild).clear_raw("custom", command) 232 | await ctx.send("I will no longer ignore the {command} action".format(command=command)) 233 | else: 234 | await self.config.guild(ctx.guild).set_raw("custom", command, value=None) 235 | await ctx.send("I will now ignore the {command} action".format(command=command)) 236 | 237 | @ignore.command(name="global") 238 | @commands.is_owner() 239 | async def ignore_global(self, ctx: commands.Context, command: str): 240 | """ 241 | Globally ignore or unignore the specified action. 242 | 243 | The bot will no longer respond to these actions. 244 | """ 245 | try: 246 | await self.config.get_raw("custom", command) 247 | except KeyError: 248 | await self.config.set_raw("custom", command, value=None) 249 | else: 250 | await self.config.clear_raw("custom", command) 251 | await ctx.tick() 252 | 253 | @actset.command() 254 | @commands.admin_or_permissions(manage_guild=True) 255 | async def embed(self, ctx: commands.Context): 256 | """ 257 | Manage tenor embed settings for this cog. 258 | """ 259 | await ctx.maybe_send_embed( 260 | "You can enable or disable whether this cog attaches tenor gifs " 261 | f"by using `{ctx.clean_prefix}embedset command act on/off`." 262 | ) 263 | 264 | @actset.command() 265 | @commands.is_owner() 266 | async def tenorkey(self, ctx: commands.Context): 267 | """ 268 | Sets a Tenor GIF API key to enable reaction gifs with act commands. 269 | 270 | You can obtain a key from here: https://tenor.com/developer/dashboard 271 | """ 272 | instructions = [ 273 | "Go to the Tenor developer dashboard: https://tenor.com/developer/dashboard", 274 | "Log in or sign up if you haven't already.", 275 | "Click `+ Create new app` and fill out the form.", 276 | "Copy the key from the app you just created.", 277 | "Give the key to Red with this command:\n" 278 | f"`{ctx.clean_prefix}set api tenor api_key your_api_key`\n" 279 | "Replace `your_api_key` with the key you just got.\n" 280 | "Everything else should be the same.\n\n", 281 | "You can disable embeds again by using this command:\n" 282 | f"`{ctx.clean_prefix}embedset command act off`", 283 | ] 284 | instructions = [f"**{i}.** {v}" for i, v in enumerate(instructions, 1)] 285 | await ctx.maybe_send_embed("\n".join(instructions)) 286 | 287 | @commands.Cog.listener() 288 | async def on_command_error( 289 | self, ctx: commands.Context, error: commands.CommandError, unhandled_by_cog: bool = False 290 | ): 291 | if ctx.command == self.act: 292 | return 293 | if not self.act.enabled: 294 | return 295 | if not cmd_re.fullmatch(ctx.invoked_with): 296 | return 297 | if await ctx.bot.cog_disabled_in_guild(self, ctx.guild): 298 | return 299 | if isinstance(error, commands.UserFeedbackCheckFailure): 300 | # UserFeedbackCheckFailure inherits from CheckFailure 301 | return 302 | if not isinstance(error, (commands.CheckFailure, commands.CommandNotFound)): 303 | return 304 | ctx.command = self.act 305 | await ctx.bot.invoke(ctx) 306 | -------------------------------------------------------------------------------- /act/helpers.py: -------------------------------------------------------------------------------- 1 | LY_VERBS = frozenset( 2 | ( 3 | "ally", 4 | "apply", 5 | "fly", 6 | "imply", 7 | "multiply", 8 | "ply", 9 | "rally", 10 | "sully", 11 | "supply", 12 | "tally", 13 | ) 14 | ) 15 | NOLY_ADV = frozenset( 16 | ( 17 | "afterward", 18 | "almost", 19 | "already", 20 | "best", 21 | "better", 22 | "bright", 23 | "deep", 24 | "different", 25 | "even", 26 | "far", 27 | "fast", 28 | "flat", 29 | "hard", 30 | "here", 31 | "high", 32 | "how", 33 | "kind", 34 | "late", 35 | "long", 36 | "low", 37 | "more", 38 | "near", 39 | "never", 40 | "next", 41 | "now", 42 | "often", 43 | "quick", 44 | "rather", 45 | "sharp", 46 | "so", 47 | "soon", 48 | "straight", 49 | "then", 50 | "tight", 51 | "today", 52 | "tomorrow", 53 | "too", 54 | "tough", 55 | "very", 56 | "well", 57 | "where", 58 | "yesterday", 59 | ) 60 | ) 61 | CONJ = frozenset(("and", "or")) 62 | SOFT_VERBS = frozenset(("back", "clean", "close", "right", "slow", "still")) 63 | -------------------------------------------------------------------------------- /act/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Please note that custom commands that take arguments will still trigger this cog.", 6 | "name": "Act", 7 | "short": "Command the bot to perform an action on a fellow user.", 8 | "min_bot_version": "3.5.0", 9 | "requirements": [ 10 | "inflection" 11 | ], 12 | "description": "Lets you command the bot to perform an action on someone else.", 13 | "tags": [ 14 | "fun" 15 | ], 16 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 17 | } 18 | -------------------------------------------------------------------------------- /anticrashvid/__init__.py: -------------------------------------------------------------------------------- 1 | # The file bundled with this cog is a base85-encoded list of known hashes that crash discord, 2 | # as pre-computed by trusted sources. 3 | 4 | from redbot.core.utils import get_end_user_data_statement_or_raise 5 | 6 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 7 | 8 | from .anticrashvid import AntiCrashVid 9 | 10 | 11 | async def setup(bot): 12 | await bot.add_cog(AntiCrashVid(bot)) 13 | -------------------------------------------------------------------------------- /anticrashvid/anticrashvid.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextvars 3 | import functools 4 | import hashlib 5 | import logging 6 | import math 7 | import os 8 | import pathlib 9 | import shutil 10 | from base64 import b85decode, b85encode 11 | from datetime import datetime, timezone 12 | from typing import TYPE_CHECKING, Callable, Final, List, TypeVar 13 | 14 | import discord 15 | import youtube_dl 16 | from redbot.core import Config, commands, modlog 17 | from redbot.core.bot import Red 18 | from redbot.core.data_manager import bundled_data_path, cog_data_path 19 | from redbot.core.utils.chat_formatting import pagify 20 | 21 | # chunks >=2048 cause hashlib to release the GIL 22 | BLOCKS: Final[int] = 128 23 | HASHES: Final[str] = "HASHES" 24 | LOG = logging.getLogger("red.fluffy.anticrashvid") 25 | T = TypeVar("T") 26 | 27 | if TYPE_CHECKING: 28 | Hex = bytes 29 | else: 30 | Hex = bytes.fromhex 31 | 32 | if os.name == "nt": 33 | FFMPEG = "ffmpeg.exe" 34 | FFPROBE = "ffprobe.exe" 35 | else: 36 | FFMPEG = "ffmpeg" 37 | FFPROBE = "ffprobe" 38 | 39 | 40 | # backport of 3.9's to_thread 41 | async def to_thread(func: Callable[..., T], /, *args, **kwargs) -> T: 42 | loop = asyncio.get_running_loop() 43 | ctx = contextvars.copy_context() 44 | func_call = functools.partial(ctx.run, func, *args, **kwargs) 45 | return await loop.run_in_executor(None, func_call) # type: ignore 46 | 47 | 48 | class VideoTooLong(Exception): 49 | """Exception raised when the video is too long. Not sure what else you were expecting.""" 50 | 51 | 52 | class EmptyOutputFile(Exception): 53 | """Exception raised when ffmpeg's output file is empty.""" 54 | 55 | 56 | # Credit for these fixes: https://www.reddit.com/r/discordapp/comments/mwsqm2/detect_discord_crash_videos_for_bot_developers/ 57 | class AntiCrashVid(commands.Cog): 58 | def __init__(self, bot: Red): 59 | self.bot = bot 60 | self.config = Config.get_conf(self, identifier=2113674295, force_registration=True) 61 | self.config.init_custom(HASHES, 1) 62 | self.config.register_custom(HASHES, unsafe=None) 63 | 64 | async def red_delete_data_for_user(self, *, requester, user_id): 65 | pass 66 | 67 | async def red_get_data_for_user(self, *, user_id): 68 | return {} 69 | 70 | async def cog_load(self) -> None: 71 | try: 72 | await modlog.register_casetype( 73 | name="malicious_video", 74 | default_setting=True, 75 | image="\N{TELEVISION}", 76 | case_str="Potentially malicious video detected", 77 | ) 78 | except RuntimeError: 79 | pass 80 | await self.preload_hashes() 81 | 82 | async def preload_hashes(self, *, clear_past_hashes=False): 83 | async with self.config.custom(HASHES).all() as current_hashes: 84 | assert isinstance(current_hashes, dict) 85 | if clear_past_hashes: 86 | current_hashes.clear() 87 | await to_thread(self._insert_hashes, current_hashes) 88 | 89 | def _insert_hashes(self, hashes: dict): 90 | # b85 uses 5 ASCII chars to represent 4 bytes of data 91 | b85_digest_size = math.ceil(hashlib.sha512().digest_size / 4) * 5 92 | value = {"unsafe": True} 93 | with open(bundled_data_path(self) / "known_hashes", "rb") as file: 94 | while chunk := file.read(b85_digest_size): 95 | if len(chunk) == b85_digest_size: 96 | hashes[b85decode(chunk).hex()] = value 97 | 98 | @commands.command(hidden=True) 99 | @commands.is_owner() 100 | async def export_hashes(self, ctx: commands.Context): 101 | """Exports known hashes as a base85-encoded block.""" 102 | all_hashes = b"".join( 103 | b85encode(bytes.fromhex(k), pad=True) 104 | for k, v in (await self.config.custom(HASHES).all()).items() 105 | if v["unsafe"] 106 | ) 107 | if all_hashes: 108 | return await ctx.send_interactive( 109 | pagify(all_hashes.decode("ascii"), shorten_by=10), box_lang="" 110 | ) 111 | await ctx.send("No hashes to export.") 112 | 113 | @commands.command(hidden=True) 114 | @commands.is_owner() 115 | async def clear_hashes(self, ctx: commands.Context): 116 | """ 117 | Removes all hex digests from the cache. 118 | 119 | Known / pre-computed hashes will remain cached. 120 | """ 121 | await self.preload_hashes(clear_past_hashes=True) 122 | await ctx.tick() 123 | 124 | @commands.Cog.listener() 125 | async def on_message(self, message: discord.Message): 126 | if not message.guild: 127 | return 128 | debug = (message.author.id, self.bot.user.id) in [ 129 | (215640856839979008, 256505473807679488), 130 | (281321316286726144, 346056290566406155), 131 | ] 132 | if not debug and ( 133 | await self.bot.cog_disabled_in_guild(self, message.guild) 134 | or await self.bot.is_automod_immune(message) 135 | ): 136 | return 137 | links = [] 138 | for attachment in message.attachments: 139 | if attachment.content_type and attachment.content_type.startswith("video/"): 140 | links.append(attachment.proxy_url) 141 | for embed in message.embeds: 142 | if url := embed.video.url: 143 | assert isinstance(url, str) 144 | links.append(url) 145 | if not links: 146 | return 147 | if not any(await self.check_links(links, message.channel.id, message.id, debug=debug)): 148 | return 149 | await self.cry(message) 150 | 151 | @commands.Cog.listener() 152 | async def on_message_edit(self, _, message: discord.Message): 153 | if not message.guild: 154 | return 155 | debug = (message.author.id, self.bot.user.id) == (215640856839979008, 256505473807679488) 156 | if not debug and ( 157 | await self.bot.cog_disabled_in_guild(self, message.guild) 158 | or await self.bot.is_automod_immune(message) 159 | ): 160 | return 161 | links = [] 162 | for embed in message.embeds: 163 | if url := embed.video.url: 164 | assert isinstance(url, str) 165 | links.append(url) 166 | if not links: 167 | return 168 | if not any(await self.check_links(links, message.channel.id, message.id, debug=debug)): 169 | return 170 | await self.cry(message) 171 | 172 | async def cry(self, message: discord.Message): 173 | assert message.guild and isinstance(message.channel, discord.TextChannel) 174 | message_deleted = False 175 | try: 176 | if ( 177 | message.author == message.guild.me 178 | or message.channel.permissions_for(message.guild.me).manage_messages 179 | ): 180 | await message.delete() 181 | message_deleted = True 182 | except discord.HTTPException: 183 | pass 184 | try: 185 | await modlog.create_case( 186 | bot=self.bot, 187 | guild=message.guild, 188 | # datetime.now because processing videos can take time 189 | created_at=datetime.now(timezone.utc), 190 | action_type="malicious_video", 191 | user=message.author, 192 | moderator=message.guild.me, 193 | channel=message.channel, 194 | reason=( 195 | message.jump_url if not message_deleted else "Offending message was deleted." 196 | ), 197 | ) 198 | except Exception: 199 | pass 200 | 201 | async def check_links( 202 | self, links: List[str], channel_id: int, message_id: int, *, debug: bool = False 203 | ) -> List[bool]: 204 | assert links 205 | directory = cog_data_path(self) / f"{channel_id}-{message_id}" 206 | try: 207 | if len(links) == 1: 208 | return [await self.check_link(links[0], directory, debug=debug)] 209 | return await asyncio.gather( 210 | *( 211 | self.check_link(link, directory / str(i), debug=debug) 212 | for i, link in enumerate(links) 213 | ), 214 | return_exceptions=True, 215 | ) 216 | finally: 217 | shutil.rmtree(directory, ignore_errors=True) 218 | 219 | async def check_link(self, link: str, path: pathlib.Path, *, debug: bool = False) -> bool: 220 | path.mkdir(parents=True) 221 | template = "%(title)s-%(id)s.%(ext)s" 222 | try: 223 | filename = template % await to_thread( 224 | self.dl_video, 225 | link, 226 | outtmpl=os.path.join(str(path).replace("%", "%%"), template), 227 | quiet=True, 228 | logger=LOG, 229 | # anything less than "best" may download gifs instead, 230 | # which are seen as safe but are not actually safe 231 | format="best", 232 | ) 233 | except VideoTooLong: 234 | LOG.info("Video at link %r was too long, and wasn't downloaded or probed.", link) 235 | return False 236 | video = path / filename 237 | video.with_suffix("").mkdir() 238 | digest = await to_thread(self.hexdigest, video) 239 | unsafe = self.config.custom(HASHES, digest).unsafe 240 | async with unsafe.get_lock(): 241 | LOG.debug("digest for video at link %r: %s", link, digest) 242 | if await unsafe(): 243 | LOG.debug("would remove message with link %r; cached digest @ %s", link, digest) 244 | if not debug: 245 | return True 246 | else: 247 | LOG.debug("link %r not in digest cache", link) 248 | LOG.info( 249 | "Beginning first of three probes for link %r.\n" 250 | "If anticrashvid logs stop suddenly, then most likely your system has insufficient RAM for this cog.", 251 | link, 252 | ) 253 | process = await asyncio.create_subprocess_exec( 254 | FFPROBE, 255 | "-v", 256 | "error", 257 | "-show_entries", 258 | "frame=width,height", 259 | "-select_streams", 260 | "v", 261 | "-of", 262 | "csv=p=0", 263 | video, 264 | stdout=asyncio.subprocess.PIPE, 265 | ) 266 | # only one pipe is used, so accessing it should™️ be safe 267 | assert process.stdout 268 | prev = b"" 269 | while line := await process.stdout.readline(): 270 | if not (line := line.strip()): 271 | continue 272 | if (prev and line != prev) or any(int(d) > 9999 for d in line.split(b",")): 273 | process.terminate() 274 | LOG.debug( 275 | "would remove message with link %r: " 276 | "ffprobe frame dimensions are not constant or are abnormally large\n\t%r\t%r", 277 | link, 278 | prev, 279 | line, 280 | ) 281 | await unsafe.set(True) 282 | return True 283 | prev = line 284 | else: 285 | LOG.debug( 286 | "ffprobe dimension scan for link %r complete, nothing abnormal found.", link 287 | ) 288 | LOG.info("Beginning second probe for link %r.", link) 289 | try: 290 | first_line = await self.get_ffmpeg_probe( 291 | "-loglevel", 292 | "fatal", 293 | "-i", 294 | str(video), 295 | "-vframes", 296 | "1", 297 | "-q:v", 298 | "1", 299 | path=video.with_suffix("") / "first.jpg", 300 | ) 301 | LOG.info("Beginning third probe for link %r.", link) 302 | last_line = await self.get_ffmpeg_probe( 303 | "-loglevel", 304 | "fatal", 305 | "-sseof", 306 | "-3", 307 | "-i", 308 | str(video), 309 | "-update", 310 | "1", 311 | "-q:v", 312 | "1", 313 | path=video.with_suffix("") / "last.jpg", 314 | ) 315 | LOG.debug("first.jpg probe: %r\nlast.jpg probe: %r", first_line, last_line) 316 | except EmptyOutputFile: 317 | LOG.debug("Empty ffmpeg output.", exc_info=True) 318 | else: 319 | if first_line != last_line: 320 | LOG.debug( 321 | "would remove message with link %r: first/last frames have conflicting results", 322 | link, 323 | ) 324 | await unsafe.set(True) 325 | return True 326 | else: 327 | LOG.debug("link %r has consistent first/last ffmpeg probe results", link) 328 | del first_line, last_line 329 | LOG.info("Nothing abnormal found for link %r: video appears safe", link) 330 | 331 | @staticmethod 332 | async def get_ffmpeg_probe(*args: str, path: pathlib.Path) -> bytes: 333 | process = await asyncio.create_subprocess_exec(FFMPEG, *args, path) 334 | if code := await process.wait(): 335 | raise RuntimeError(f"Process exited with exit code {code}") 336 | if not path.exists(): 337 | raise RuntimeError(f"ffmpeg did not create a file at {path}") 338 | process = await asyncio.create_subprocess_exec( 339 | FFPROBE, "-i", path, stderr=asyncio.subprocess.PIPE 340 | ) 341 | # only one pipe is used, so accessing it should™️ be safe 342 | assert process.stderr 343 | line = b"" 344 | while next_line := await process.stderr.readline(): 345 | if not next_line.isspace(): 346 | line = next_line 347 | return line 348 | 349 | @staticmethod 350 | def hexdigest(path) -> str: 351 | hasher = hashlib.sha512() 352 | block = BLOCKS * hasher.block_size 353 | with open(path, "rb") as file: 354 | while chunk := file.read(block): 355 | hasher.update(chunk) 356 | return hasher.hexdigest() 357 | 358 | @staticmethod 359 | def dl_video(link: str, /, **options) -> dict: 360 | with youtube_dl.YoutubeDL(options) as ytdl: 361 | # don't download quite yet 362 | info = ytdl.extract_info(link, download=False) 363 | try: 364 | if info["duration"] > 60: 365 | # 60s is arbitrary, but crashing videos are extremely unlikely to be very long 366 | raise VideoTooLong 367 | except KeyError: 368 | pass 369 | return ytdl.extract_info(link) 370 | -------------------------------------------------------------------------------- /anticrashvid/data/known_hashes: -------------------------------------------------------------------------------- 1 | 6Qt8%VG)@g<%_rS%Q{fxqfN)dJb7|5W(BBw{6Ji3oDnXYkky+5mLkd@QT*X7b9|ZTJhU7r#d-0+T(64bDQ*nM5W3XoG$;Q%JR?I->N@)bQd&wxjV!?keB}p}zAvabYM6z&kv;M*2i4WQY0@irf4)3*S{_PyNcAtS=-&ged)k8D@Zhej0D)t!bv%imUNHdUsE$ok&ygxETRRspi+W>7Oa2*)S>E#TFD*0ZI&4@g%jov^>vWd_?g=owC`9NX0ow^$BAA5L+`FMkY4W*9!a5w^w!;}eIT-&&2ogDN_PK>@_9N&n*Yu8|9;dP)`?*7|3TW^NdX!J?TaWT0JjM}P=y84@7@L73L|$sqBTae!-xt89Uftlw4%X!Be?*hw3io@R(`kTJE*@q51bQn=mNiRyGKD1HcCfezrrE#09D7j;5gRMb|0W#=d}++v!eAkloRlCzVL*V4QF$o=^BVG)vLy4%kv{aAgh`}ZFS<9blbstq-7N{c|6UK^xa-!L_|?hHCl*w?*`Z~xaB|2&tzVbGn;y&!Iv%ixIef1wkUak0k5@4}9X?TeGBv{Z3eQM4%Wf*P1L(Px?n+is`Qetjda(?_>yjp}%S=#g8t?7}w8=U%W!)?844)5LY`w;OsN`~ 0: 66 | try: 67 | await self.bot.wait_for("voice_state_update", check=check, timeout=timeout) 68 | except asyncio.TimeoutError: 69 | pass # we want this to happen 70 | else: 71 | return # the member moved on their own 72 | try: 73 | await member.move_to(None) 74 | except discord.HTTPException: 75 | return 76 | -------------------------------------------------------------------------------- /autodisconnect/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help AutoDisconnect`", 6 | "name": "AutoDisconnect", 7 | "short": "Automatically disconnect inactive VC users. Requires a set AFK channel.", 8 | "description": "Automatically disconnect inactive VC users. Requires a set AFK channel.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "voice", 12 | "utility" 13 | ], 14 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 15 | } 16 | -------------------------------------------------------------------------------- /avatar/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import discord 4 | from redbot.core import app_commands 5 | from redbot.core.bot import Red 6 | 7 | 8 | @app_commands.context_menu(name="Avatar", extras={"red_force_enable": True}) 9 | @app_commands.user_install() 10 | async def avatar(interaction: discord.Interaction[Red], user: Union[discord.Member, discord.User]): 11 | await interaction.response.send_message( 12 | embed=discord.Embed(title=f"{user.display_name} - {user.id}", color=user.color).set_image( 13 | url=user.display_avatar.url 14 | ), 15 | ephemeral=True, 16 | ) 17 | 18 | 19 | async def setup(bot: Red): 20 | bot.tree.add_command(avatar) 21 | -------------------------------------------------------------------------------- /avatar/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "This cog requires your bot to be installed on your user account.", 6 | "short": "Adds a simple Avatar context menu to your user-installed bot.", 7 | "description": "Adds a simple Avatar context menu to your user-installed bot.", 8 | "min_bot_version": "3.5.10", 9 | "tags": [ 10 | "utility" 11 | ], 12 | "hidden": false, 13 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 14 | } 15 | -------------------------------------------------------------------------------- /clocks/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .clocks import Clocks 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(Clocks()) 11 | -------------------------------------------------------------------------------- /clocks/chart.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | ch = "\u2500\u2572\u2502\u2571 " 4 | 5 | 6 | def cch(fil, per, a): 7 | c = divmod(a, per) 8 | p = round(per, 2) 9 | b = round(c[1], 2) 10 | if b == p or b == 0 or c[0] < fil: 11 | a = (a + 0.25) * 8 12 | return ch[round(a) % 4] 13 | return ch[-1] 14 | 15 | 16 | def pie(fil, tot): 17 | r = min(tot // 2, 8) + 4 18 | per = 1 / tot 19 | final = f"{fil} / {tot}" 20 | final = f"Clock{final:^{4 * r - 9}}".rstrip() + "\n" 21 | for y in range(-r, r + 1): 22 | for x in range(-2 * r, 2 * r + 1): 23 | x /= -2 24 | i = round((x * x + y * y) / (r * r) - 1, 1) 25 | a = math.atan2(x, y) / math.pi / 2 + 0.5 26 | if i < 0: 27 | a = math.atan2(x, y) / math.pi / 2 + 0.5 28 | n = cch(fil, per, a) 29 | elif i > 0: 30 | n = ch[-1] 31 | else: 32 | n = ch[round(a * 8) % 4] 33 | final += n 34 | final = final.rstrip() + "\n" 35 | return f"**```{final.rstrip()}```**" 36 | -------------------------------------------------------------------------------- /clocks/clocks.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from redbot.core import Config, commands 3 | 4 | from .chart import pie 5 | 6 | 7 | def n_or_greater(n): 8 | def bounded_int(argument): 9 | argument = int(argument) 10 | if argument < n: 11 | raise ValueError 12 | return argument 13 | 14 | return bounded_int 15 | 16 | 17 | def nonzero_int(argument): 18 | argument = int(argument) 19 | if argument == 0: 20 | raise ValueError 21 | return argument 22 | 23 | 24 | class Clocks(commands.Cog): 25 | # TODO: async def red_get_data_for_user(self, *, user_id): 26 | 27 | async def red_delete_data_for_user(self, *, requester, user_id): 28 | # Nothing here is operational, so just delete it all 29 | await self.config.user_from_id(user_id).clear() 30 | 31 | def __init__(self): 32 | super().__init__() 33 | self.config = Config.get_conf(self, identifier=2113674295, force_registration=True) 34 | self.config.register_user(clocks={}) 35 | 36 | @commands.group(aliases=["clock"]) 37 | async def clocks(self, ctx): 38 | """Track projects with clocks""" 39 | 40 | @clocks.command() 41 | async def create( 42 | self, ctx, name: str.lower, slices: n_or_greater(2), *, start: n_or_greater(0) = 0 43 | ): 44 | """Create a new clock""" 45 | async with self.config.user(ctx.author).clocks() as clocks: 46 | if name in clocks: 47 | return await ctx.send("This clock already exists.") 48 | clocks[name] = [start, slices] 49 | await ctx.send(pie(start, slices)) 50 | 51 | @clocks.command() 52 | async def delete(self, ctx, *, name: str.lower): 53 | """Delete a clock""" 54 | async with self.config.user(ctx.author).clocks() as clocks: 55 | clocks.pop(name, None) 56 | await ctx.send("Clock deleted.") 57 | 58 | @clocks.command() 59 | async def extend(self, ctx, name: str.lower, *, slices: nonzero_int): 60 | """Modify a clock's maximum slices.""" 61 | async with self.config.user(ctx.author).clocks() as clocks: 62 | try: 63 | this_clock = clocks[name] 64 | except KeyError: 65 | return await ctx.send("No such clock.") 66 | this_clock[1] = max(2, this_clock[1] + slices) 67 | this_clock[0] = sorted((0, this_clock[0], this_clock[1]))[1] 68 | await ctx.send(pie(*this_clock)) 69 | 70 | @clocks.command(aliases=["add", "modify"]) 71 | async def mod(self, ctx, name: str.lower, *, slices: nonzero_int): 72 | """Modify a clock's progress.""" 73 | async with self.config.user(ctx.author).clocks() as clocks: 74 | try: 75 | this_clock = clocks[name] 76 | except KeyError: 77 | return await ctx.send("No such clock.") 78 | this_clock[0] += slices 79 | this_clock[0] = sorted((0, this_clock[0], this_clock[1]))[1] 80 | await ctx.send(pie(*this_clock)) 81 | 82 | @clocks.command(name="set") 83 | async def _set( 84 | self, ctx, name: str.lower, slices: n_or_greater(0), *, max: n_or_greater(2) = None 85 | ): 86 | """Sets a clock's state.""" 87 | async with self.config.user(ctx.author).clocks() as clocks: 88 | try: 89 | this_clock = clocks[name] 90 | except KeyError: 91 | return await ctx.send("No such clock.") 92 | if max: 93 | this_clock[1] = max 94 | this_clock[0] = sorted((0, slices, this_clock[1]))[1] 95 | await ctx.send(pie(*this_clock)) 96 | 97 | @clocks.command() 98 | async def show(self, ctx, name: str.lower = None, *, user: discord.Member = None): 99 | """Show a clock's progress.""" 100 | if user and not ctx.guild: 101 | return 102 | if not user: 103 | user = ctx.author 104 | clocks = await self.config.user(user).clocks() 105 | try: 106 | result = pie(*clocks[name]) if name else ", ".join(clocks.keys()) 107 | except KeyError: 108 | return await ctx.send("No such clock.") 109 | if result: 110 | return await ctx.send(result) 111 | await ctx.send("No clocks created.") 112 | -------------------------------------------------------------------------------- /clocks/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help clocks`\n**NOTE:** This cog is mainly used for Forged in the Dark tabletop RPGs.\nIt will likely make no sense outside of this context.", 6 | "name": "Clocks", 7 | "short": "Track FitD projects with clocks.", 8 | "description": "Track projects with clocks. Mainly used for Forged in the Dark tabletop RPGs.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "utility" 12 | ], 13 | "hidden": false, 14 | "end_user_data_statement": "This cog stores data provided via command by users for the express purpose of redisplaying. Users may remove this data via data request or via command." 15 | } 16 | -------------------------------------------------------------------------------- /cmdreplier/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import partial 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from redbot.core.bot import Red 8 | from redbot.core.commands import Context 9 | 10 | from redbot.core.utils import get_end_user_data_statement_or_raise 11 | 12 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 13 | 14 | 15 | async def new_send(__sender, /, *args, **kwargs): 16 | ctx: Context = __sender.__self__ 17 | if not ctx.command_failed and "reference" not in kwargs: 18 | message = ctx.message 19 | try: 20 | resolved = message.reference.resolved 21 | failsafe_ref = resolved.to_reference(fail_if_not_exists=False) 22 | except AttributeError: 23 | pass 24 | else: 25 | kwargs["reference"] = failsafe_ref 26 | kwargs["mention_author"] = resolved.author in message.mentions 27 | return await __sender(*args, **kwargs) 28 | 29 | 30 | async def before_hook(ctx: Context): 31 | # onedit allows command calls on message edits 32 | # since replies always mention and can't be changed on edit, 33 | # this won't patch ctx if the command invokation is an edit. 34 | if ctx.message.reference and not ctx.message.edited_at: 35 | try: 36 | # before_hook might be called multiple times 37 | # clear any overwritten send method before overwriting it again 38 | del ctx.send 39 | except AttributeError: 40 | pass 41 | ctx.send = partial(new_send, ctx.send) 42 | 43 | 44 | async def setup(bot: Red): 45 | bot.before_invoke(before_hook) 46 | 47 | 48 | async def teardown(bot: Red): 49 | bot.remove_before_invoke_hook(before_hook) 50 | -------------------------------------------------------------------------------- /cmdreplier/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "This extension has no commands. Loading enables the functionality globally, and unloading disables it.\nNot all commands will respect this setting.", 6 | "name": "CMDReplier", 7 | "short": "Let command responses reply to the same message the command message replies to.", 8 | "description": "Let command responses reply to the same message the command message replies to.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "utility" 12 | ], 13 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 14 | } 15 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/Cog-Creators/Red-DiscordBot/V3/develop/schema/red_cog_repo.schema.json", 3 | "author": [ 4 | "Zephyrkul (Zephyrkul#1089)" 5 | ], 6 | "install_msg": "Thank you and commiserations for installing FluffyCogs.\nIf you need support, find me in #support-fluffycogs at Red's cog support server: https://discord.gg/GET4DVk", 7 | "name": "FluffyCogs", 8 | "short": "Only the fluffiest of cogs go here.", 9 | "description": "My cogs for utility, requests, or proof-of-concepts. Or memes, as is the case with the skyrim cog.", 10 | "tags": [ 11 | "fluffy" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /invoice/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .invoice import InVoice 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(InVoice(bot)) 11 | -------------------------------------------------------------------------------- /invoice/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)", 4 | "Paddo" 5 | ], 6 | "hidden": true, 7 | "install_msg": "Usage: `[p]help InVoice`", 8 | "name": "InVoice", 9 | "short": "Create and manage roles and text channels for VC users. Useful for nomic / microphoneless setups.", 10 | "description": "Create and manage roles and text channels for VC users. Useful for nomic / microphoneless setups.", 11 | "min_bot_version": "3.5.0", 12 | "requirements": [ 13 | "git+https://github.com/zephyrkul/proxyembed" 14 | ], 15 | "tags": [ 16 | "voice", 17 | "utility" 18 | ], 19 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 20 | } 21 | -------------------------------------------------------------------------------- /invoice/invoice.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import builtins 3 | import contextlib 4 | import itertools 5 | import logging 6 | import operator 7 | import re 8 | from datetime import timedelta 9 | from functools import partial 10 | from typing import ( 11 | TYPE_CHECKING, 12 | Any, 13 | Callable, 14 | ChainMap, 15 | DefaultDict, 16 | Dict, 17 | Final, 18 | List, 19 | Mapping, 20 | Optional, 21 | Set, 22 | Tuple, 23 | TypedDict, 24 | TypeVar, 25 | Union, 26 | cast, 27 | ) 28 | 29 | import discord 30 | from proxyembed import ProxyEmbed 31 | from redbot.core import Config, commands 32 | from redbot.core.bot import Red 33 | from redbot.core.utils.antispam import AntiSpam 34 | from redbot.core.utils.chat_formatting import humanize_list 35 | 36 | LOG: Final = logging.getLogger("red.fluffy.invoice") 37 | GuildVoice = Union[discord.VoiceChannel, discord.StageChannel] 38 | GuildVoiceTypes: Final = (discord.VoiceChannel, discord.StageChannel) 39 | 40 | 41 | if TYPE_CHECKING: 42 | AsCFIdentifier = str 43 | else: 44 | 45 | def AsCFIdentifier(argument: str) -> str: 46 | return re.sub(r"\W+|^(?=\d)", "_", argument.casefold()) 47 | 48 | 49 | class Settings(TypedDict): 50 | role: Optional[int] 51 | channel: Optional[int] 52 | dynamic: Optional[bool] 53 | dynamic_name: Optional[str] 54 | mute: Optional[bool] 55 | deaf: Optional[bool] 56 | self_deaf: Optional[bool] 57 | suppress: Optional[bool] 58 | 59 | 60 | class SettingsConverter(commands.FlagConverter, case_insensitive=True, delimiter=" "): 61 | role: Optional[discord.Role] = None 62 | channel: Optional[discord.TextChannel] = None 63 | dynamic: Optional[bool] = None 64 | dynamic_name: Optional[str] = None 65 | mute: Optional[bool] = None 66 | deaf: Optional[bool] = None 67 | self_deaf: Optional[bool] = None 68 | suppress: Optional[bool] = None 69 | 70 | 71 | assert set(SettingsConverter.__annotations__) == set(Settings.__annotations__) 72 | 73 | Cache = DefaultDict[int, Settings] 74 | _KT = TypeVar("_KT") 75 | _VT = TypeVar("_VT") 76 | _T = TypeVar("_T") 77 | 78 | 79 | def _filter_none(d: Mapping[_KT, Optional[_VT]]) -> Dict[_KT, _VT]: 80 | return {k: v for k, v in d.items() if v is not None} 81 | 82 | 83 | def _filter_value(d, filterer: Callable[[Any], bool] = operator.itemgetter(1)) -> dict: 84 | try: 85 | items = d.items() 86 | except AttributeError: 87 | items = d 88 | return dict(filter(filterer, items)) 89 | 90 | 91 | class Chain(ChainMap[str, Any]): 92 | @classmethod 93 | def from_scope( 94 | cls, scope: Union[GuildVoice, discord.CategoryChannel, discord.Guild], cache: Cache 95 | ): 96 | if category_id := getattr(scope, "category_id", None): 97 | assert isinstance(scope, GuildVoiceTypes) and isinstance(category_id, int) 98 | return cls( 99 | _filter_none(cache[scope.id]), 100 | _filter_none(cache[category_id]), 101 | cache[scope.guild.id], 102 | ) 103 | elif guild := getattr(scope, "guild", None): 104 | assert isinstance(guild, discord.Guild) and not isinstance(scope, discord.Guild) 105 | if scope.type == discord.ChannelType.category: 106 | assert isinstance(scope, discord.CategoryChannel) 107 | return cls({}, _filter_none(cache[scope.id]), cache[guild.id]) 108 | else: 109 | assert isinstance(scope, GuildVoiceTypes) 110 | return cls(_filter_none(cache[scope.id]), {}, cache[guild.id]) 111 | else: 112 | assert isinstance(scope, discord.Guild) 113 | return cls(cache[scope.id]) 114 | 115 | def all(self, key, *, map=None): 116 | if not map: 117 | 118 | def map(arg): 119 | return arg 120 | 121 | _map = builtins.map 122 | return list(filter(None, _map(map, (m.get(key) for m in self.maps)))) 123 | 124 | 125 | class InVoice(commands.Cog): 126 | intervals = ( 127 | (timedelta(seconds=5), 3), 128 | (timedelta(minutes=1), 5), 129 | (timedelta(hours=1), 30), 130 | ) 131 | 132 | async def red_get_data_for_user(self, *, user_id): 133 | return {} # No data to get 134 | 135 | async def red_delete_data_for_user(self, *, requester, user_id): 136 | pass # No data to delete 137 | 138 | @staticmethod 139 | def _debug_and_return(message: str, obj: _T) -> _T: 140 | LOG.debug(message, obj) 141 | return obj 142 | 143 | @staticmethod 144 | def _is_afk(voice: discord.VoiceState): 145 | if not voice.channel: 146 | return None 147 | assert isinstance(voice.channel, GuildVoiceTypes) 148 | return voice.channel == voice.channel.guild.afk_channel 149 | 150 | def __init__(self, bot: Red): 151 | self.bot: Final = bot 152 | self.config: Final = Config.get_conf(self, identifier=2113674295, force_registration=True) 153 | self.config.register_guild(**self._defaults()) 154 | self.config.register_channel(**self._defaults()) 155 | self.cache: Final = Cache(self._defaults) 156 | self.guild_as: Final[DefaultDict[int, AntiSpam]] = DefaultDict( 157 | partial(AntiSpam, self.intervals) 158 | ) 159 | self.member_as: Final[DefaultDict[Tuple[int, int], AntiSpam]] = DefaultDict( 160 | partial(AntiSpam, self.intervals) 161 | ) 162 | self.dynamic_ready: Final[Dict[int, asyncio.Event]] = {} 163 | 164 | async def cog_load(self): 165 | self.cache.update(await self.config.all_guilds()) 166 | # Default channels before discord removed them shared their IDs with their guild, 167 | # which would theoretically cause a key conflict here. However, 168 | # default channels are text channels and these are everything but. 169 | self.cache.update(await self.config.all_channels()) 170 | 171 | @staticmethod 172 | def _defaults() -> Settings: 173 | # using __annotations__ directly here since we don't need to eval the annotations 174 | return cast(Settings, dict.fromkeys(Settings.__annotations__.keys(), None)) 175 | 176 | @commands.group() 177 | @commands.guild_only() 178 | @commands.admin_or_permissions(manage_guild=True) 179 | async def invoice(self, ctx: commands.GuildContext): 180 | """ 181 | Configure or view settings for automated voice-based permissions. 182 | """ 183 | 184 | @invoice.command(name="unset", require_var_positional=True) 185 | @commands.guild_only() 186 | @commands.admin_or_permissions(manage_guild=True) 187 | async def _unset( 188 | self, 189 | ctx: commands.GuildContext, 190 | scope: Optional[Union[discord.CategoryChannel, GuildVoice]], 191 | *settings: AsCFIdentifier, 192 | ): 193 | """ 194 | Unset various settings, causing them to fall back to the outer scope. 195 | 196 | `scope` is the voice channel or category that you wish to change; 197 | leave it empty to manage guild-wide settings. 198 | Unset guild-wide settings tell the bot to take no action. 199 | 200 | See `[p]help invoice set` for info on the various settings available. 201 | """ 202 | if invalid := set(settings).difference(Settings.__annotations__.keys()): 203 | raise commands.UserInputError("Invalid settings: " + humanize_list(list(invalid))) 204 | scoped = scope or ctx.guild 205 | if scope: 206 | config = self.config.channel(scope) 207 | else: 208 | config = self.config.guild(ctx.guild) 209 | cache = self.cache[scoped.id] 210 | cache.update(dict.fromkeys(settings, None)) # type: ignore 211 | if any(cache.values()): 212 | async with config.all() as conf: 213 | for k in settings: 214 | conf.pop(k, None) 215 | else: 216 | # no need to keep this around anymore 217 | await config.clear() 218 | await self.show(ctx, scope=scope) 219 | 220 | @invoice.group(name="set", invoke_without_command=True, ignore_extra=False) 221 | @commands.guild_only() 222 | @commands.admin_or_permissions(manage_guild=True) 223 | async def _set( 224 | self, 225 | ctx: commands.GuildContext, 226 | scope: Optional[Union[discord.CategoryChannel, GuildVoice]], 227 | *, 228 | settings: SettingsConverter, 229 | ): 230 | """ 231 | Configure various settings. 232 | 233 | `scope` is the voice channel or category that you wish to change; 234 | leave it empty to manage guild-wide settings. 235 | 236 | __Configurable Settings__ 237 | **role**:\tThe role granted to users inside the scoped VCs. 238 | **channel**:\tThe text channel granted access to while inside the scoped VCs. 239 | **dynamic**:\ttrue/false, create a new role and text channel when new VCs are created here. 240 | The new role will inherit the permissions of higher-scoped roles. 241 | **dynamic_name**:\tThe name to apply to dynamically created roles and text channels. 242 | `{vc}` will be replaced with the name of the new channel. 243 | **mute**:\ttrue/false, mute the user in the text channel if they are server muted. 244 | **suppress**:\ttrue/false, mute the user in the text channel if they don't have permission to speak. 245 | **deaf**:\ttrue/false, remove the user from the text channel if they are server deafened. 246 | **self_deaf**:\ttrue/false, remove the user from the text channel if they are self deafened. 247 | """ 248 | scoped = scope or ctx.guild 249 | config = self.config.channel(scope) if scope else self.config.guild(ctx.guild) 250 | decomposed = {k: getattr(v, "id", v) for k, v in settings if v is not None} 251 | self.cache[scoped.id].update(decomposed) # type: ignore 252 | async with config.all() as conf: 253 | assert isinstance(conf, dict) 254 | conf.update(decomposed) 255 | await self.show(ctx, scope=scope) 256 | 257 | @_set.command(aliases=["showsettings"]) 258 | @commands.guild_only() 259 | async def show( 260 | self, 261 | ctx: commands.GuildContext, 262 | *, 263 | scope: Union[discord.CategoryChannel, GuildVoice] = None, 264 | ): 265 | """ 266 | Show the current settings for the specified scope. 267 | 268 | See `[p]help invoice set` for explanations of the various settings. 269 | """ 270 | guild = ctx.guild 271 | scoped = scope or guild 272 | chain = Chain.from_scope(scoped, self.cache) 273 | embed = ProxyEmbed(title=f"Current settings for {scoped}", color=await ctx.embed_color()) 274 | for key, value in chain.items(): 275 | if value and key == "role": 276 | value = guild.get_role(value) or "" 277 | elif value and key == "channel": 278 | value = guild.get_channel(value) or "" 279 | elif not value and key == "dynamic_name": 280 | value = "\N{SPEAKER WITH THREE SOUND WAVES} {vc}" 281 | else: 282 | value = value or False 283 | embed.add_field( 284 | name=key.replace("_", " ").title(), value=getattr(value, "mention", str(value)) 285 | ) 286 | embed.set_footer( 287 | text="Settings shown here reflect the effective settings for the scope," 288 | " including inherited settings from the category or guild." 289 | ) 290 | await embed.send_to(ctx, allowed_mentions=discord.AllowedMentions(users=False)) 291 | 292 | @commands.Cog.listener() 293 | async def on_guild_channel_create(self, vc: discord.abc.GuildChannel): 294 | # TODO: split this code into smaller functions 295 | if not isinstance(vc, GuildVoiceTypes): 296 | return 297 | guild = vc.guild 298 | me = guild.me 299 | my_perms = me.guild_permissions 300 | # manage_roles & manage_channels & read_messages & send_messages 301 | if my_perms.value & 0x10000C10 != 0x10000C10: 302 | return 303 | if self.guild_as[guild.id].spammy: 304 | return 305 | if await self.bot.cog_disabled_in_guild(self, guild): 306 | return 307 | chain = Chain.from_scope(vc, self.cache) 308 | if not chain["dynamic"]: 309 | return 310 | self.dynamic_ready[vc.id] = asyncio.Event() 311 | try: 312 | if dynamic_name := chain["dynamic_name"]: 313 | name = re.sub(r"(?i){vc}", vc.name, dynamic_name) 314 | else: 315 | name = "\N{SPEAKER WITH THREE SOUND WAVES} " + vc.name 316 | scoped_roles: List[discord.Role] = chain.all("role", map=guild.get_role) 317 | if scoped_roles: 318 | perms = scoped_roles[0].permissions 319 | else: 320 | perms = discord.Permissions.none() 321 | perms.value &= my_perms.value 322 | dynamic_role = await guild.create_role( 323 | name=name, 324 | permissions=perms, 325 | reason="Dynamic role for {vc}".format(vc=vc), 326 | ) 327 | await self.config.channel(vc).role.set(dynamic_role.id) 328 | self.cache[vc.id]["role"] = dynamic_role.id 329 | # assume my_perms doesn't have Manage Roles for channel creation if not admin 330 | # because: https://discord.com/developers/docs/resources/guild#create-guild-channel 331 | my_perms.manage_roles = my_perms.administrator 332 | # also if your bot actually has admin... why... 333 | if vc.category: 334 | overs = vc.category.overwrites 335 | else: 336 | overs = {} 337 | # inherit scoped roles and remove their permissions 338 | allow, deny = discord.Permissions(read_messages=True), discord.Permissions.none() 339 | for role in scoped_roles: 340 | try: 341 | o_allow, o_deny = overs.pop(role).pair() 342 | except KeyError: 343 | pass 344 | else: 345 | deny.value |= o_deny.value 346 | allow.value |= o_allow.value 347 | # ensure default can be applied by the bot on creation 348 | allow.value &= my_perms.value 349 | deny.value &= my_perms.value 350 | default = discord.PermissionOverwrite.from_pair(allow, deny) 351 | # now assume we don't have read_messages 352 | # makes the following code simpler 353 | my_perms.read_messages = False 354 | # prevent any other roles from having read_messages, 355 | # and also ensure that the overwrites can be applied on creation 356 | for k, overwrite in overs.copy().items(): 357 | o_allow, o_deny = overwrite.pair() 358 | o_allow.value &= my_perms.value 359 | o_deny.value &= my_perms.value 360 | overwrite = discord.PermissionOverwrite.from_pair(o_allow, o_deny) 361 | if overwrite.is_empty(): 362 | del overs[k] 363 | else: 364 | overs[k] = overwrite 365 | # now apply the vc-specific role 366 | overs[dynamic_role] = default 367 | # let admins and mods see the channel 368 | for role in itertools.chain( 369 | await self.bot.get_admin_roles(guild), await self.bot.get_mod_roles(guild) 370 | ): 371 | overs.setdefault(role, discord.PermissionOverwrite()).update(read_messages=True) 372 | # deny read permissions from @everyone 373 | overs.setdefault(guild.default_role, discord.PermissionOverwrite()).update( 374 | read_messages=False 375 | ) 376 | # add bot to the channel 377 | key: Union[discord.Member, discord.Role] = me 378 | for role in me.roles: 379 | if role.tags and role.tags.bot_id == me.id: 380 | key = role 381 | break 382 | overs.setdefault(key, discord.PermissionOverwrite()).update( 383 | read_messages=True, manage_channels=True 384 | ) 385 | text = await guild.create_text_channel( 386 | name=name, 387 | overwrites=overs, 388 | category=vc.category, 389 | reason="Dynamic channel for {vc}".format(vc=vc), 390 | ) 391 | await self.config.channel(vc).channel.set(text.id) 392 | self.cache[vc.id]["channel"] = text.id 393 | finally: 394 | self.guild_as[guild.id].stamp() 395 | self.dynamic_ready[vc.id].set() 396 | 397 | @commands.Cog.listener() 398 | async def on_guild_channel_delete(self, vc): 399 | if not isinstance(vc, GuildVoiceTypes): 400 | return 401 | guild = vc.guild 402 | await self.config.channel(vc).clear() 403 | chain = Chain.from_scope(vc, self.cache) 404 | try: 405 | settings = self.cache.pop(vc.id) 406 | except KeyError: 407 | return 408 | if not chain["dynamic"]: 409 | return 410 | role = guild.get_role(settings["role"]) 411 | if role: 412 | await role.delete(reason=f"Dynamic role for {vc}") 413 | channel = guild.get_channel(settings["channel"]) 414 | if channel: 415 | await channel.delete(reason=f"Dynamic channel for {vc}") 416 | 417 | @commands.Cog.listener() 418 | async def on_voice_state_update( 419 | self, m: discord.Member, b: discord.VoiceState, a: discord.VoiceState 420 | ) -> None: 421 | if m.bot: 422 | return 423 | if not b.channel and not a.channel: 424 | return # I doubt this could happen, but just in case 425 | if await self.bot.cog_disabled_in_guild(self, m.guild): 426 | return 427 | LOG.debug("on_voice_state_update(%s, %s, %s)", m, b, a) 428 | role_set: Set[int] = set(m._roles) 429 | channel_updates: Dict[int, Optional[discord.PermissionOverwrite]] = {} 430 | if b.channel != a.channel and b.channel: 431 | self._remove_before(b, role_set, channel_updates) 432 | 433 | if self._is_afk(a) is False and not self.member_as[(m.guild.id, m.id)].spammy: 434 | assert isinstance(a.channel, GuildVoiceTypes) 435 | try: 436 | await self.dynamic_ready[a.channel.id].wait() 437 | except KeyError: 438 | pass 439 | self._add_after(a, role_set, channel_updates) 440 | 441 | # This event gets triggered when a member leaves the server, 442 | # but before the on_member_leave event updates the cache. 443 | # So, I suppress the exception to save Dav's logs. 444 | with contextlib.suppress(discord.NotFound): 445 | await self.apply_permissions(m, role_set, channel_updates) 446 | 447 | def _remove_before( 448 | self, 449 | b: discord.VoiceState, 450 | role_set: Set[int], 451 | channel_updates: Dict[int, Optional[discord.PermissionOverwrite]], 452 | ): 453 | assert isinstance(b.channel, GuildVoiceTypes) 454 | chain = Chain.from_scope(b.channel, self.cache) 455 | role_set.difference_update( 456 | self._debug_and_return("maybe removing role IDs: %s", chain.all("role")) 457 | ) 458 | channel_id: int 459 | for channel_id in filter( 460 | None, 461 | self._debug_and_return("maybe removing channel overwrites: %s", chain.all("channel")), 462 | ): 463 | channel_updates[channel_id] = None 464 | 465 | def _add_after( 466 | self, 467 | a: discord.VoiceState, 468 | role_set: Set[int], 469 | channel_updates: Dict[int, Optional[discord.PermissionOverwrite]], 470 | ): 471 | assert isinstance(a.channel, GuildVoiceTypes) 472 | guild = a.channel.guild 473 | chain = Chain.from_scope(a.channel, self.cache) 474 | role_id: int = next(filter(guild.get_role, chain.all("role")), 0) 475 | channel_id: int = next(filter(guild.get_channel, chain.all("channel")), 0) 476 | if role_id: 477 | LOG.debug("Pre-emptively adding role: %s", role_id) 478 | role_set.add(role_id) 479 | overwrites = discord.PermissionOverwrite() 480 | elif channel_id: 481 | LOG.debug("Pre-emptively adding read_messages: %s", channel_id) 482 | overwrites = discord.PermissionOverwrite(read_messages=True) 483 | else: 484 | # nothing to do 485 | return 486 | mute: bool = a.mute and chain["mute"] 487 | deaf: bool = a.deaf and chain["deaf"] 488 | self_deaf: bool = a.self_deaf and chain["self_deaf"] 489 | suppress: bool = a.suppress and chain["suppress"] 490 | LOG.debug( 491 | "mute: %s, suppress: %s, deaf: %s, self_deaf: %s", mute, suppress, deaf, self_deaf 492 | ) 493 | if mute or suppress: 494 | LOG.debug("muted or suppressed") 495 | if channel_id: 496 | overwrites.update(send_messages=False, add_reactions=False) 497 | else: 498 | role_set.discard(role_id) 499 | if deaf or self_deaf: 500 | if role_id: 501 | role_set.discard(role_id) 502 | else: 503 | overwrites.update(read_messages=False) 504 | if LOG.isEnabledFor(logging.DEBUG): 505 | LOG.debug( 506 | "role: %s; overwrites: allow %s, deny %s", 507 | role_id in role_set, 508 | *map(_filter_value, overwrites.pair()), 509 | ) 510 | channel_updates[channel_id] = None if overwrites.is_empty() else overwrites 511 | 512 | async def apply_permissions( 513 | self, 514 | m: discord.Member, 515 | role_set: Set[int], 516 | channel_updates: Dict[int, Optional[discord.PermissionOverwrite]], 517 | ) -> None: 518 | guild = m.guild 519 | stamp = False 520 | role_set.discard(guild.id) 521 | if role_set.symmetric_difference(m._roles): 522 | try: 523 | await m.edit(roles=[discord.Object(id) for id in role_set]) 524 | except discord.Forbidden: 525 | LOG.warning("Unable to edit roles for %s in guild %s", m, guild) 526 | LOG.debug("Before: %s\nAfter: %s", m._roles, role_set) 527 | stamp = True 528 | for channel_id, overs in channel_updates.items(): 529 | if (channel := guild.get_channel(channel_id)) and channel.overwrites.get(m) != overs: 530 | try: 531 | await channel.set_permissions(m, overwrite=overs) 532 | except discord.Forbidden: 533 | LOG.warning("Unable to edit channel permissions for %s in guild %s", m, guild) 534 | stamp = True 535 | if stamp: 536 | self.member_as[(m.guild.id, m.id)].stamp() 537 | -------------------------------------------------------------------------------- /logsfrom/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .logsfrom import LogsFrom 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(LogsFrom()) 11 | -------------------------------------------------------------------------------- /logsfrom/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help logsfrom`", 6 | "name": "LogsFrom", 7 | "short": "Log a specified channel to a markdown file.", 8 | "description": "Log a specified channel to a markdown file.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "logging", 12 | "utility" 13 | ], 14 | "min_python_version": [ 15 | 3, 16 | 7, 17 | 0 18 | ], 19 | "end_user_data_statement": "This cog enables logging messages for bot owners and admins. The bot itself does not persistently store this data." 20 | } 21 | -------------------------------------------------------------------------------- /logsfrom/logsfrom.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import io 4 | from copy import copy 5 | from dataclasses import dataclass 6 | from datetime import datetime 7 | from typing import Optional 8 | 9 | import discord 10 | from redbot.core import commands 11 | from redbot.core.i18n import Translator, cog_i18n 12 | from redbot.core.utils.mod import check_permissions 13 | from redbot.core.utils.predicates import MessagePredicate 14 | 15 | _T = Translator("LogsFrom", __file__) 16 | 17 | 18 | @dataclass 19 | class MHeaders: 20 | author: discord.User 21 | created: datetime 22 | edited: Optional[datetime] 23 | 24 | def to_str(self, other: "MHeaders") -> str: 25 | final = [] 26 | if self.author != other.author: 27 | if other.author: 28 | final.append("") 29 | auth = self.author.display_name 30 | if self.author.bot: 31 | auth += " [BOT]" 32 | final.append(auth) 33 | if self.edited: 34 | if self.edited.date() == self.created.date(): 35 | ed = ", edited {:%X}".format(self.edited.time()) 36 | else: 37 | ed = ", edited {:%c}".format(self.edited) 38 | else: 39 | ed = "" 40 | if other.created and self.created.date() == other.created.date(): 41 | final.append("[{:%X}{}] ".format(self.created.time(), ed)) 42 | else: 43 | final.append("[{:%c}{}] ".format(self.created, ed)) 44 | return "\n".join(final) 45 | 46 | 47 | async def history(channel, **kwargs): 48 | d = collections.deque() 49 | async for message in channel.history(**kwargs): 50 | d.append(message) 51 | return d 52 | 53 | 54 | # The below effectively makes for an owner-only command in guilds, 55 | # but one that can be overwritten with permissions 56 | def logsfrom_check(): 57 | @commands.permissions_check 58 | def predicate(ctx): 59 | return ctx.guild is None 60 | 61 | return predicate 62 | 63 | 64 | @cog_i18n(_T) 65 | class LogsFrom(commands.Cog): 66 | async def red_get_data_for_user(self, *, user_id): 67 | return {} # No data to get 68 | 69 | async def red_delete_data_for_user(self, *, requester, user_id): 70 | pass # No data to delete 71 | 72 | @logsfrom_check() 73 | @commands.command(usage="[bounds...] [channel]") 74 | async def logsfrom( 75 | self, 76 | ctx, 77 | after: Optional[discord.PartialMessage] = None, 78 | before: Optional[discord.PartialMessage] = None, 79 | *, 80 | channel: discord.TextChannel = None, 81 | ): 82 | """ 83 | Logs the specified channel into a file, then uploads the file. 84 | 85 | The channel will default to the current channel if none is specified. 86 | The limit may be the number of messages to log or the ID of the message to start after, exclusive. 87 | All timestamps are in UTC. 88 | """ 89 | if channel: 90 | ctxc = copy(ctx) 91 | ctxc.channel = channel 92 | else: 93 | channel = ctx.channel 94 | ctxc = ctx 95 | if not channel.permissions_for(ctx.me).read_message_history: 96 | raise commands.BotMissingPermissions(discord.Permissions(read_message_history=True)) 97 | if not await check_permissions(ctxc, {"read_message_history": True}): 98 | raise commands.MissingPermissions(["read_message_history"]) 99 | after, before = getattr(after, "id", after), getattr(before, "id", before) 100 | cancel_task = asyncio.ensure_future( 101 | ctx.bot.wait_for("message", check=MessagePredicate.cancelled(ctx)) 102 | ) 103 | async with ctx.typing(): 104 | kwargs = {"oldest_first": False} 105 | if not after and not before: 106 | kwargs["limit"] = 100 107 | elif not before: 108 | kwargs.update(after=discord.Object(id=after), limit=after) 109 | elif not after: 110 | raise RuntimeError("This should never happen.") 111 | else: 112 | before = min((ctx.message.id, before)) 113 | # TODO: wtf should this shit even *mean* 114 | if after >= before: 115 | kwargs.update(after=discord.Object(id=after), limit=before, oldest_first=True) 116 | else: 117 | kwargs.update( 118 | after=discord.Object(id=after), 119 | before=discord.Object(id=before), 120 | limit=min((before, after)), 121 | ) 122 | print(kwargs) 123 | stream = io.BytesIO() 124 | last_h = MHeaders(None, None, None) 125 | message_task = asyncio.ensure_future(history(channel, **kwargs)) 126 | done, _ = await asyncio.wait( 127 | (cancel_task, message_task), return_when=asyncio.FIRST_COMPLETED 128 | ) 129 | if cancel_task in done: 130 | message_task.cancel() 131 | return await ctx.send(_T("Okay, I've cancelled my logging.")) 132 | messages = message_task.result() 133 | processed = 0 134 | pop = messages.popleft if kwargs["oldest_first"] else messages.pop 135 | while messages: 136 | await asyncio.sleep(0) 137 | if cancel_task.done(): 138 | return await ctx.send(_T("Okay, I've cancelled my logging.")) 139 | message = pop() 140 | now_h = MHeaders(message.author, message.created_at, message.edited_at) 141 | headers = now_h.to_str(last_h) 142 | last_h = now_h 143 | if headers: 144 | stream.write(headers.encode("utf-8")) 145 | stream.write(message.clean_content.encode("utf-8")) 146 | if message.attachments: 147 | stream.write(b"\n") 148 | stream.write( 149 | "; ".join(f"[{a.filename}]({a.url})" for a in message.attachments).encode( 150 | "utf-8" 151 | ) 152 | ) 153 | stream.write(b"\n") 154 | processed += 1 155 | cancel_task.cancel() 156 | stream.seek(0) 157 | return await ctx.send( 158 | content=_T("{} message{s} logged.").format( 159 | processed, s=("" if processed == 1 else "s") 160 | ), 161 | file=discord.File(stream, filename=f"{channel.name}.md"), 162 | delete_after=300, 163 | ) 164 | -------------------------------------------------------------------------------- /nationstates/__init__.py: -------------------------------------------------------------------------------- 1 | from packaging import version 2 | from redbot.core.bot import Red 3 | from redbot.core.errors import CogLoadError 4 | from redbot.core.utils import get_end_user_data_statement_or_raise 5 | 6 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 7 | 8 | try: 9 | import sans 10 | except ImportError as e: 11 | import_failed = e 12 | else: 13 | import_failed = None 14 | from .nationstates import NationStates 15 | 16 | 17 | async def setup(bot: Red): 18 | if import_failed or version.parse(sans.__version__) < version.parse("1.2.0"): 19 | raise CogLoadError( 20 | "The sans library is out of date or not installed.\n" 21 | "Run this command to update it: [p]pipinstall sans\n" 22 | "You may have to [p]restart your bot to have the new version take effect." 23 | ) from import_failed 24 | await bot.add_cog(NationStates(bot)) 25 | -------------------------------------------------------------------------------- /nationstates/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help NationStates`.", 6 | "name": "NationStates", 7 | "short": "Access information from NationStates.net.", 8 | "min_bot_version": "3.5.0", 9 | "requirements": [ 10 | "sans>=1.2.0,<2.0.0", 11 | "git+https://github.com/zephyrkul/proxyembed" 12 | ], 13 | "description": "Access information from NationStates.net.", 14 | "tags": [ 15 | "utility" 16 | ], 17 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 18 | } 19 | -------------------------------------------------------------------------------- /onedit/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .onedit import OnEdit 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(OnEdit(bot)) 11 | -------------------------------------------------------------------------------- /onedit/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help OnEdit`.", 6 | "name": "OnEdit", 7 | "short": "Allow users to run bot commands with edited messages, with a specified timeout.", 8 | "description": "Allow users to run bot commands with edited messages, with a specified timeout.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "utility" 12 | ], 13 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 14 | } 15 | -------------------------------------------------------------------------------- /onedit/onedit.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | import discord 5 | from redbot.core import Config, checks, commands, i18n 6 | from redbot.core.bot import Red 7 | 8 | 9 | class OnEdit(commands.Cog): 10 | async def red_get_data_for_user(self, *, user_id): 11 | return {} # No data to get 12 | 13 | async def red_delete_data_for_user(self, *, requester, user_id): 14 | pass # No data to delete 15 | 16 | def __init__(self, bot: Red): 17 | self.bot = bot 18 | self.config = Config.get_conf(self, identifier=2_113_674_295, force_registration=True) 19 | self.config.register_global(timeout=5) 20 | self.timeout: Optional[float] = None 21 | 22 | async def edit_process_commands(self, before: discord.Message, after: discord.Message): 23 | """Same as Red's method (Red.process_commands), but dont dispatch message_without_command.""" 24 | ctx: Optional[commands.Context] 25 | if not after.author.bot: 26 | ctx = await self.bot.get_context(after) 27 | assert ctx 28 | await self.bot.invoke(ctx) 29 | if ctx.valid is False: 30 | # My Act and Phen's Tags use on_command_error, and thus aren't needed in this list. 31 | for allowed_name in ("Alias", "CustomCommands", "CCRoles"): 32 | cog = self.bot.get_cog(allowed_name) 33 | if not cog: 34 | continue 35 | for name, listener in cog.get_listeners(): 36 | if name != "on_message_without_command": 37 | continue 38 | asyncio.ensure_future(listener(after)) # noqa: RUF006 39 | else: 40 | ctx = None 41 | if ctx is None or ctx.valid is False: 42 | self.bot.dispatch("message_edit_without_command", before, after) 43 | 44 | @commands.command() 45 | @checks.is_owner() 46 | async def edittime(self, ctx: commands.Context, *, timeout: float): 47 | """ 48 | Change how long the bot will listen for message edits to invoke as commands. 49 | 50 | Defaults to 5 seconds. 51 | Set to 0 to disable. 52 | """ 53 | timeout = max(timeout, 0) 54 | await self.config.timeout.set(timeout) 55 | self.timeout = timeout 56 | await ctx.tick() 57 | 58 | @commands.Cog.listener() 59 | async def on_message_edit(self, before: discord.Message, after: discord.Message): 60 | if not after.edited_at: 61 | return 62 | if before.content == after.content: 63 | return 64 | if await self.bot.cog_disabled_in_guild(self, after.guild): 65 | return 66 | if self.timeout is None: 67 | self.timeout = await self.config.timeout() 68 | if (after.edited_at - after.created_at).total_seconds() > self.timeout: 69 | return 70 | await i18n.set_contextual_locales_from_guild(self.bot, after.guild) 71 | await self.edit_process_commands(before, after) 72 | -------------------------------------------------------------------------------- /onetrueslash/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from redbot.core import app_commands 5 | from redbot.core.bot import Red 6 | from redbot.core.errors import CogLoadError 7 | from redbot.core.utils import get_end_user_data_statement_or_raise 8 | 9 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 10 | 11 | from .commands import onetrueslash 12 | from .events import before_hook, on_user_update 13 | from .utils import valid_app_name 14 | 15 | LOG = logging.getLogger("red.fluffy.onetrueslash") 16 | 17 | 18 | async def setup(bot: Red) -> None: 19 | bot.before_invoke(before_hook) 20 | bot.add_listener(on_user_update) 21 | bot.add_dev_env_value("interaction", lambda ctx: getattr(ctx, "interaction", None)) 22 | asyncio.create_task(_setup(bot)) # noqa: RUF006 23 | 24 | 25 | async def _setup(bot: Red): 26 | await bot.wait_until_red_ready() 27 | assert bot.user 28 | try: 29 | onetrueslash.name = valid_app_name(bot.user.name) 30 | bot.tree.add_command(onetrueslash, guild=None) 31 | except ValueError: 32 | await bot.send_to_owners( 33 | f"`onetrueslash` was unable to make the name {bot.user.name!r} " 34 | "into a valid slash command name. The command name was left unchanged." 35 | ) 36 | except app_commands.CommandAlreadyRegistered: 37 | raise CogLoadError( 38 | f"A slash command named {onetrueslash.name} is already registered." 39 | ) from None 40 | except app_commands.CommandLimitReached: 41 | raise CogLoadError( 42 | f"{bot.user.name} has already reached the maximum of 100 global slash commands." 43 | ) from None 44 | 45 | 46 | async def teardown(bot: Red): 47 | bot.remove_before_invoke_hook(before_hook) 48 | bot.remove_dev_env_value("interaction") 49 | -------------------------------------------------------------------------------- /onetrueslash/channel.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Union, cast 2 | 3 | import discord 4 | 5 | from .utils import Thinking, contexts 6 | 7 | if TYPE_CHECKING: 8 | from discord.context_managers import Typing 9 | 10 | Base = discord.abc.Messageable 11 | else: 12 | Base = object 13 | 14 | 15 | class InterChannel(Base): 16 | __slots__ = () 17 | 18 | def permissions_for( 19 | self, obj: Union[discord.abc.User, discord.Role], / 20 | ) -> discord.Permissions: 21 | try: 22 | ctx = contexts.get() 23 | except LookupError: 24 | pass 25 | else: 26 | interaction = ctx._interaction 27 | bot_user = cast(discord.ClientUser, ctx.bot.user) 28 | if obj.id == interaction.user.id: 29 | return ctx.permissions 30 | elif obj.id == bot_user.id: 31 | return ctx.bot_permissions 32 | return super().permissions_for(obj) # type: ignore 33 | 34 | def send(self, *args, **kwargs): 35 | return contexts.get(super()).send(*args, **kwargs) 36 | 37 | def typing(self) -> Union[Thinking, "Typing"]: 38 | try: 39 | ctx = contexts.get() 40 | except LookupError: 41 | return super().typing() 42 | else: 43 | return Thinking(ctx) 44 | -------------------------------------------------------------------------------- /onetrueslash/commands.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import heapq 4 | import operator 5 | from copy import copy 6 | from typing import Awaitable, Callable, Dict, List, Optional, Tuple, cast 7 | 8 | import discord 9 | from rapidfuzz import fuzz 10 | from redbot.core import app_commands, commands 11 | from redbot.core.bot import Red 12 | from redbot.core.commands.help import HelpSettings 13 | from redbot.core.i18n import set_contextual_locale 14 | 15 | from .context import InterContext 16 | from .utils import walk_aliases 17 | 18 | 19 | @app_commands.command(extras={"red_force_enable": True}) 20 | async def onetrueslash( 21 | interaction: discord.Interaction, 22 | command: str, 23 | arguments: Optional[str] = None, 24 | attachment: Optional[discord.Attachment] = None, 25 | ) -> None: 26 | """ 27 | The one true slash command. 28 | 29 | Parameters 30 | ----------- 31 | command: str 32 | The text-based command to run. 33 | arguments: Optional[str] 34 | The arguments to provide to the command, if any. 35 | attachment: Optional[Attachment] 36 | The attached file to provide to the command, if any. 37 | """ 38 | assert isinstance(interaction.client, Red) 39 | set_contextual_locale(str(interaction.guild_locale or interaction.locale)) 40 | actual = interaction.client.get_command(command) 41 | ctx = await InterContext.from_interaction(interaction, recreate_message=True) 42 | error = None 43 | if command == "help": 44 | ctx._deferring = True 45 | # Moving ctx._interaction can cause check errors with some hybrid commands 46 | # see https://github.com/Zephyrkul/FluffyCogs/issues/75 for details 47 | # ctx.interaction = interaction 48 | await interaction.response.defer(ephemeral=True) 49 | actual = None 50 | if arguments: 51 | actual = interaction.client.get_command(arguments) 52 | if actual and (signature := actual.signature): 53 | actual = copy(actual) 54 | actual.usage = f"arguments:{signature}" 55 | await interaction.client.send_help_for( 56 | ctx, actual or interaction.client, from_help_command=True 57 | ) 58 | else: 59 | ferror: asyncio.Task[Tuple[InterContext, commands.CommandError]] = asyncio.create_task( 60 | interaction.client.wait_for("command_error", check=lambda c, _: c is ctx) 61 | ) 62 | ferror.add_done_callback(lambda _: setattr(ctx, "interaction", interaction)) 63 | await interaction.client.invoke(ctx) 64 | if not interaction.response.is_done(): 65 | ctx._deferring = True 66 | await interaction.response.defer(ephemeral=True) 67 | if ferror.done(): 68 | error = ferror.exception() or ferror.result()[1] 69 | ferror.cancel() 70 | if ctx._deferring and not interaction.is_expired(): 71 | if error is None: 72 | if ctx._ticked: 73 | await interaction.followup.send(ctx._ticked, ephemeral=True) 74 | else: 75 | await interaction.delete_original_response() 76 | elif isinstance(error, commands.CommandNotFound): 77 | await interaction.followup.send( 78 | f"❌ Command `{command}` was not found.", ephemeral=True 79 | ) 80 | elif isinstance(error, commands.CheckFailure): 81 | await interaction.followup.send( 82 | f"❌ You don't have permission to run `{command}`.", ephemeral=True 83 | ) 84 | 85 | 86 | @onetrueslash.autocomplete("command") 87 | async def onetrueslash_command_autocomplete( 88 | interaction: discord.Interaction, current: str 89 | ) -> List[app_commands.Choice[str]]: 90 | assert isinstance(interaction.client, Red) 91 | 92 | if not await interaction.client.allowed_by_whitelist_blacklist(interaction.user): 93 | return [] 94 | 95 | ctx = await InterContext.from_interaction(interaction) 96 | if not await interaction.client.message_eligible_as_command(ctx.message): 97 | return [] 98 | 99 | help_settings = await HelpSettings.from_context(ctx) 100 | if current: 101 | extracted = cast( 102 | List[str], 103 | await asyncio.get_event_loop().run_in_executor( 104 | None, 105 | heapq.nlargest, 106 | 6, 107 | walk_aliases(interaction.client, show_hidden=help_settings.show_hidden), 108 | functools.partial(fuzz.token_sort_ratio, current), 109 | ), 110 | ) 111 | extracted.append("help") 112 | else: 113 | extracted = ["help"] 114 | _filter: Callable[[commands.Command], Awaitable[bool]] = operator.methodcaller( 115 | "can_run" if help_settings.show_hidden else "can_see", ctx 116 | ) 117 | matches: Dict[commands.Command, str] = {} 118 | for name in extracted: 119 | command = interaction.client.get_command(name) 120 | if not command or command in matches: 121 | continue 122 | try: 123 | if name == "help" and await command.can_run(ctx) or await _filter(command): 124 | if len(name) > 100: 125 | name = name[:99] + "\N{HORIZONTAL ELLIPSIS}" 126 | matches[command] = name 127 | except commands.CommandError: 128 | pass 129 | return [app_commands.Choice(name=name, value=name) for name in matches.values()] 130 | 131 | 132 | @onetrueslash.error 133 | async def onetrueslash_error(interaction: discord.Interaction, error: Exception): 134 | assert isinstance(interaction.client, Red) 135 | if isinstance(error, app_commands.CommandInvokeError): 136 | error = error.original 137 | error = getattr(error, "original", error) 138 | await interaction.client.on_command_error( 139 | await InterContext.from_interaction(interaction, recreate_message=True), 140 | commands.CommandInvokeError(error), 141 | ) 142 | -------------------------------------------------------------------------------- /onetrueslash/context.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | from copy import copy 4 | from typing import Optional, Type, Union 5 | 6 | import discord 7 | from discord.ext.commands.view import StringView 8 | from redbot.core import commands 9 | from redbot.core.bot import Red 10 | 11 | from .message import InterMessage 12 | from .utils import Thinking, contexts 13 | 14 | INCOMPATABLE_PARAMETERS_DISCARD = tuple( 15 | k 16 | for k in inspect.signature(discord.abc.Messageable.send).parameters 17 | if k not in inspect.signature(discord.Webhook.send).parameters 18 | ) 19 | 20 | 21 | class InterContext(commands.Context): 22 | _deferring: bool = False 23 | _ticked: Optional[str] = None 24 | _interaction: discord.Interaction[Red] 25 | message: InterMessage 26 | 27 | @classmethod 28 | def _get_type(cls, bot: Red) -> Type["InterContext"]: 29 | default = bot.get_context.__kwdefaults__.get("cls", None) 30 | if not isinstance(default, type) or default in cls.__mro__: 31 | return cls 32 | try: 33 | return types.new_class(cls.__name__, (cls, default)) 34 | except Exception: 35 | return cls 36 | 37 | @classmethod 38 | async def from_interaction( 39 | cls: Type["InterContext"], 40 | interaction: discord.Interaction[Red], 41 | *, 42 | recreate_message: bool = False, 43 | ) -> "InterContext": 44 | prefix = f" command:" 45 | try: 46 | self = contexts.get() 47 | if recreate_message: 48 | assert self.prefix is not None 49 | self.message._recreate_from_interaction(interaction, prefix) 50 | view = self.view = StringView(self.message.content) 51 | view.skip_string(self.prefix) 52 | invoker = view.get_word() 53 | self.invoked_with = invoker 54 | self.command = interaction.client.all_commands.get(invoker) 55 | return self 56 | except LookupError: 57 | pass 58 | message = InterMessage._from_interaction(interaction, prefix) 59 | view = StringView(message.content) 60 | view.skip_string(prefix) 61 | invoker = view.get_word() 62 | self = cls._get_type(interaction.client)( 63 | message=message, 64 | prefix=prefix, 65 | bot=interaction.client, 66 | view=view, 67 | invoked_with=invoker, 68 | command=interaction.client.all_commands.get(invoker), 69 | ) 70 | # don't set self.interaction so make d.py parses commands the old way 71 | self._interaction = interaction 72 | interaction._baton = self 73 | contexts.set(self) 74 | return self 75 | 76 | @property 77 | def clean_prefix(self) -> str: 78 | return f"/{self._interaction.data['name']} command:" 79 | 80 | async def tick(self, *, message: Optional[str] = None) -> bool: 81 | return await super().tick(message="Done." if message is None else message) 82 | 83 | async def react_quietly( 84 | self, 85 | reaction: Union[discord.Emoji, discord.Reaction, discord.PartialEmoji, str], 86 | *, 87 | message: Optional[str] = None, 88 | ) -> bool: 89 | self._ticked = f"{reaction} {message}" if message else str(reaction) 90 | return False 91 | 92 | async def send(self, *args, **kwargs): 93 | interaction = self._interaction 94 | if interaction.is_expired(): 95 | assert interaction.channel 96 | return await interaction.channel.send(*args, **kwargs) # type: ignore 97 | await self.typing(ephemeral=True) 98 | self._deferring = False 99 | delete_after = kwargs.pop("delete_after", None) 100 | for key in INCOMPATABLE_PARAMETERS_DISCARD: 101 | kwargs.pop(key, None) 102 | m = await interaction.followup.send(*args, **kwargs) 103 | if delete_after: 104 | await m.delete(delay=delete_after) 105 | return m 106 | 107 | def typing(self, *, ephemeral: bool = False) -> Thinking: 108 | return Thinking(self, ephemeral=ephemeral) 109 | 110 | async def defer(self, *, ephemeral: bool = False) -> None: 111 | await self._interaction.response.defer(ephemeral=ephemeral) 112 | 113 | async def send_help( 114 | self, command: Optional[Union[commands.Command, commands.GroupMixin, str]] = None 115 | ): 116 | command = command or self.command 117 | if isinstance(command, str): 118 | command = self.bot.get_command(command) or command 119 | signature: str 120 | if signature := getattr(command, "signature", ""): 121 | assert not isinstance(command, str) 122 | command = copy(command) 123 | command.usage = f"arguments:{signature}" 124 | return await super().send_help(command) 125 | 126 | def _apply_implicit_permissions( 127 | self, user: discord.abc.User, base: discord.Permissions 128 | ) -> discord.Permissions: 129 | if base.administrator or (self.guild and self.guild.owner_id == user.id): 130 | return discord.Permissions.all() 131 | 132 | base = copy(base) 133 | if not base.send_messages: 134 | base.send_tts_messages = False 135 | base.mention_everyone = False 136 | base.embed_links = False 137 | base.attach_files = False 138 | 139 | if not base.read_messages: 140 | base &= ~discord.Permissions.all_channel() 141 | 142 | channel_type = self.channel.type 143 | if channel_type in (discord.ChannelType.voice, discord.ChannelType.stage_voice): 144 | if not base.connect: 145 | denied = discord.Permissions.voice() 146 | denied.update(manage_channels=True, manage_roles=True) 147 | base &= ~denied 148 | else: 149 | base &= ~discord.Permissions.voice() 150 | 151 | return base 152 | 153 | @discord.utils.cached_property 154 | def permissions(self): 155 | if self._interaction._permissions == 0: 156 | return discord.Permissions._dm_permissions() # type: ignore 157 | return self._apply_implicit_permissions(self.author, self._interaction.permissions) 158 | 159 | @discord.utils.cached_property 160 | def bot_permissions(self): 161 | return self._apply_implicit_permissions( 162 | self.me, self._interaction.app_permissions 163 | ) | discord.Permissions(send_messages=True, attach_files=True, embed_links=True) 164 | -------------------------------------------------------------------------------- /onetrueslash/events.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import discord 4 | from redbot.core import commands as red_commands 5 | from redbot.core.bot import Red 6 | 7 | from .commands import onetrueslash 8 | from .utils import valid_app_name 9 | 10 | 11 | async def before_hook(ctx: red_commands.Context): 12 | interaction: Optional[discord.Interaction] = getattr(ctx, "_interaction", None) 13 | if not interaction or getattr(ctx.command, "__commands_is_hybrid__", False): 14 | return 15 | ctx.interaction = interaction 16 | if not interaction.response.is_done(): 17 | ctx._deferring = True # type: ignore 18 | await interaction.response.defer(ephemeral=False) 19 | 20 | 21 | async def on_user_update(before: discord.User, after: discord.User): 22 | bot: Red = after._state._get_client() # type: ignore # DEP-WARN 23 | assert bot.user 24 | if after.id != bot.user.id: 25 | return 26 | if before.name == after.name: 27 | return 28 | old_name = onetrueslash.name 29 | try: 30 | onetrueslash.name = valid_app_name(after.name) 31 | except ValueError: 32 | await bot.send_to_owners( 33 | f"`onetrueslash` was unable to make the name {after.name!r} " 34 | "into a valid slash command name. The command name was left unchanged." 35 | ) 36 | return 37 | bot.tree.remove_command(old_name) 38 | bot.tree.add_command(onetrueslash, guild=None) 39 | await bot.send_to_owners( 40 | "The bot's username has changed. `onetrueslash`'s slash command has been updated to reflect this.\n" 41 | "**You will need to re-sync the command tree yourself to see this change.**\n" 42 | "It is recommended not to change the bot's name too often with this cog, as this can potentially " 43 | "create confusion for users as well as ratelimiting issues for the bot." 44 | ) 45 | -------------------------------------------------------------------------------- /onetrueslash/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Thanks for loading OneTrueSlash! Your one and only slash command will show in all servers your bot has applications.commands permission for within an hour after syncing.\nNote that you will have to sync the associated command on your own, using `[p]slash sync`.\nThis cog has no commands beyond the one true slash command.\nDue to the necessary work to force text commands to work via slash, commands won't necessarily work correctly. No warranty is provided for any damage this cog may cause.", 6 | "name": "OneTrueSlash", 7 | "short": "Add the one and only slash command you will ever need to your bot!", 8 | "description": "Add the one and only slash command you will ever need to your bot!", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "slash", 12 | "dpy2", 13 | "utility" 14 | ], 15 | "requirements": [ 16 | "rapidfuzz" 17 | ], 18 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 19 | } 20 | -------------------------------------------------------------------------------- /onetrueslash/message.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from copy import copy 3 | from typing import TypeVar 4 | 5 | import discord 6 | 7 | from .channel import InterChannel 8 | 9 | _TT = TypeVar("_TT", bound=type) 10 | 11 | 12 | def __step(*args, **kwargs): 13 | # ensure the coro still yields to the event loop 14 | return asyncio.sleep(0) 15 | 16 | 17 | def neuter_coros(cls: _TT) -> _TT: 18 | for name in dir(cls): 19 | if name in cls.__dict__: 20 | continue 21 | if (attr := getattr(cls, name, None)) is None: 22 | continue 23 | if asyncio.iscoroutinefunction(attr): 24 | setattr(cls, name, property(lambda self: __step)) 25 | return cls 26 | 27 | 28 | @neuter_coros 29 | class InterMessage(discord.Message): 30 | __slots__ = () 31 | 32 | def __init__(self, **kwargs) -> None: 33 | raise RuntimeError 34 | 35 | @classmethod 36 | def _from_interaction(cls, interaction: discord.Interaction, prefix: str) -> "InterMessage": 37 | assert interaction.data 38 | assert interaction.client.user 39 | 40 | self = InterMessage.__new__(InterMessage) 41 | self._state = interaction._state 42 | self._edited_timestamp = None 43 | 44 | self.tts = False 45 | self.webhook_id = None 46 | self.mention_everyone = False 47 | self.embeds = [] 48 | self.role_mentions = [] 49 | self.id = interaction.id 50 | self.nonce = None 51 | self.pinned = False 52 | self.type = discord.MessageType.default 53 | self.flags = discord.MessageFlags() 54 | self.reactions = [] 55 | self.reference = None 56 | self.application = None 57 | self.activity = None 58 | self.stickers = [] 59 | self.components = [] 60 | self.role_subscription = None 61 | self.application_id = None 62 | self.position = None 63 | 64 | channel = interaction.channel 65 | if not channel: 66 | raise RuntimeError("Interaction channel is missing, maybe a Discord bug") 67 | 68 | self.guild = interaction.guild 69 | if interaction.guild_id and not interaction.guild: 70 | # act as if this is a DMChannel 71 | assert isinstance(interaction.user, discord.Member) 72 | self.author = interaction.user._user 73 | channel = discord.DMChannel( 74 | me=interaction.client.user, 75 | state=interaction._state, 76 | data={ 77 | "id": channel.id, 78 | "name": str(channel), 79 | "type": 1, 80 | "last_message_id": None, 81 | "recipients": [ 82 | self.author._to_minimal_user_json(), 83 | interaction.client.user._to_minimal_user_json(), 84 | ], 85 | }, # type: ignore 86 | ) 87 | else: 88 | self.author = interaction.user 89 | channel = copy(channel) 90 | 91 | channel.__class__ = type( 92 | InterChannel.__name__, (InterChannel, channel.__class__), {"__slots__": ()} 93 | ) 94 | self.channel = channel # type: ignore 95 | 96 | self._recreate_from_interaction(interaction, prefix) 97 | return self 98 | 99 | def _recreate_from_interaction(self, interaction: discord.Interaction, prefix: str): 100 | assert interaction.data and interaction.client.user 101 | 102 | self.content = f"{prefix}{interaction.namespace.command}" 103 | if interaction.namespace.arguments: 104 | self.content = f"{self.content} {interaction.namespace.arguments}" 105 | if interaction.namespace.attachment: 106 | self.attachments = [interaction.namespace.attachment] 107 | else: 108 | self.attachments = [] 109 | 110 | resolved = interaction.data.get("resolved", {}) 111 | if self.guild: 112 | self.mentions = [ 113 | discord.Member(data=user_data, guild=self.guild, state=self._state) 114 | for user_data in resolved.get("members", {}).values() 115 | ] 116 | else: 117 | self.mentions = [ 118 | discord.User(data=user_data, state=self._state) 119 | for user_data in resolved.get("users", {}).values() 120 | ] 121 | 122 | def to_reference(self, *, fail_if_not_exists: bool = True): 123 | return None 124 | 125 | def to_message_reference_dict(self): 126 | return discord.utils.MISSING 127 | 128 | async def reply(self, *args, **kwargs): 129 | return await self.channel.send(*args, **kwargs) 130 | 131 | def edit(self, *args, **kwargs): 132 | return asyncio.sleep(0, self) 133 | -------------------------------------------------------------------------------- /onetrueslash/utils.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar 2 | from typing import TYPE_CHECKING, Any, Generator, Optional 3 | 4 | # discord.ext.commands.GroupMixin has easier typehints to work with 5 | from discord.ext.commands import GroupMixin 6 | from redbot.core import commands 7 | 8 | if TYPE_CHECKING: 9 | from .context import InterContext 10 | 11 | 12 | try: 13 | import regex as re 14 | except ImportError: 15 | import re 16 | 17 | 18 | contexts = ContextVar["InterContext"]("contexts") 19 | 20 | 21 | def valid_app_name(name: str) -> str: 22 | from discord.app_commands.commands import VALID_SLASH_COMMAND_NAME, validate_name 23 | 24 | name = "_".join(re.findall(VALID_SLASH_COMMAND_NAME.pattern.strip("^$"), name.lower())) 25 | return validate_name(name[:32]) 26 | 27 | 28 | class Thinking: 29 | def __init__(self, ctx: "InterContext", *, ephemeral: bool = False): 30 | self.ctx = ctx 31 | self.ephemeral = ephemeral 32 | 33 | def __await__(self) -> Generator[Any, Any, None]: 34 | ctx = self.ctx 35 | interaction = ctx._interaction 36 | if not ctx._deferring and not interaction.response.is_done(): 37 | # yield from is necessary here to force this function to be a generator 38 | # even in the negative case 39 | ctx._deferring = True 40 | return (yield from interaction.response.defer(ephemeral=self.ephemeral).__await__()) 41 | 42 | async def __aenter__(self): 43 | await self 44 | 45 | async def __aexit__(self, *args): 46 | pass 47 | 48 | 49 | def walk_aliases( 50 | group: GroupMixin[Any], /, *, parent: Optional[str] = "", show_hidden: bool = False 51 | ) -> Generator[str, None, None]: 52 | for name, command in group.all_commands.items(): 53 | if command.qualified_name == "help": 54 | continue 55 | if not command.enabled or (not show_hidden and command.hidden): 56 | continue 57 | yield f"{parent}{name}" 58 | if isinstance(command, commands.GroupMixin): 59 | yield from walk_aliases(command, parent=f"{parent}{name} ", show_hidden=show_hidden) 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pyright] 2 | pythonVersion = '3.8' 3 | reportUnnecessaryTypeIgnoreComment = true 4 | 5 | [tool.ruff] 6 | target-version = 'py38' 7 | line-length = 99 8 | 9 | [tool.ruff.lint] 10 | select = ['E', 'F', 'B', 'Q', 'I', 'W', 'ASYNC', 'RUF'] 11 | ignore = [ 12 | 'RUF013', # implicit-optional 13 | # discord.py messes with how Optional is interpreted 14 | 'E501', # line-too-long 15 | # not strictly incompatible with ruff-format but annoying nonetheless 16 | 17 | # ignore rules incompatible with ruff-format: https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 18 | 'E501', 'W191', 'E111', 'E114', 'E117', 'D206', 'D300', 'Q000', 'Q001', 'Q002', 'Q003', 'COM812', 'COM819', 'ISC001', 'ISC002' 19 | ] 20 | 21 | [tool.ruff.lint.isort] 22 | combine-as-imports = true 23 | extra-standard-library = ['typing_extensions'] 24 | -------------------------------------------------------------------------------- /rift/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .rift import Rift 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(Rift(bot)) 11 | -------------------------------------------------------------------------------- /rift/converter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import operator 4 | from typing import List 5 | 6 | import discord 7 | from redbot.core import commands 8 | from redbot.core.i18n import Translator 9 | from redbot.core.utils.chat_formatting import inline, pagify 10 | from redbot.core.utils.predicates import MessagePredicate 11 | 12 | log = logging.getLogger("red.fluffy.rift.converter") 13 | _ = Translator(__name__, __file__) 14 | 15 | 16 | class NoRiftsFound(Exception): 17 | def __init__(self, reasons: "dict[str, str]") -> None: 18 | self.reasons = reasons 19 | super().__init__(reasons) 20 | 21 | 22 | class Limited(discord.abc.Messageable): 23 | __slots__ = ("author", "channel") 24 | 25 | def __init__(self, **kwargs): 26 | super().__init__() 27 | if message := kwargs.pop("message", None): 28 | self.author, self.channel = message.author, message.channel 29 | else: 30 | self.author, self.channel = kwargs.pop("author"), kwargs.pop("channel") 31 | if kwargs: 32 | log.warning(f"Extraneous kwargs for class {self.__class__.__qualname__}: {kwargs}") 33 | 34 | def __getattr__(self, attr): 35 | return getattr(self.channel, attr) 36 | 37 | def __hash__(self) -> int: 38 | return hash((self.author, self.channel)) 39 | 40 | def __eq__(self, o: object) -> bool: 41 | if isinstance(o, discord.abc.User): 42 | return self.author == o or self.channel == o 43 | if isinstance(o, (discord.TextChannel, discord.DMChannel)): 44 | return self.channel == o 45 | try: 46 | return (self.author, self.channel) == (o.author, o.channel) # type: ignore 47 | except AttributeError: 48 | return NotImplemented 49 | 50 | def __str__(self) -> str: 51 | return f"{self.author.global_name}, in {self.channel}" 52 | 53 | def __repr__(self) -> str: 54 | return f"{self.__class__.__qualname__}(author={self.author!r}, channel={self.channel!r})" 55 | 56 | _get_channel = property(operator.attrgetter("channel._get_channel")) # type: ignore 57 | 58 | 59 | class DiscordConverter(commands.Converter): 60 | @classmethod 61 | async def convert( 62 | cls, ctx: commands.Context, argument: str, *, globally: bool = True 63 | ) -> discord.abc.Messageable: 64 | try: 65 | results = await cls._search(ctx, argument, globally=globally) 66 | except NoRiftsFound as nrf: 67 | for page in pagify( 68 | "No destinations found.\n\n" 69 | + "\n".join( 70 | f"{result} > {reason}".lstrip() for result, reason in nrf.reasons.items() 71 | ) 72 | ): 73 | await ctx.send(page, allowed_mentions=discord.AllowedMentions.none()) 74 | raise commands.CheckFailure() from None 75 | if len(results) == 1: 76 | return results[0] 77 | message = _("Multiple results found. Choose a destination:\n\n") 78 | for i, result in enumerate(results): 79 | m = f"{i}: {result} ({result.id})" 80 | if guild := getattr(result, "guild", None): 81 | m = f"{m}, in {guild}" 82 | message = f"{message}\n{m}" 83 | await ctx.send(message) 84 | predicate = MessagePredicate.valid_int(ctx=ctx) 85 | try: 86 | await ctx.bot.wait_for("message", check=predicate, timeout=30) 87 | except asyncio.TimeoutError: 88 | m = _("No destination selected.") 89 | await ctx.send(m) 90 | raise commands.BadArgument(m) from None 91 | result = predicate.result 92 | try: 93 | return results[result] 94 | except IndexError: 95 | raise commands.BadArgument(f"{result} is not a number in the list.") from None 96 | 97 | @classmethod 98 | async def _search( 99 | cls, ctx: commands.Context, argument: str, *, globally: bool = False 100 | ) -> List[discord.abc.Messageable]: 101 | is_owner = await ctx.bot.is_owner(ctx.author) 102 | is_nsfw = getattr(ctx.channel, "nsfw", False) 103 | globally = globally or is_owner 104 | if not globally and not ctx.guild: 105 | raise NoRiftsFound({}) 106 | source = ctx.channel if ctx.guild else ctx.author 107 | config = ctx.cog.config 108 | blacklists = await asyncio.gather( 109 | config.all_guilds(), config.all_channels(), config.all_users() 110 | ) 111 | guilds = ctx.bot.guilds if globally else [ctx.guild] 112 | results: set[discord.abc.Messageable] = set() 113 | reasons: dict[str, str] = {} 114 | for guild in guilds: 115 | assert guild is not None 116 | if blacklists[0].get(guild.id, {}).get("blacklisted"): 117 | continue 118 | for channel in guild.text_channels: 119 | if argument.lstrip("#") not in (str(channel.id), channel.mention, channel.name): 120 | continue 121 | if channel in results: 122 | continue 123 | if channel == source: 124 | reasons[ 125 | channel.mention 126 | ] = "Rifts cannot be opened to the same channel as their source." 127 | continue 128 | if getattr(channel, "nsfw", False) != is_nsfw: 129 | reasons[ 130 | channel.mention 131 | ] = f"Channel {'is not' if is_nsfw else 'is'} nsfw, while this channel {'is' if is_nsfw else 'is not'}." 132 | continue 133 | if blacklists[1].get(channel.id, {}).get("blacklisted"): 134 | reasons[channel.mention] = "Channel is blocked from receiving rifts." 135 | continue 136 | results.add(channel) 137 | if is_nsfw: 138 | # don't allow rifts from nsfw channels to DMs 139 | continue 140 | for user in guild.members: 141 | if user in results: 142 | continue 143 | to_match = [ 144 | str(user.id), 145 | f"<@{user.id}>", 146 | f"<@!{user.id}>", 147 | user.name, 148 | user.global_name, 149 | ] 150 | if guild == ctx.guild: 151 | to_match.append(user.display_name) 152 | if argument.lstrip("@") not in to_match: 153 | continue 154 | if user == source: 155 | reasons[user.name] = "Rifts cannot be opened to the same user as their source." 156 | continue 157 | if user.bot: 158 | reasons[user.name] = "User is a bot." 159 | continue 160 | if blacklists[2].get(user.id, {}).get("blacklisted"): 161 | reasons[user.name] = "User has blocked rifts to their direct messages." 162 | continue 163 | if not await ctx.bot.allowed_by_whitelist_blacklist(user): 164 | reasons[user.name] = "User is not permitted to use this bot." 165 | continue 166 | results.add(user._user) 167 | await asyncio.sleep(0) 168 | results.discard(None) # type: ignore 169 | if results: 170 | return list(results) 171 | if reasons: 172 | raise NoRiftsFound(reasons) 173 | if is_nsfw: 174 | raise NoRiftsFound( 175 | { 176 | "": f"If {inline(argument)} is a user, note that rifts cannot be opened to direct messages from nsfw channels." 177 | } 178 | ) 179 | raise NoRiftsFound( 180 | { 181 | "": f"Either {inline(argument)} does not exist or it is in a server that is blocked from receiving rifts." 182 | } 183 | ) 184 | -------------------------------------------------------------------------------- /rift/graph.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | from typing import ( 3 | TYPE_CHECKING, 4 | ClassVar, 5 | Dict, 6 | Generator, 7 | Generic, 8 | Hashable, 9 | MutableMapping, 10 | Set, 11 | Tuple, 12 | Type, 13 | TypeVar, 14 | ) 15 | 16 | __all__ = ["GraphError", "SimpleGraph", "Vector"] 17 | T = TypeVar("T", bound=Hashable) 18 | Vector = Tuple[T, T] # ORDER MATTERS 19 | 20 | 21 | class GraphError(Exception): 22 | pass 23 | 24 | 25 | if TYPE_CHECKING: 26 | _Base = MutableMapping[T, Set[T]] 27 | else: 28 | _Base = Generic 29 | 30 | 31 | class GraphMixin(_Base[T]): 32 | _set: ClassVar[Type[Set[T]]] 33 | 34 | def add_web(self, *vertices: T) -> None: 35 | """ 36 | Opens up all possible connections between the specified vertices. 37 | """ 38 | for vertex in vertices: 39 | self.add_vectors(vertex, *vertices) 40 | 41 | def remove_vertices(self, *vertices: T) -> None: 42 | """ 43 | Removes all connections to and from the specified vertices. 44 | """ 45 | v_set = set(vertices) 46 | for vertex, neighbors in self.copy().items(): # type: ignore 47 | if vertex in v_set: 48 | self.pop(vertex) 49 | else: 50 | neighbors -= v_set 51 | 52 | def add_vectors(self, a: T, *b_s: T, two_way: bool = False) -> None: 53 | b_set = set(b_s) 54 | b_set.discard(a) 55 | self.setdefault(a, self._set()).update(b_set) # type: ignore 56 | if two_way: 57 | for b in b_set: 58 | self.setdefault(b, self._set()).add(a) 59 | 60 | def remove_vectors(self, a: T, *b_s: T, two_way: bool = False) -> None: 61 | b_set = set(b_s) 62 | b_set.discard(a) 63 | self.setdefault(a, self._set()).difference_update(b_set) # type: ignore 64 | if two_way: 65 | for b in b_set: 66 | self.setdefault(b, self._set()).discard(a) 67 | 68 | def is_vector(self, a: T, b: T, *, two_way: bool = False) -> bool: 69 | if two_way: 70 | return b in self.get(a, ()) and a in self.get(b, ()) 71 | return b in self.get(a, ()) 72 | 73 | def vertices(self) -> Set[T]: 74 | keys = set(filter(self.__getitem__, self.keys())) 75 | return keys.union(chain.from_iterable(self.values())) 76 | 77 | def vectors(self) -> Generator[Vector[T], None, None]: 78 | for vertex, neighbors in self.items(): 79 | for neighbor in neighbors: 80 | yield vertex, neighbor 81 | 82 | def to_json(self): 83 | return {k: list(v) for k, v in self.items()} 84 | 85 | @classmethod 86 | def from_json(cls, json): 87 | return cls((k, cls._set(v)) for k, v in json.items()) 88 | 89 | 90 | class SimpleGraph(GraphMixin[T], Dict[T, Set[T]]): 91 | _set = set 92 | -------------------------------------------------------------------------------- /rift/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)", 4 | "Twentysix26 (Twentysix#5252)" 5 | ], 6 | "install_msg": "Usage: `[p]help rift`", 7 | "name": "Rift", 8 | "short": "Allows cross-server communication through Red", 9 | "description": "Communicate with other servers through Red! Works to and from DMs as well.", 10 | "min_bot_version": "3.5.0", 11 | "hidden": true, 12 | "tags": [ 13 | "cross-server", 14 | "communication", 15 | "fun", 16 | "trolling", 17 | "impersonation" 18 | ], 19 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users. This cog does store user IDs necessary for anti-abuse measures, i.e. blocklists." 20 | } 21 | -------------------------------------------------------------------------------- /rtfs/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .rtfs import RTFS 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(RTFS(bot)) 11 | -------------------------------------------------------------------------------- /rtfs/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help rtfs`", 6 | "name": "RTFS", 7 | "short": "Read The Source", 8 | "description": "Allows users to read the source of publicly-sourced commands. The bot owner can also read the source of any Python object if developer mode is enabled.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "dev", 12 | "utilities" 13 | ], 14 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 15 | } 16 | -------------------------------------------------------------------------------- /rtfs/pages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from itertools import islice 5 | from typing import ( 6 | TYPE_CHECKING, 7 | AsyncIterable, 8 | AsyncIterator, 9 | Awaitable, 10 | Generic, 11 | Iterable, 12 | Sequence, 13 | SupportsIndex, 14 | TypeVar, 15 | ) 16 | from typing_extensions import Never 17 | 18 | import discord 19 | from discord.ui import Button, View, button 20 | from discord.utils import MISSING 21 | 22 | _T_co = TypeVar("_T_co", covariant=True) 23 | 24 | if TYPE_CHECKING: 25 | 26 | def aiter(__aiter: AsyncIterable[_T_co]) -> AsyncIterator[_T_co]: ... 27 | def anext(__aiter: AsyncIterator[_T_co]) -> Awaitable[_T_co]: ... 28 | elif sys.version_info < (3, 10): 29 | from operator import methodcaller 30 | 31 | aiter = methodcaller("__aiter__") 32 | anext = methodcaller("__anext__") 33 | del methodcaller 34 | 35 | 36 | async def _take(iterator: AsyncIterator[_T_co], stop: int) -> AsyncIterator[_T_co]: 37 | count = 0 38 | while count < stop: 39 | try: 40 | item = await anext(iterator) 41 | except StopAsyncIteration: 42 | return 43 | count += 1 44 | yield item 45 | 46 | 47 | class _SequenceSource(Generic[_T_co]): 48 | __slots__ = ("_cache",) 49 | 50 | def __init__(self, __seq: Sequence[_T_co]) -> None: 51 | self._cache = __seq 52 | 53 | def __getitem__(self, item: SupportsIndex) -> _T_co: 54 | return self._cache[item] 55 | 56 | async def _fill_index(self, idx: int) -> int: 57 | return len(self._cache) 58 | 59 | 60 | class _IterSource(Generic[_T_co]): 61 | __slots__ = ("_cache", "_iter") 62 | 63 | def __init__(self, __seq: Iterable[_T_co]) -> None: 64 | self._iter = iter(__seq) 65 | self._cache: list[_T_co] = [] 66 | 67 | def __getitem__(self, item: SupportsIndex) -> _T_co: 68 | return self._cache[item] 69 | 70 | async def _fill_index(self, idx: int) -> int: 71 | if idx < 0: 72 | it = self._iter 73 | else: 74 | it = islice(self._iter, max(0, idx + 1 - len(self._cache))) 75 | self._cache.extend(it) 76 | return len(self._cache) 77 | 78 | 79 | class _AsyncIterSource(Generic[_T_co]): 80 | __slots__ = ("_cache", "_aiter") 81 | 82 | def __init__(self, __seq: AsyncIterable[_T_co]) -> None: 83 | self._aiter = aiter(__seq) 84 | self._cache: list[_T_co] = [] 85 | 86 | def __getitem__(self, idx: SupportsIndex) -> _T_co: 87 | return self._cache[idx] 88 | 89 | async def _fill_index(self, idx: int) -> int: 90 | if idx < 0: 91 | it = self._aiter 92 | else: 93 | it = _take(self._aiter, max(0, idx + 1 - len(self._cache))) 94 | async for i in it: 95 | self._cache.append(i) 96 | return len(self._cache) 97 | 98 | 99 | class Pages: 100 | __slots__ = ("_author_id", "_index", "_message", "_source", "_timeout_content", "_view") 101 | 102 | def __init__( 103 | self, 104 | *, 105 | source: Iterable[str] | AsyncIterable[str], 106 | author_id: int, 107 | starting_index: int = 0, 108 | timeout_content: str | int | None = MISSING, 109 | timeout: float | None = 180.0, 110 | ): 111 | if isinstance(source, Sequence): 112 | self._source = _SequenceSource(source) 113 | elif isinstance(source, Iterable): 114 | self._source = _IterSource(source) 115 | elif isinstance(source, AsyncIterable): 116 | self._source = _AsyncIterSource(source) 117 | else: 118 | raise TypeError( 119 | f"Expected Iterable or AsyncIterable, got {source.__class__.__name__!r}" 120 | ) 121 | self._author_id = author_id 122 | self._message: discord.Message | None = None 123 | self._index = starting_index 124 | self._timeout_content = timeout_content 125 | self._view = _PageView(parent=self, timeout=timeout) 126 | 127 | async def _set_index(self, value: int, /): 128 | source_len = await self._source._fill_index( 129 | # -1 if value is negative, 2 if value is 0, value + 1 if value is positive 130 | # signum(x) = (x > 0) - (x < 0) 131 | [-1, 2, value + 1][1 + (value > 0) - (value < 0)] 132 | ) 133 | self._index = value = value % source_len 134 | offset = source_len == 2 135 | view = self._view 136 | if source_len == 1: 137 | view.clear_items() 138 | elif value == 0: 139 | view._update_button(view.first, disabled=True) 140 | view._update_button(view.last, disabled=False) 141 | view._update_inner_buttons(range(0 - offset, 3 - offset)) 142 | elif value == source_len - 1: 143 | view._update_button(view.first, disabled=False) 144 | view._update_button(view.last, disabled=True) 145 | view._update_inner_buttons(range(value - 2 + offset, value + 1 + offset)) 146 | else: 147 | view._update_button(view.first, disabled=False) 148 | view._update_button(view.last, disabled=False) 149 | view._update_inner_buttons(range(value - 1, value + 2)) 150 | 151 | @property 152 | def current_page(self) -> str: 153 | return self._source[self._index] 154 | 155 | async def send_to( 156 | self, 157 | destination: discord.abc.Messageable, 158 | *, 159 | content: "Never" = MISSING, 160 | view: "Never" = MISSING, 161 | **kwargs, 162 | ): 163 | await self._set_index(self._index) 164 | self._message = await destination.send( 165 | content=self.current_page, view=self._view, **kwargs 166 | ) 167 | return self._message 168 | 169 | 170 | class _PageView(View): 171 | def __init__(self, *, parent: Pages, timeout: float | None = 180): 172 | super().__init__(timeout=timeout) 173 | self.parent = parent 174 | 175 | def _update_inner_buttons(self, assigned: Iterable[int]): 176 | bounds = range(len(self.parent._source._cache)) 177 | for btn, idx in zip((self.left, self.center, self.right), assigned): 178 | if idx not in bounds: 179 | self._update_button(btn, label="\u200b", disabled=True) 180 | else: 181 | disable = idx == self.parent._index 182 | self._update_button(btn, label=idx + 1, disabled=disable) 183 | 184 | def _update_button(self, button: Button, *, label: object = None, disabled: bool): 185 | if label is not None: 186 | button.label = str(label) 187 | button.disabled = disabled 188 | button.style = discord.ButtonStyle.grey if disabled else discord.ButtonStyle.blurple 189 | 190 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 191 | return interaction.user.id == self.parent._author_id 192 | 193 | async def on_timeout(self) -> None: 194 | parent = self.parent 195 | if parent._message: 196 | timeout_content = parent._timeout_content 197 | if timeout_content is None: 198 | await parent._message.delete() 199 | elif isinstance(timeout_content, int): 200 | await parent._source._fill_index(timeout_content) 201 | await parent._message.edit(content=parent._source[timeout_content], view=None) 202 | else: 203 | await parent._message.edit(content=timeout_content, view=None) 204 | 205 | @button(label="≪", style=discord.ButtonStyle.blurple) 206 | async def first(self, interaction: discord.Interaction, button: Button): 207 | await self.parent._set_index(0) 208 | await interaction.response.edit_message(content=self.parent.current_page, view=self) 209 | 210 | @button(label=".", style=discord.ButtonStyle.blurple) 211 | async def left(self, interaction: discord.Interaction, button: Button): 212 | await self.parent._set_index(int(button.label) - 1) # type: ignore 213 | await interaction.response.edit_message(content=self.parent.current_page, view=self) 214 | 215 | @button(label=".", style=discord.ButtonStyle.blurple) 216 | async def center(self, interaction: discord.Interaction, button: Button): 217 | await self.parent._set_index(int(button.label) - 1) # type: ignore 218 | await interaction.response.edit_message(content=self.parent.current_page, view=self) 219 | 220 | @button(label=".", style=discord.ButtonStyle.blurple) 221 | async def right(self, interaction: discord.Interaction, button: Button): 222 | await self.parent._set_index(int(button.label) - 1) # type: ignore 223 | await interaction.response.edit_message(content=self.parent.current_page, view=self) 224 | 225 | @button(label="≫", style=discord.ButtonStyle.blurple) 226 | async def last(self, interaction: discord.Interaction, button: Button): 227 | await self.parent._set_index(-1) 228 | await interaction.response.edit_message(content=self.parent.current_page, view=self) 229 | -------------------------------------------------------------------------------- /rtfs/rtfs.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import inspect 3 | import logging 4 | import sys 5 | import textwrap 6 | import traceback 7 | from functools import partial, partialmethod 8 | from importlib.metadata import PackageNotFoundError, version 9 | from itertools import chain, product 10 | from math import ceil 11 | from textwrap import dedent 12 | from typing import TYPE_CHECKING, Any, Optional, Union 13 | 14 | import discord 15 | import redbot 16 | from redbot.core import commands 17 | from redbot.core.bot import Red 18 | from redbot.core.dev_commands import cleanup_code 19 | from redbot.core.utils.chat_formatting import box, pagify 20 | 21 | from .pages import Pages 22 | 23 | if sys.version_info < (3, 10): 24 | from typing import Iterable, Iterator, TypeVar 25 | 26 | _T = TypeVar("_T") 27 | 28 | def pairwise(iterable: Iterable[_T]) -> "Iterator[tuple[_T, _T]]": 29 | iterator = iter(iterable) 30 | a = next(iterator, ...) 31 | for b in iterator: 32 | yield a, b 33 | a = b 34 | else: 35 | from itertools import pairwise 36 | 37 | try: 38 | import regex as re 39 | except ImportError: 40 | import re 41 | 42 | if TYPE_CHECKING: 43 | from redbot.cogs.downloader import Downloader 44 | from redbot.core.dev_commands import Dev 45 | 46 | LOG = logging.getLogger("red.fluffy.rtfs") 47 | GIT_AT = re.compile(r"(?i)git@(?P[^:]+):(?P[^/]+)/(?P.+)(?:\.git)?") 48 | 49 | 50 | class Unlicensed(Exception): 51 | """ 52 | Exception class for when the source code is known to have too restrictive of a license to redistribute code. 53 | """ 54 | 55 | def __init__(self, *, cite: Optional[str] = None): 56 | super().__init__(cite) 57 | self.cite = cite 58 | 59 | 60 | class NoLicense(Exception): 61 | """ 62 | Exception class for when the source code is known to have no license. 63 | """ 64 | 65 | 66 | def _wrap_with_linereturn(wrapper: textwrap.TextWrapper): 67 | def inner(line: str): 68 | wrapped = wrapper.wrap(line) 69 | if not wrapped: 70 | return ["\n"] 71 | wrapped[-1] += "\n" 72 | return wrapped 73 | 74 | return inner 75 | 76 | 77 | def _pager(source: str, *, header: Optional[str]): 78 | # \u02CB = modifier letter grave accent 79 | source = source.replace("```", "\u02cb\u02cb\u02cb") 80 | header = header or "" 81 | max_page = 1890 - len(header) 82 | if len(source) < max_page: 83 | # fast path 84 | yield f"{header}```py\n{source}\n```" 85 | return 86 | wrapper = textwrap.TextWrapper(88, tabsize=4) 87 | lines: list[str] = list( 88 | # split overly long lines to allow for page breaks 89 | chain.from_iterable(map(_wrap_with_linereturn(wrapper), source.splitlines())) 90 | ) 91 | total_lines = len(lines) 92 | num_pages = ceil((len(source) + 600) / max_page) 93 | format = f"{header}```py\n%s\n```".__mod__ 94 | pages: map[str] = map( 95 | lambda t: "".join(lines[slice(*t)]).rstrip(), 96 | pairwise( 97 | chain( 98 | map( 99 | lambda x: round(x / num_pages), range(0, total_lines * num_pages, total_lines) 100 | ), 101 | (None,), 102 | ) 103 | ), 104 | ) 105 | for page in pages: 106 | if len(page) > max_page: 107 | # degenerate case 108 | yield from map(format, pagify(page, page_length=max_page, shorten_by=0)) 109 | elif page: 110 | yield format(page) 111 | 112 | 113 | async def format_and_send(ctx: commands.Context, obj: Any, *, is_owner: bool = False) -> None: 114 | obj = inspect.unwrap(obj) 115 | source: Any 116 | if isinstance( 117 | obj, (commands.Command, discord.app_commands.Command, discord.app_commands.ContextMenu) 118 | ): 119 | source = obj.callback 120 | if not inspect.getmodule(source): 121 | # probably some kind of custom-coded command 122 | cog: Any = ctx.bot.get_cog("InstantCommands") 123 | if not is_owner or cog is None: 124 | raise OSError 125 | for snippet in cog.code_snippets: 126 | if snippet.verbose_name == obj.name: 127 | header = f"__command `{snippet.verbose_name}`__" 128 | await Pages( 129 | source=_pager(snippet.source, header=header), 130 | author_id=ctx.author.id, 131 | timeout_content=None, 132 | timeout=60, 133 | ).send_to(ctx) 134 | return 135 | raise OSError 136 | elif isinstance(obj, (partial, partialmethod)): 137 | source = obj.func 138 | elif isinstance(obj, property): 139 | source = obj.fget 140 | elif isinstance(obj, (discord.utils.cached_property, discord.utils.CachedSlotProperty)): 141 | source = obj.function # type: ignore 142 | else: 143 | source = getattr(obj, "__func__", obj) 144 | try: 145 | lines, line = inspect.getsourcelines(source) 146 | except TypeError as e: 147 | if "was expected, got" not in e.args[0]: 148 | raise 149 | source = type(source) 150 | lines, line = inspect.getsourcelines(source) 151 | source_file = inspect.getsourcefile(source) 152 | if line > 0: 153 | comments = inspect.getcomments(source) or "" 154 | line_suffix, _ = next( 155 | filter((lambda item: not re.match(r"\s*@", item[1])), enumerate(lines, line)) 156 | ) 157 | line_suffix = f"#L{line_suffix}" 158 | else: 159 | comments = "" 160 | line_suffix = "" 161 | module = getattr(inspect.getmodule(source), "__name__", None) 162 | if source_file and module and source_file.endswith("__init__.py"): 163 | full_module = f"{module}.__init__" 164 | else: 165 | full_module = module 166 | is_installed = False 167 | header: str = "" 168 | if full_module: 169 | dl: Optional[Downloader] = ctx.bot.get_cog("Downloader") 170 | full_module_path = full_module.replace(".", "/") 171 | if full_module.startswith("discord."): 172 | is_installed = True 173 | if discord.__version__[-1].isdigit(): 174 | dpy_commit = "v" + discord.__version__ 175 | else: 176 | try: 177 | _, _, dpy_commit = version("discord.py").partition("+g") 178 | except PackageNotFoundError: 179 | dpy_commit = "master" 180 | dpy_commit = dpy_commit or "master" 181 | header = f"https://github.com/Rapptz/discord.py/blob/{dpy_commit}/{full_module_path}.py{line_suffix}" 182 | elif full_module.startswith("redbot."): 183 | is_installed = not redbot.version_info.dirty 184 | if redbot.version_info.dev_release: 185 | red_commit = redbot.version_info.short_commit_hash or "V3/develop" 186 | else: 187 | red_commit = redbot.__version__ 188 | if is_installed: 189 | header = f"https://github.com/Cog-Creators/Red-DiscordBot/blob/{red_commit}/{full_module_path}.py{line_suffix}" 190 | elif dl: 191 | is_installed, installable = await dl.is_installed(full_module.split(".")[0]) 192 | if is_installed: 193 | assert installable 194 | if installable.repo is None: 195 | is_installed = False 196 | else: 197 | if ctx.guild or not is_owner: 198 | surl = str(installable.repo.url).lower() 199 | if "aikaterna/gobcog" in surl or "aikaterna/imgwelcome" in surl: 200 | raise NoLicense() 201 | if match := GIT_AT.match(installable.repo.url): 202 | # SSH URL 203 | # Since it's not possible to tell if it's a private repo or not without an extra web request, 204 | # we'll just assume it's a private repo 205 | is_installed = False 206 | repo_url = f"https://{match.group('host')}/{match.group('user')}/{match.group('repo')}" 207 | else: 208 | repo_url = installable.repo.clean_url 209 | if repo_url != installable.repo.url: 210 | # Private repo 211 | is_installed = False 212 | repo_url = repo_url.rstrip("/") 213 | header = f"{repo_url}/blob/{installable.commit}/{full_module.replace('.', '/')}.py{line_suffix}" 214 | if not is_installed and not is_owner: 215 | # don't disclose the source of private cogs 216 | raise OSError() 217 | if not header: 218 | if module: 219 | header = f"```py\nFile {source_file}, line {line}, in module {module}\n```" 220 | else: 221 | header = f"```py\nFile {source_file}, line {line}\n```" 222 | else: 223 | header = f"<{header}>" 224 | comments = comments and dedent(comments) 225 | lines = dedent("".join(lines)) 226 | await Pages( 227 | source=_pager(f"{comments}{lines}", header=header), 228 | author_id=ctx.author.id, 229 | timeout_content=header if header.startswith("<") else None, 230 | timeout=60, 231 | ).send_to(ctx) 232 | 233 | 234 | def _find_app_command( 235 | tree: discord.app_commands.CommandTree, guild: Optional[discord.Guild], name: str 236 | ) -> Union[ 237 | discord.app_commands.Command, 238 | discord.app_commands.ContextMenu, 239 | discord.app_commands.Group, 240 | None, 241 | ]: 242 | return next( 243 | filter( 244 | None, 245 | ( 246 | tree.get_command(name, guild=scope, type=command_type) 247 | for scope, command_type in product( 248 | (guild, None) if guild else (None,), discord.AppCommandType 249 | ) 250 | ), 251 | ), 252 | None, 253 | ) 254 | 255 | 256 | class RTFS(commands.Cog): 257 | def __init__(self, bot: Red): 258 | super().__init__() 259 | self.bot = bot 260 | 261 | async def cog_load(self) -> None: 262 | self.bot.add_dev_env_value( 263 | "rtfs", lambda ctx: partial(format_and_send, ctx, is_owner=True) 264 | ) 265 | 266 | async def cog_unload(self) -> None: 267 | self.bot.remove_dev_env_value("rtfs") 268 | 269 | @commands.command(aliases=["rts", "source"]) 270 | async def rtfs(self, ctx: commands.Context, *, thing: str): 271 | """ 272 | Read the source code for a cog or command. 273 | 274 | The bot owner may additionally supply any valid Python object, 275 | if developer mode is enabled. 276 | """ 277 | is_owner = await ctx.bot.is_owner(ctx.author) 278 | try: 279 | if thing.startswith("/") and ( 280 | obj := _find_app_command(ctx.bot.tree, ctx.guild, thing[1:]) 281 | ): 282 | return await format_and_send(ctx, obj, is_owner=is_owner) 283 | elif obj := ctx.bot.get_cog(thing): 284 | return await format_and_send(ctx, type(obj), is_owner=is_owner) 285 | elif obj := ctx.bot.get_command(thing): 286 | return await format_and_send(ctx, obj, is_owner=is_owner) 287 | except OSError: 288 | return await ctx.send(f"I couldn't find any source file for `{thing}`") 289 | except Unlicensed as e: 290 | if e.cite: 291 | message = f"The source code for `{thing}` is copyrighted under too strict a license for me to show it here. (See <{e.cite}>)" 292 | else: 293 | message = f"The source code for `{thing}` is copyrighted under too strict a license for me to show it here." 294 | return await ctx.send(message) 295 | except NoLicense: 296 | return await ctx.send( 297 | f"The source code for `{thing}` has no license, so I cannot show it here." 298 | ) 299 | dev: Optional[Dev] = ctx.bot.get_cog("Dev") 300 | if not is_owner or not dev: 301 | raise commands.UserFeedbackCheckFailure( 302 | f"I couldn't find any cog or command named `{thing}`." 303 | ) 304 | thing = cleanup_code(thing) 305 | env = dev.get_environment(ctx) 306 | env["getattr_static"] = inspect.getattr_static 307 | try: 308 | tree = ast.parse(thing, "", "eval") 309 | if isinstance(tree.body, ast.Attribute) and isinstance(tree.body.ctx, ast.Load): 310 | tree.body = ast.Call( 311 | func=ast.Name(id="getattr_static", ctx=ast.Load()), 312 | args=[tree.body.value, ast.Constant(value=tree.body.attr)], 313 | keywords=[], 314 | ) 315 | tree = ast.fix_missing_locations(tree) 316 | obj = eval(compile(tree, "", "eval"), env) 317 | except NameError: 318 | return await ctx.send(f"I couldn't find any cog, command, or object named `{thing}`.") 319 | except Exception as e: 320 | return await ctx.send( 321 | box("".join(traceback.format_exception_only(type(e), e)), lang="py") 322 | ) 323 | try: 324 | return await format_and_send(ctx, obj, is_owner=is_owner) 325 | except OSError: 326 | return await ctx.send(f"I couldn't find source file for object `{thing}`") 327 | except TypeError as te: 328 | return await ctx.send( 329 | box("".join(traceback.format_exception_only(type(te), te)), lang="py") 330 | ) 331 | -------------------------------------------------------------------------------- /secureinv/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .secureinv import SecureInv 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(SecureInv(bot)) 11 | -------------------------------------------------------------------------------- /secureinv/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help SecureInv`.", 6 | "name": "SecureInv", 7 | "short": "Set up secure invites.", 8 | "description": "Set up secure invites.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "utility" 12 | ], 13 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 14 | } 15 | -------------------------------------------------------------------------------- /secureinv/secureinv.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional, TypedDict, Union, cast 3 | from typing_extensions import TypeAlias 4 | 5 | import discord 6 | from redbot.core import Config, commands 7 | from redbot.core.utils.mod import get_audit_reason, is_mod_or_superior 8 | 9 | InviteableChannel: TypeAlias = Union[ 10 | discord.TextChannel, discord.ForumChannel, discord.VoiceChannel, discord.StageChannel 11 | ] # Categories also have a .create_invite() method, but the API doesn't actually support it 12 | 13 | 14 | class Settings(TypedDict): 15 | channel: Optional[int] 16 | days: Optional[int] 17 | uses: Optional[int] 18 | 19 | 20 | class SettingsConverter(commands.FlagConverter, case_insensitive=True, delimiter=" "): 21 | channel: Optional[InviteableChannel] = None 22 | days: Optional[commands.Range[int, 0, 7]] = None 23 | uses: Optional[commands.Range[int, 0, 100]] = None 24 | 25 | 26 | class InviteSettingsConverter(commands.FlagConverter, case_insensitive=True, delimiter=" "): 27 | channel: Optional[InviteableChannel] = None 28 | days: Optional[commands.Range[int, 0, 7]] = None 29 | uses: Optional[commands.Range[int, 0, 100]] = None 30 | amount: Optional[commands.Range[int, 1, 10]] = None 31 | reason: Optional[str] = None 32 | 33 | 34 | assert frozenset(SettingsConverter.__annotations__) == frozenset(Settings.__annotations__) 35 | assert frozenset(InviteSettingsConverter.__annotations__) >= frozenset(Settings.__annotations__) 36 | 37 | 38 | @commands.permissions_check 39 | def _record_permissions_checked(ctx): 40 | """Remember whether permissions was checked, for in-command checks""" 41 | ctx.__is_permissions_checked__ = True 42 | return True 43 | 44 | 45 | class SecureInv(commands.Cog): 46 | async def red_get_data_for_user(self, *, user_id): 47 | return {} # No data to get 48 | 49 | async def red_delete_data_for_user(self, *, requester, user_id): 50 | pass # No data to delete 51 | 52 | def __init__(self, bot): 53 | super().__init__() 54 | self.bot = bot 55 | self.last_purge = {} 56 | self.config = Config.get_conf(self, identifier=2_113_674_295, force_registration=True) 57 | self.config.register_guild(**Settings(channel=None, days=1, uses=0)) 58 | 59 | @commands.group(invoke_without_command=True) 60 | @_record_permissions_checked 61 | async def inv(self, ctx: commands.GuildContext, *, settings: InviteSettingsConverter): 62 | """ 63 | Create one or several invites with the specified parameters, e.g. 64 | [p]inv channel #general days 1 uses 6 amount 3 reason "friend group invites" 65 | 66 | For specifying unlimited days or uses, use 0. 67 | 68 | Defaults can be set with `[p]inv set`. 69 | If no defaults are found, channel defaults to the current channel, 70 | days defaults to 1, uses defaults to 0 (infinite), and amount defaults to 1. 71 | 72 | Uses will always be finite if days is infinite. 73 | """ 74 | defaults = cast(Settings, await self.config.guild(ctx.guild).all()) 75 | parent = cast(InviteableChannel, getattr(ctx.channel, "parent", ctx.channel)) 76 | channel = settings.channel 77 | if not channel and defaults["channel"]: 78 | channel = cast(Optional[InviteableChannel], ctx.guild.get_channel(defaults["channel"])) 79 | channel = channel or parent 80 | 81 | # Bot permissions check 82 | if not channel.permissions_for(ctx.me).create_instant_invite: 83 | raise commands.BotMissingPermissions(discord.Permissions(create_instant_invite=True)) 84 | 85 | # Author permissions check, taking into account bot-mod and permissions checks as well 86 | # Since this depends on the channel argument, a check decorator won't work 87 | if not ( 88 | channel.permissions_for(ctx.author).create_instant_invite 89 | or await is_mod_or_superior(ctx.bot, ctx.author) 90 | or ( 91 | channel.id == (defaults["channel"] or parent.id) 92 | and not hasattr(ctx, "__is_permissions_checked__") 93 | ) 94 | ): 95 | raise commands.MissingPermissions(["create_instant_invite"]) 96 | 97 | days = defaults["days"] if settings.days is None else settings.days 98 | uses = defaults["uses"] if settings.uses is None else settings.uses 99 | if days == 0: 100 | uses = uses or 1 # if days is infinite, limit uses 101 | generated = await asyncio.gather( 102 | *( 103 | channel.create_invite( 104 | max_age=(days or 0) * 86400, 105 | max_uses=uses or 0, 106 | temporary=False, 107 | unique=True, 108 | reason=get_audit_reason(ctx.author, reason=settings.reason), # type: ignore 109 | ) 110 | for _ in range(settings.amount or 1) 111 | ) 112 | ) 113 | await ctx.send("\n".join(invite.url for invite in generated), delete_after=120) 114 | 115 | @inv.group(name="set", invoke_without_command=True) 116 | @commands.admin_or_permissions(manage_guild=True) 117 | @commands.guild_only() 118 | async def _inv_set(self, ctx: commands.GuildContext, *, settings: Optional[SettingsConverter]): 119 | """ 120 | Configure or view the server's default inv settings, e.g. 121 | [p]inv set channel #general days 0 uses 1 122 | """ 123 | if settings is None: 124 | await ctx.send_help() 125 | await ctx.maybe_send_embed( 126 | "\n".join( 127 | f"**{k.title()}:** {v}" 128 | for k, v in (await self.config.guild(ctx.guild).all()).items() 129 | ) 130 | ) 131 | else: 132 | async with self.config.guild(ctx.guild).all() as current_settings: 133 | for setting, value in settings: 134 | if value is not None: 135 | current_settings[setting] = value 136 | await ctx.tick() 137 | -------------------------------------------------------------------------------- /skyrim/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .skyrim import Skyrim 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(Skyrim()) 11 | -------------------------------------------------------------------------------- /skyrim/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "`[p]help Skyrim`", 6 | "name": "Skyrim", 7 | "short": "Say a random line from Skyrim. WARNING: Spoilers for Skyrim.", 8 | "description": "Say a random line from Skyrim\nWARNING: SPOILERS FOR SKYRIM.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "skyrim", 12 | "gaming", 13 | "fun" 14 | ], 15 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 16 | } 17 | -------------------------------------------------------------------------------- /skyrim/skyrim.py: -------------------------------------------------------------------------------- 1 | from random import randrange 2 | 3 | from redbot.core import commands 4 | from redbot.core.data_manager import bundled_data_path 5 | 6 | 7 | class Skyrim(commands.Cog): 8 | """ 9 | Says a random line from Skyrim. 10 | """ 11 | 12 | async def red_get_data_for_user(self, *, user_id): 13 | return {} # No data to get 14 | 15 | async def red_delete_data_for_user(self, *, requester, user_id): 16 | pass # No data to delete 17 | 18 | @commands.command() 19 | async def guard(self, ctx): 20 | """ 21 | Says a random guard line from Skyrim. 22 | """ 23 | filepath = bundled_data_path(self) / "lines.txt" 24 | with filepath.open() as file: 25 | line = next(file) 26 | for num, readline in enumerate(file): 27 | if readline and randrange(num + 2): 28 | continue 29 | line = readline 30 | await ctx.maybe_send_embed(line) 31 | 32 | @commands.command() 33 | async def nazeem(self, ctx): 34 | """ 35 | Do you get to the Cloud District very often? 36 | 37 | Oh, what am I saying, of course you don't. 38 | """ 39 | await ctx.maybe_send_embed( 40 | "Do you get to the Cloud District very often? Oh, what am I saying, of course you don't." 41 | ) 42 | -------------------------------------------------------------------------------- /spoilerer/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .spoilerer import Spoilerer 7 | 8 | 9 | async def setup(bot: Red): 10 | spoilerer = Spoilerer(bot) 11 | await spoilerer.initialize() 12 | await bot.add_cog(spoilerer) 13 | -------------------------------------------------------------------------------- /spoilerer/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help spoiler`", 6 | "name": "Spoilerer", 7 | "short": "Add a spoiler command and a spoiler button for use by mobile users, who lack the ability to spoiler images.", 8 | "description": "Add a spoiler command and a spoiler button for use by mobile users, who lack the ability to spoiler images.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "utilities" 12 | ], 13 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 14 | } 15 | -------------------------------------------------------------------------------- /spoilerer/spoilerer.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from typing import Dict, Final, Optional, TypedDict, Union 4 | 5 | import discord 6 | from redbot.core import Config, commands 7 | from redbot.core.bot import Red 8 | from redbot.core.utils.chat_formatting import quote, spoiler 9 | 10 | button: Final = "\N{WHITE SQUARE BUTTON}" 11 | content_re: Final = re.compile(r"(?i)^(?:image|video)/") 12 | 13 | 14 | class Settings(TypedDict): 15 | enabled: bool 16 | 17 | 18 | class Spoilerer(commands.Cog): 19 | def __init__(self, bot: Red): 20 | super().__init__() 21 | self.bot: Final = bot 22 | self.config: Final[Config] = Config.get_conf( 23 | self, identifier=2_113_674_295, force_registration=True 24 | ) 25 | self.config.register_guild(**Settings(enabled=False)) 26 | 27 | async def initialize(self): 28 | all_guilds: Dict[int, Settings] = await self.config.all_guilds() 29 | self.enabled_guilds = {k for k, v in all_guilds.items() if v["enabled"]} 30 | 31 | @commands.group(invoke_without_command=True) 32 | async def spoiler(self, ctx: commands.Context, *, message: str = None): 33 | """ 34 | Spoilers the attachments provided with the message for you. 35 | 36 | The optional `[message]` argument will be posted along with the spoilered attachments. 37 | The message itself will remain as-is, without any spoilering. 38 | """ 39 | if not any( 40 | attach.content_type and content_re.match(attach.content_type) 41 | for attach in ctx.message.attachments 42 | ): 43 | return await ctx.send("You didn't attach any images or videos for me to spoil.") 44 | await self._spoil(ctx.message, message) 45 | 46 | @commands.mod_or_permissions(manage_messages=True) 47 | @commands.guild_only() 48 | @spoiler.command() 49 | async def message( 50 | self, 51 | ctx: commands.GuildContext, 52 | spoiler_content: Optional[bool], 53 | *, 54 | message: discord.Message = None, 55 | ): 56 | """ 57 | Spoilers the specified message's attachments by deleting it and re-posting it. 58 | 59 | Pass `True` to `spoiler_content` to also spoiler the message text, e.g.: 60 | `[p]spoiler message True 1053802538056548392` 61 | 62 | If `message` is not specified, then this will default to the message you reply to 63 | when using the command. 64 | """ 65 | if not message: 66 | if ctx.message.reference: 67 | message = ctx.message.reference.resolved 68 | else: 69 | await ctx.send_help() 70 | return 71 | if ( 72 | message.author != ctx.me 73 | and not message.channel.permissions_for(ctx.me).manage_messages 74 | ): 75 | raise commands.BotMissingPermissions(discord.Permissions(manage_messages=True)) 76 | await self._spoil( 77 | message, spoiler(message.content) if spoiler_content else message.content 78 | ) 79 | await ctx.tick() 80 | 81 | @commands.admin_or_permissions(manage_messages=True) 82 | @commands.guild_only() 83 | @spoiler.command() 84 | async def button(self, ctx: commands.GuildContext, *, enable: bool): 85 | """ 86 | Enable or disable the spoiler button for this guild. 87 | 88 | The spoiler button adds \N{WHITE SQUARE BUTTON} as a reaction to any attachments 89 | sent by members that are on mobile or that are invisible. 90 | Clicking this button acts as if they used the `[p]spoiler` command. 91 | """ 92 | guild = ctx.guild 93 | if enable: 94 | self.enabled_guilds.add(guild.id) 95 | await self.config.guild(guild).enabled.set(True) 96 | else: 97 | self.enabled_guilds.discard(guild.id) 98 | await self.config.guild(guild).enabled.set(False) 99 | await ctx.send( 100 | f"The {button} spoiler button is {'now' if enable else 'no longer'} enabled" 101 | ) 102 | 103 | @commands.Cog.listener() 104 | async def on_message_without_command(self, message: discord.Message): 105 | author = message.author 106 | if author.bot: 107 | return 108 | if not message.attachments: 109 | return 110 | guild = message.guild 111 | if guild and guild.id not in self.enabled_guilds: 112 | return 113 | if ( 114 | sum(attach.size for attach in message.attachments) 115 | > getattr(guild, "filesize_limit", 1 << 23) - 10_000 116 | ): 117 | return 118 | if all(attach.is_spoiler() for attach in message.attachments): 119 | return 120 | me: Union[discord.ClientUser, discord.Member] = (message.guild or message.channel).me # type: ignore 121 | # 0x2040 - add_reactions, manage_messages 122 | if guild and message.channel.permissions_for(me).value & 0x2040 != 0x2040: # type: ignore 123 | return 124 | for dg in [guild] if guild else filter(None, map(self.bot.get_guild, self.enabled_guilds)): 125 | if (dm := dg.get_member(author.id)) and not await self.bot.cog_disabled_in_guild( 126 | self, dg 127 | ): 128 | break 129 | else: 130 | return 131 | if dm.status != discord.Status.offline and not dm.is_on_mobile(): 132 | return 133 | try: 134 | await message.add_reaction(button) 135 | except discord.Forbidden: 136 | return 137 | 138 | def check(r: discord.Reaction, u: Union[discord.Member, discord.User]): 139 | return r.message == message and r.emoji == button and u == author 140 | 141 | try: 142 | await self.bot.wait_for("reaction_add", timeout=10, check=check) 143 | except asyncio.TimeoutError: 144 | try: 145 | await message.remove_reaction(button, me) 146 | except discord.HTTPException: 147 | return 148 | else: 149 | await self._spoil(message) 150 | 151 | @staticmethod 152 | async def _spoil(message: discord.Message, content: Optional[str] = ...): 153 | if content is ...: 154 | content = message.content 155 | channel = message.channel 156 | files = await asyncio.gather( 157 | *( 158 | attach.to_file(spoiler=True) 159 | for attach in message.attachments 160 | if attach.content_type and content_re.match(attach.content_type) 161 | ) 162 | ) 163 | me: Union[discord.ClientUser, discord.Member] 164 | if guild := message.guild: 165 | assert isinstance(channel, discord.TextChannel) 166 | me = guild.me 167 | if content: 168 | content = f"from {message.author.mention}\n{quote(message.content)}" 169 | else: 170 | content = f"from {message.author.mention}" 171 | if channel.permissions_for(me).manage_messages: 172 | await message.delete(delay=0) 173 | else: 174 | assert isinstance(channel, discord.DMChannel) 175 | me = channel.me 176 | content = None 177 | await message.channel.send( 178 | content, files=files, reference=message.reference, mention_author=False 179 | ) 180 | -------------------------------------------------------------------------------- /theme/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .theme import Theme 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(Theme()) 11 | -------------------------------------------------------------------------------- /theme/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul #1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help theme`. Requires the Audio cog to function.", 6 | "name": "Theme", 7 | "short": "Allows you to set themes to easily play accross all servers.", 8 | "description": "Allows you to set themes to easily play accross all servers.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "music", 12 | "audio", 13 | "fun" 14 | ], 15 | "end_user_data_statement": "This cog stores data provided via command by users for the purposes of replaying." 16 | } 17 | -------------------------------------------------------------------------------- /theme/theme.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from random import choice 3 | 4 | import discord 5 | from redbot.core import Config, commands 6 | from redbot.core.i18n import Translator, cog_i18n 7 | from redbot.core.utils.chat_formatting import bold, italics, pagify, warning 8 | from redbot.core.utils.menus import menu 9 | 10 | _ = Translator("Theme", __file__) 11 | 12 | 13 | def theme_strip(argument): 14 | return [t.strip().strip('"<>"') for t in argument.split(",")] # noqa: B005 15 | 16 | 17 | @cog_i18n(_) 18 | class Theme(commands.Cog): 19 | """ 20 | Allows you to set themes to easily play accross all servers. 21 | """ 22 | 23 | async def red_get_data_for_user(self, *, user_id): 24 | if themes := await self.config.user_from_id(user_id).themes(): 25 | themes_text = "\n".join(themes) 26 | bio = BytesIO( 27 | (f"You currently have the following theme songs saved:\n{themes_text}").encode( 28 | "utf-8" 29 | ) 30 | ) 31 | bio.seek(0) 32 | return {f"{self.__class__.__name__}.txt": bio} 33 | return {} # No data to get 34 | 35 | async def red_delete_data_for_user(self, *, requester, user_id): 36 | # Nothing here is operational, so just delete it all 37 | await self.config.user_from_id(user_id).clear() 38 | 39 | def __init__(self): 40 | super().__init__() 41 | self.config = Config.get_conf(self, identifier=2_113_674_295, force_registration=True) 42 | self.config.register_user(themes=[]) 43 | 44 | @commands.group(invoke_without_command=True, aliases=["themes"]) 45 | @commands.guild_only() 46 | async def theme(self, ctx, *, user: discord.User = None): 47 | """ 48 | Play, view, or configure a user's set theme song(s). 49 | """ 50 | if not ctx.invoked_subcommand: 51 | await ctx.invoke(self.theme_play, user=user) 52 | 53 | @theme.command(name="play") 54 | @commands.guild_only() 55 | async def theme_play(self, ctx, *, user: discord.User = None): 56 | """ 57 | Play a user's set theme song(s). 58 | """ 59 | play = ctx.bot.get_command("play") 60 | if not play: 61 | return await ctx.send(warning(_("Audio cog is not loaded."))) 62 | if not user: 63 | user = ctx.author 64 | themes = await self.maybe_bot_themes(ctx, user) 65 | if not themes: 66 | return await ctx.send(_("{} has not set any themes.").format(user.name)) 67 | theme = choice(themes) 68 | await ctx.invoke(play, query=theme) 69 | 70 | @theme.command(name="add") 71 | async def theme_add(self, ctx, *, new_themes: theme_strip): 72 | """ 73 | Adds the specified themes to your theme list. 74 | 75 | Comma-seperated list. 76 | """ 77 | async with self.config.user(ctx.author).themes() as themes: 78 | themes[:] = set(themes).union(new_themes) 79 | await ctx.send(_("Themes added.")) 80 | 81 | @theme.command(name="remove") 82 | async def theme_remove(self, ctx, *, themes_to_remove: theme_strip): 83 | """ 84 | Removes the specified themes from your theme list. 85 | 86 | Comma-seperated list. 87 | """ 88 | async with self.config.user(ctx.author).themes() as themes: 89 | if not themes: 90 | return await ctx.send(_("You have no themes to remove.")) 91 | themes[:] = set(themes).difference(themes_to_remove) 92 | await ctx.send(_("Themes removed.")) 93 | 94 | @theme.command(name="clear") 95 | async def theme_clear(self, ctx): 96 | """ 97 | Clear your list of themes. 98 | 99 | \N{WARNING SIGN} This action cannot be undone. 100 | """ 101 | if not await self.config.user(ctx.author).themes(): 102 | return await ctx.send(_("You have no themes to remove.")) 103 | 104 | async def clear(ctx, pages, controls, message, *_): 105 | try: 106 | await message.clear_reactions() 107 | except discord.Forbidden: 108 | for key in controls.keys(): 109 | await message.remove_reaction(key, ctx.bot.user) 110 | 111 | async def yes(*args): 112 | # pylint: disable=E1120 113 | await clear(*args) 114 | return True 115 | 116 | async def no(*args): 117 | # pylint: disable=E1120 118 | await clear(*args) 119 | return False 120 | 121 | reply = await menu( 122 | ctx, 123 | [_("Are you sure you wish to clear your themes?")], 124 | {"\N{WHITE HEAVY CHECK MARK}": yes, "\N{CROSS MARK}": no}, 125 | ) 126 | if reply: 127 | await self.config.user(ctx.author).clear() 128 | await ctx.send(_("Themes cleared.")) 129 | else: 130 | await ctx.send(_("Okay, I haven't cleared your themes.")) 131 | 132 | @theme.command(name="list") 133 | async def theme_list(self, ctx, *, user: discord.User = None): 134 | """ 135 | Lists your currently set themes. 136 | """ 137 | if not user: 138 | user = ctx.author 139 | themes = await self.maybe_bot_themes(ctx, user) 140 | if themes: 141 | message = self.pretty_themes(bold(_("{}'s Themes")).format(user.name), themes) 142 | else: 143 | message = "{}\n\n{}".format( 144 | bold(_("{0}'s Themes")), italics(_("{0} has not set any themes.")) 145 | ).format(user.name) 146 | for msg in pagify(message): 147 | await ctx.maybe_send_embed(msg) 148 | 149 | async def maybe_bot_themes(self, ctx, user): 150 | if user == ctx.bot.user: 151 | return ( 152 | "https://youtu.be/zGTkAVsrfg8", 153 | "https://youtu.be/cGMWL8cOeAU", 154 | "https://youtu.be/vFrjMq4aL-g", 155 | "https://youtu.be/WROI5WYBU_A", 156 | "https://youtu.be/41tIUr_ex3g", 157 | "https://youtu.be/f9O2Rjn1azc", 158 | ) 159 | elif user.bot: 160 | return ("https://youtu.be/nMyoI-Za6z8",) 161 | else: 162 | return await self.config.user(user).themes() 163 | 164 | def pretty_themes(self, pre, themes): 165 | themes = "\n".join(f"<{theme}>" for theme in themes) 166 | return f"{pre}\n\n{themes}" 167 | -------------------------------------------------------------------------------- /turn/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core.bot import Red 2 | from redbot.core.utils import get_end_user_data_statement_or_raise 3 | 4 | __red_end_user_data_statement__ = get_end_user_data_statement_or_raise(__file__) 5 | 6 | from .turn import Turn 7 | 8 | 9 | async def setup(bot: Red): 10 | await bot.add_cog(Turn(bot)) 11 | -------------------------------------------------------------------------------- /turn/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": [ 3 | "Zephyrkul (Zephyrkul#1089)" 4 | ], 5 | "install_msg": "Usage: `[p]help turn`", 6 | "name": "Turn", 7 | "short": "Track turns for posting in a channel.", 8 | "description": "Track turns for posting in a channel, and bugs the next person in line until they start typing.", 9 | "min_bot_version": "3.5.0", 10 | "tags": [ 11 | "utility" 12 | ], 13 | "end_user_data_statement": "This cog does not persistently store any data or metadata about users." 14 | } 15 | -------------------------------------------------------------------------------- /turn/namedlist.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from itertools import chain, repeat 3 | 4 | 5 | # from https://stackoverflow.com/questions/2970608/what-are-named-tuples-in-python 6 | class NamedList(Sequence): 7 | """Abstract Base Class for objects that work like mutable 8 | namedtuples. Subclass and define your named fields with 9 | __slots__ and away you go. 10 | """ 11 | 12 | __slots__ = () 13 | 14 | def __init__(self, *args, default=None): 15 | for slot, arg in zip(self.__slots__, chain(args, repeat(default))): 16 | setattr(self, slot, arg) 17 | 18 | def __repr__(self): 19 | return type(self).__name__ + repr(tuple(self)) 20 | 21 | # more direct __iter__ than Sequence's 22 | def __iter__(self): 23 | for name in self.__slots__: 24 | yield getattr(self, name) 25 | 26 | # Sequence requires __getitem__ & __len__: 27 | def __getitem__(self, index): 28 | if isinstance(index, slice): 29 | return [getattr(self, self.__slots__[a]) for a in range(len(self.__slots__))[index]] 30 | return getattr(self, self.__slots__[index]) 31 | 32 | def __len__(self): 33 | return len(self.__slots__) 34 | -------------------------------------------------------------------------------- /turn/turn.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import contextlib 4 | import functools 5 | import typing 6 | from io import BytesIO 7 | 8 | import discord 9 | from redbot.core import Config, checks, commands 10 | from redbot.core.utils import mod 11 | 12 | from .namedlist import NamedList 13 | 14 | 15 | class Game(NamedList): 16 | __slots__ = "queue", "destination", "source", "time", "paused", "task" 17 | 18 | 19 | def standstr(argument): 20 | return "_".join(argument.lower().split()) 21 | 22 | 23 | def nonnegative_int(argument): 24 | i = int(argument) 25 | if i < 0: 26 | raise commands.BadArgument("Argument must not be negative.") 27 | return i 28 | 29 | 30 | def is_all(argument): 31 | if argument.lower() == "all": 32 | return True 33 | raise commands.BadArgument() 34 | 35 | 36 | def skipcheck(func=None, /, **perms: bool): 37 | async def predicate(ctx): 38 | cog = ctx.bot.get_cog("Turn") 39 | if not cog: 40 | return False 41 | queue = cog.get(ctx).queue 42 | if queue and queue[0] == ctx.author: 43 | return True 44 | if await mod.is_mod_or_superior(ctx.bot, ctx.author): 45 | return True 46 | return perms and await mod.check_permissions(ctx, perms) 47 | 48 | if func: 49 | return commands.check(predicate)(func) 50 | return commands.check(predicate) 51 | 52 | 53 | def gamecheck(is_running=True): 54 | def predicate(ctx): 55 | cog = ctx.bot.get_cog("Turn") 56 | if not cog: 57 | return False 58 | return is_running == bool(cog.get(ctx).task) 59 | 60 | return commands.check(predicate) 61 | 62 | 63 | class Turn(commands.Cog): 64 | async def red_get_data_for_user(self, *, user_id): 65 | bio = BytesIO() 66 | all_guilds = await self.config.all_guilds() 67 | for guild_id, data in all_guilds.items(): 68 | for name, game in data["games"].items(): 69 | if user_id in game[0]: 70 | if guild := self.bot.get_guild(guild_id): 71 | guild_name = guild.name 72 | else: 73 | guild_name = f"ID {guild_id}" 74 | bio.write( 75 | f"You are currently saved as a member of game {name!r} in guild {guild_name}".encode( 76 | "utf-8" 77 | ) 78 | ) 79 | if bio.tell(): 80 | bio.seek(0) 81 | return {f"{self.__class__.__name__}.txt": bio} 82 | return {} # No data to get 83 | 84 | async def red_delete_data_for_user(self, *, requester, user_id): 85 | # Nothing here is operational, so just delete it all 86 | async with self.config.get_guilds_lock(): 87 | all_guilds = await self.config.all_guilds() 88 | for guild_id, data in all_guilds.items(): 89 | for name, game in data["games"].items(): 90 | if user_id in game[0]: 91 | game[0].remove(user_id) 92 | await self.config.guild_from_id(guild_id).set_raw( 93 | "games", name, value=game 94 | ) 95 | 96 | def __init__(self, bot): 97 | super().__init__() 98 | self.bot = bot 99 | self.games = {} 100 | self.config = Config.get_conf(self, identifier=2_113_674_295, force_registration=True) 101 | self.config.register_guild(games={}) 102 | 103 | def default(self, ctx): 104 | return self.games.setdefault(ctx.guild, Game(collections.deque())) 105 | 106 | def get(self, ctx): 107 | return self.games.get(ctx.guild, Game(collections.deque())) 108 | 109 | def serialize(self, ctx): 110 | try: 111 | g = list(self.games[ctx.guild])[:4] 112 | g[0] = list(map(lambda m: m.id, g[0])) 113 | g[1], g[2] = g[1].id if g[1] else None, g[2].id if g[2] else None 114 | return g 115 | except KeyError: 116 | return None 117 | 118 | @commands.group(aliases=["turns"]) 119 | @commands.guild_only() 120 | async def turn(self, ctx): 121 | """Manage turns in a channel.""" 122 | 123 | @turn.command() 124 | @checks.mod_or_permissions(manage_channels=True) 125 | @commands.guild_only() 126 | async def add(self, ctx, *members: discord.Member): 127 | """Add members to the queue.""" 128 | if not members: 129 | members = [ctx.author] 130 | self.default(ctx).queue.extend(members) 131 | await ctx.send("Queue: " + ", ".join(map(str, self.get(ctx).queue))) 132 | 133 | @turn.command() 134 | @checks.mod_or_permissions(manage_channels=True) 135 | @commands.guild_only() 136 | @gamecheck(False) 137 | async def load(self, ctx, *, name: standstr): 138 | """Load a previously saved turn set.""" 139 | loaded = await self.config.guild(ctx.guild).get_raw("games", name) 140 | loaded[0] = collections.deque(map(ctx.guild.get_member, loaded[0])) 141 | gc = ctx.guild.get_channel 142 | loaded[1], loaded[2] = gc(loaded[1]), gc(loaded[2]) 143 | g = Game(*loaded) 144 | self.games[ctx.guild] = g 145 | await ctx.send("Queue: " + ", ".join(map(str, self.get(ctx).queue))) 146 | 147 | @turn.command() 148 | @commands.guild_only() 149 | @skipcheck(manage_channels=True) 150 | async def pause(self, ctx): 151 | """Pauses the timer. 152 | 153 | The bot will wait indefinitely for the current member, rather than skipping when time is up. 154 | """ 155 | self.games[ctx.guild].paused = True 156 | await ctx.tick() 157 | 158 | @turn.command() 159 | @checks.mod_or_permissions(manage_channels=True) 160 | @commands.guild_only() 161 | async def remove(self, ctx, all: typing.Optional[is_all] = False, *, member: discord.Member): 162 | """Remove a member from the queue. 163 | 164 | If `remove all` is used, the member is removed completely. 165 | Otherwise, only the member's next turn is removed.""" 166 | with contextlib.suppress(ValueError): 167 | if all: 168 | while True: 169 | self.default(ctx).queue.remove(member) 170 | else: 171 | self.default(ctx).queue.remove(member) 172 | task = self.get(ctx).task 173 | if task: 174 | task.cancel() 175 | await ctx.send("Queue: " + ", ".join(map(str, self.get(ctx).queue))) 176 | 177 | @turn.command() 178 | @checks.mod_or_permissions(manage_channels=True) 179 | @commands.guild_only() 180 | @gamecheck(False) 181 | async def save(self, ctx, *, name: standstr): 182 | """Save the current turn settings to disk.""" 183 | await self.config.guild(ctx.guild).set_raw("games", name, value=self.serialize(ctx)) 184 | await ctx.tick() 185 | 186 | @turn.group(name="set") 187 | @checks.mod_or_permissions(manage_channels=True) 188 | @commands.guild_only() 189 | async def turn_set(self, ctx): 190 | """Configure turn settings.""" 191 | 192 | @turn_set.command() 193 | @checks.mod_or_permissions(manage_channels=True) 194 | @commands.guild_only() 195 | async def destination(self, ctx, *, channel: discord.TextChannel = None): 196 | """Change where the bot announces turns.""" 197 | channel = channel or ctx.channel 198 | g = self.default(ctx) 199 | g.destination = channel 200 | g.source = g.source or channel 201 | await ctx.tick() 202 | 203 | @turn_set.command() 204 | @checks.mod_or_permissions(manage_channels=True) 205 | @commands.guild_only() 206 | async def source(self, ctx, *, channel: discord.TextChannel = None): 207 | """Change where the bot will look for messages.""" 208 | channel = channel or ctx.channel 209 | g = self.default(ctx) 210 | g.source = channel 211 | g.destination = g.destination or channel 212 | await ctx.tick() 213 | 214 | @turn_set.command() 215 | @checks.mod_or_permissions(manage_channels=True) 216 | @commands.guild_only() 217 | async def time(self, ctx, *, time: nonnegative_int): 218 | """Change how long the bot will wait for a message. 219 | 220 | The bot will reset the timer on seeing a typing indicator. 221 | A time of 0 will cause the bot to wait indefinitely.""" 222 | self.default(ctx).time = time 223 | await ctx.tick() 224 | 225 | @turn.command(aliases=["next"]) 226 | @commands.guild_only() 227 | @gamecheck() 228 | @skipcheck() 229 | async def skip(self, ctx, *, amount: int = 1): 230 | """Skip the specified amount of people. 231 | 232 | Specify a negative number to rewind the queue.""" 233 | if not amount or (amount != 1 and not await mod.is_mod_or_superior(ctx.bot, ctx.author)): 234 | return 235 | self.games[ctx.guild].queue.rotate(-amount) 236 | self.games[ctx.guild].task.cancel() 237 | await ctx.tick() 238 | 239 | @turn.command() 240 | @checks.mod_or_permissions(manage_channels=True) 241 | @commands.guild_only() 242 | @gamecheck(False) 243 | async def start(self, ctx): 244 | """Begin detecting and announcing the turn order.""" 245 | g = self.games[ctx.guild] 246 | if not g.queue: 247 | return await ctx.send("Not yet setup.") 248 | g.source = g.source or ctx.channel 249 | g.destination = g.destination or ctx.channel 250 | g.time = 600 if g.time is None else g.time 251 | g.paused = False 252 | g.task = ctx.bot.loop.create_task(self.task(ctx.guild)) 253 | await ctx.tick() 254 | 255 | @turn.command() 256 | @checks.mod_or_permissions(manage_channels=True) 257 | @commands.guild_only() 258 | @gamecheck() 259 | async def stop(self, ctx): 260 | """Stop detecting and announcing the turn order.""" 261 | self.games.pop(ctx.guild).task.cancel() 262 | await ctx.tick() 263 | 264 | def __unload(self): 265 | for k in self.games.copy(): 266 | v = self.games.pop(k) 267 | t = v.task 268 | if t: 269 | t.cancel() 270 | 271 | __del__ = __unload 272 | 273 | cog_unload = __unload 274 | 275 | async def task(self, guild: discord.Guild): 276 | # force a KeyError as soon as possible 277 | get = functools.partial(self.games.__getitem__, guild) 278 | # block the bot until waiting; handle task logic 279 | schedule = self.bot.loop.create_task 280 | 281 | member = get().queue[0] 282 | pings = 1 283 | last = None 284 | 285 | def typing_check(channel, author, _): 286 | return channel == get().source and author == get().queue[0] 287 | 288 | def msg_check(msg): 289 | return msg.channel == get().source and msg.author == get().queue[0] 290 | 291 | typing_coro = functools.partial(self.bot.wait_for, "typing", check=typing_check) 292 | message_coro = functools.partial(self.bot.wait_for, "message", check=msg_check) 293 | 294 | with contextlib.suppress(KeyError): 295 | while self is self.bot.get_cog(self.__class__.__name__): 296 | with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError): 297 | if member != get().queue[0]: 298 | member = get().queue[0] 299 | pings = 1 300 | if not get().paused: 301 | if last: 302 | schedule(last.delete()) 303 | last = await get().destination.send( 304 | f"{member.mention}, you're up. Ping #{pings}." 305 | ) 306 | try: 307 | if get().paused: 308 | timeout = None 309 | elif get().time: 310 | timeout = get().time // 5 311 | else: 312 | timeout = 300 313 | tasks = ( 314 | schedule(typing_coro(timeout=timeout)), 315 | schedule(message_coro(timeout=timeout)), 316 | ) 317 | done, pending = await asyncio.wait( 318 | tasks, return_when=asyncio.FIRST_COMPLETED 319 | ) 320 | for p in pending: 321 | p.cancel() 322 | for d in done: 323 | d.result() # propagate any errors 324 | if not done: 325 | raise asyncio.TimeoutError() 326 | if tasks[1] in done: 327 | get().paused = False 328 | get().queue.rotate(-1) 329 | continue 330 | except asyncio.TimeoutError: 331 | if get().paused or member != get().queue[0]: 332 | continue 333 | if not get().time or pings < 5: 334 | pings += 1 335 | continue 336 | schedule( 337 | get().destination.send( 338 | f"No reply from {member.display_name}. Skipping...", 339 | delete_after=get().time or 300, 340 | ) 341 | ) 342 | else: 343 | timeout = get().time or None 344 | await message_coro(timeout=timeout) 345 | get().paused = False 346 | get().queue.rotate(-1) 347 | if last: 348 | await last.delete() 349 | --------------------------------------------------------------------------------