diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 0d579403..00000000 --- a/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 127 -extend-ignore = E203, W503 -max-complexity = 10 \ No newline at end of file diff --git a/.github/actions/check-json/json_checker.py b/.github/actions/check-json/json_checker.py index 311440f0..77a74ff0 100644 --- a/.github/actions/check-json/json_checker.py +++ b/.github/actions/check-json/json_checker.py @@ -1,5 +1,6 @@ import json import re +import sys from glob import glob from typing import List, Tuple, Union @@ -8,11 +9,7 @@ def format_output(*, level: str, file: str, line: int, col: int, message: str) -> str: return "::{level} file={file},line={line},col={col}::{message}".format( - level=level, - file=file, - line=line, - col=col, - message=message + level=level, file=file, line=line, col=col, message=message ) @@ -36,13 +33,13 @@ def validate(schema_name: str, filename: str) -> bool: error_keys, msg_bounds = list_from_str(error.message) for key in error_keys: line, col = get_key_pos(filename, key) - message = f"{error.message[:msg_bounds[0] + 1]}{key}{error.message[msg_bounds[1] - 1:]}" + message = f"{error.message[: msg_bounds[0] + 1]}{key}{error.message[msg_bounds[1] - 1 :]}" print(format_output(level="error", file=filename, line=line, col=col, message=message)) else: key_name = error.path[1] line, col = get_key_pos(filename, key_name) print(format_output(level="warning", file=filename, line=line, col=col, message=error.message)) - return False + return False def get_key_pos(filename: str, key: str) -> Tuple[int, int]: @@ -72,8 +69,8 @@ def list_from_str(set_str: str) -> Tuple[List[str], Tuple[int, int]]: def main() -> int: validation_success: List[bool] = [] for file_pattern, schema_path in { - "info.json": ".github/actions/check-json/repo.json", - "*/info.json": ".github/actions/check-json/cog.json" + "info.json": ".github/actions/check-json/repo.json", + "*/info.json": ".github/actions/check-json/cog.json", }.items(): for filename in glob(file_pattern): validation_success.append(validate(schema_path, filename)) @@ -83,4 +80,4 @@ def main() -> int: if __name__ == "__main__": exit_code = main() - exit(exit_code) + sys.exit(exit_code) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d2828658..7d4aca1f 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -3,16 +3,16 @@ description: Installs project dependencies runs: using: composite steps: - - name: Set up Python 3.8 - uses: actions/setup-python@v2 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.11" - name: Install base dependencies shell: bash run: pip install --quiet --upgrade --requirement requirements.txt - name: Install CI dependencies shell: bash - run: pip install --quiet --upgrade --requirement requirements-ci.txt + run: pip install --quiet --upgrade --requirement requirements-dev.txt - name: Install cog dependencies shell: bash run: | diff --git a/.github/actions/setup/compile_requirements.py b/.github/actions/setup/compile_requirements.py index 931986f8..4390c647 100644 --- a/.github/actions/setup/compile_requirements.py +++ b/.github/actions/setup/compile_requirements.py @@ -1,4 +1,5 @@ """Pipeline script for extracting imports from cogs""" + import json from glob import glob from typing import Set diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5b2b71c..5eaa48e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,55 +3,33 @@ name: CI on: [pull_request] jobs: - lint-flake8: + ruff: runs-on: ubuntu-latest steps: - name: Checkout the repository at the current branch - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install dependencies uses: ./.github/actions/setup - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings - flake8 . --format="::warning file=%(path)s,line=%(row)d,col=%(col)d::%(text)s" --exit-zero --max-complexity=10 + - uses: astral-sh/ruff-action@v3 + with: + version-file: "./requirements-dev.txt" - lint-black: + pyright: runs-on: ubuntu-latest steps: - name: Checkout the repository at the current branch - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install dependencies uses: ./.github/actions/setup - - name: Lint with black - run: black --diff . - - lint-pylint: - runs-on: ubuntu-latest - steps: - - name: Checkout the repository at the current branch - uses: actions/checkout@v2 - - name: Install dependencies - uses: ./.github/actions/setup - - name: Lint with pylint - run: pylint --msg-template='::warning file={path},line={line},col={column}::{msg}' */ || exit 0 - - lint-isort: - runs-on: ubuntu-latest - steps: - - name: Checkout the repository at the current branch - uses: actions/checkout@v2 - - name: Install dependencies - uses: ./.github/actions/setup - - name: Lint with isort - run: isort --check --diff . + - uses: jakebailey/pyright-action@v2 + with: + python-version: "3.11" check-json: runs-on: ubuntu-latest steps: - name: Checkout the repository at the current branch - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install dependencies uses: ./.github/actions/setup - name: Check cog and repo JSON files against schema @@ -61,8 +39,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository at the current branch - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Install dependencies uses: ./.github/actions/setup - name: Run unit tests run: python3 -m pytest . + + fixmes: + name: FIXME check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rippleFCL/action-fixme-annotate@v0.1.0 + with: + terms: 'WIP|FIXME' + case-sensitive: false + severity: "WARNING" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..0f7956c5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.9.6 + hooks: + # Run the linter. + - id: ruff + args: ["--fix", "--exit-non-zero-on-fix"] + # Run the formatter. + - id: ruff-format + + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.393 + hooks: + - id: pyright diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 4acc08f3..07616dbd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,7 @@ { "recommendations": [ - "ms-python.black-formatter", - "ms-python.isort", - "ms-python.pylint", - "ms-python.python", + "charliermarsh.ruff", "ms-python.vscode-pylance", + "MarkLarah.pre-commit-vscode", ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 6e0e6ba8..00000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Install task requirements", - "type": "process", - "command": "python3", - "args": [ - "-m", - "pip", - "install", - "--requirement", - "requirements-ci.txt", - "--quiet" - ] - }, - { - "label": "Flake8", - "type": "shell", - "dependsOn": "Install task requirements", - "command": "flake8", - "args": [ - ".", - "--format=\"%(path)s:%(row)d:%(col)d %(text)s\"", - "--exit-zero", - "--max-complexity=10", - "--max-line-length=127" - ], - "problemMatcher": { - "fileLocation": [ - "relative", - "${workspaceFolder}" - ], - "pattern": { - "regexp": "^(.*):(\\d+):(\\d+) (.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - } - }, - { - "label": "Lint with Pylint", - "type": "shell", - "dependsOn": "Install task requirements", - "command": "pylint", - "args": [ - "--msg-template='error {path}:{line}:{column} {msg}'", - "*/" - ], - "problemMatcher": { - "fileLocation": [ - "relative", - "${workspaceFolder}" - ], - "pattern": { - "regexp": "^(error|warning) (.*):(\\d+):(\\d+) (.*)$", - "severity": 1, - "file": 2, - "line": 3, - "column": 4, - "message": 5 - } - } - }, - { - "label": "Format with Black", - "type": "shell", - "dependsOn": "Install task requirements", - "command": "black", - "args": [ - "." - ], - "problemMatcher": [] - }, - { - "label": "Sort imports with isort", - "type": "process", - "dependsOn": "Install task requirements", - "command": "isort", - "args": [ - "." - ] - }, - { - "label": "Check info.json files", - "type": "shell", - "dependsOn": "Install task requirements", - "command": "python3", - "args": [ - ".github/scripts/json_checker.py" - ], - "problemMatcher": { - "fileLocation": [ - "relative", - "${workspaceFolder}" - ], - "pattern": { - "regexp": "^(.*):(\\d+):(\\d+) (.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - } - } - ] -} \ No newline at end of file diff --git a/README.md b/README.md index c38b6448..42b286ef 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ Cogs for the [Red-DiscordBot](https://github.com/Cog-Creators/Red-DiscordBot/)-b - [Reactrole](#reactrole) - [Report](#report) - [role\_welcome](#role_welcome) + - [Welcome Message Templating](#welcome-message-templating) + - [Welcome Logic](#welcome-logic) + - [Commands](#commands) - [Roleinfo](#roleinfo) - [Sentry](#sentry) - [Tags](#tags) @@ -114,7 +117,7 @@ This cog displays the number of users banned in the guild with a random selectio - `[p]bancount` - Displays the total ban count using a randomly selected message. - `[p]bancount list` - Lists all the messages that can be used in the guild. - `[p]bancount add ` - Add a message to the guild list. Use `$ban` to insert the ban count in the message. -- `[p]bancount remove ` - Deletes (by index, from the list command) the message from the guild list. +- `[p]bancount remove ` - Deletes (by index, from the list command) the message from the guild list. ### BetterPing @@ -206,14 +209,14 @@ Also availble as a slash command. ### Jail This cog allows users to be "jailed" in their own personal cell (channel). Also archives the channel to disk for future -reference. +reference. -* `[p]jail setup <#category>` - Set the jail category where jail channels will be created in. (category is the channel +* `[p]jail setup <#category>` - Set the jail category where jail channels will be created in. (category is the channel category ID of the jail category) * `[p]jail <@user>` - Jail the specified user. * `[p]jail free <@user>` - Frees the specified user and cleans up the jail channel & role, archives the channel content. * `[p]jail archives` - Lists the archived jails with user, date, and jail UUID. -* `[p]jail archives fetch ` - Sends the specified jail channel archive as a file in the current channel (be +* `[p]jail archives fetch ` - Sends the specified jail channel archive as a file in the current channel (be careful running this in public channels) ### Latex @@ -477,16 +480,15 @@ If you're using VSCode, this repository also includes launch configs for the bot ### Linting your code -The CI will fail unless your code is [PEP8](https://www.python.org/dev/peps/pep-0008/) compliant. +Your code must be passed by our lint & type checkers to be merged: ```bash -pip install -r requirements-ci.txt -isort . # This will fix the order of imports -black . # This will auto-format and fix a lot of common mistakes -pylint * # This will show general pep8-violations +pip install -r requirements-dev.txt -r requirements.txt +pre-commit install +pre-commit run --all-files ``` -If you use [VSCode](https://code.visualstudio.com/) you can use the tasks integrated into the repo to locally run the same tasks as our CI +If you use [VSCode](https://code.visualstudio.com/) you can use the [pre-commit-vscode](https://marketplace.visualstudio.com/items?itemName=MarkLarah.pre-commit-vscode) extension to auto generate tasks for the pre-commit hooks ### Making changes diff --git a/autoreact/autoreact.py b/autoreact/autoreact.py index 05bd4af2..0529d42c 100644 --- a/autoreact/autoreact.py +++ b/autoreact/autoreact.py @@ -1,7 +1,8 @@ """discord red-bot autoreact""" + import asyncio import logging -from typing import Generator, Optional +from typing import Generator, Optional, cast import discord import discord.utils @@ -33,7 +34,12 @@ def __init__(self, bot): @commands.Cog.listener() async def on_message(self, message: discord.Message): - if message.author.bot or not message.guild: + if ( + message.author.bot + or not message.guild + or isinstance(message.channel, discord.DMChannel) + or isinstance(message.channel, discord.PartialMessageable) + ): return reactions = await self.config.guild(message.guild).reactions() @@ -47,11 +53,11 @@ async def on_message(self, message: discord.Message): await message.add_reaction(reaction) except discord.errors.NotFound: log.info( - "Could not react to message %s in channel %s (%s) as the message was not found." + - "Maybe the message was deleted?", + "Could not react to message %s in channel %s (%s) as the message was not found." + + "Maybe the message was deleted?", message.id, - message.channel.name, - message.channel.id + cast(str, message.channel.name), + message.channel.id, ) # Do not continue if channel is whitelisted @@ -65,11 +71,11 @@ async def on_message(self, message: discord.Message): await message.add_reaction(emoji) except discord.errors.NotFound: log.info( - "Could not react to message %s in channel %s (%s) as the message was not found." + - "Maybe the message was deleted?", + "Could not react to message %s in channel %s (%s) as the message was not found." + + "Maybe the message was deleted?", message.id, message.channel.name, - message.channel.id + message.channel.id, ) # Command groups @@ -79,16 +85,16 @@ async def on_message(self, message: discord.Message): async def _autoreact(self, ctx): """Automagically add reactions to messages containing certain phrases""" - @_autoreact.group(name="add", pass_context=True) + @_autoreact.group(name="add", pass_context=True) # type: ignore async def _add(self, ctx): """Add autoreact pairs, channels, or whitelisted channels""" - @_autoreact.group(name="remove", pass_context=True) + @_autoreact.group(name="remove", pass_context=True) # type: ignore async def _remove(self, ctx): """Remove autoreact pairs, channels, or whitelisted channels""" @commands.guild_only() - @_autoreact.command(name="view", aliases=["list"]) + @_autoreact.command(name="view", aliases=["list"]) # type: ignore async def _view(self, ctx, *, object_type): """View the configuration for the autoreact cog @@ -129,7 +135,7 @@ async def _view(self, ctx, *, object_type): # Add commands @commands.guild_only() - @_add.command(name="reaction") + @_add.command(name="reaction") # type: ignore async def _add_reaction(self, ctx, emoji, *, phrase): """Add an autoreact pair @@ -161,7 +167,7 @@ async def _add_reaction(self, ctx, emoji, *, phrase): await ctx.send(embed=success_embed) @commands.guild_only() - @_add.command(name="channel") + @_add.command(name="channel") # type: ignore async def _add_channel(self, ctx, channel: discord.TextChannel, *emojis): """Adds groups of reactions to every message in a channel @@ -180,7 +186,7 @@ async def _add_channel(self, ctx, channel: discord.TextChannel, *emojis): await ctx.send(embed=success_embed) @commands.guild_only() - @_add.command(name="whitelisted_channel") + @_add.command(name="whitelisted_channel") # type: ignore async def _add_whitelisted(self, ctx, channel: discord.TextChannel): """Adds a channel to the reaction whitelist @@ -200,7 +206,7 @@ async def _add_whitelisted(self, ctx, channel: discord.TextChannel): # Remove commands @commands.guild_only() - @_remove.command(name="reaction", aliases=["delete"]) + @_remove.command(name="reaction", aliases=["delete"]) # type: ignore async def _remove_reaction(self, ctx, num: int): """Remove a reaction pair @@ -225,7 +231,7 @@ async def _remove_reaction(self, ctx, num: int): await ctx.send(embed=success_embed) @commands.guild_only() - @_remove.command(name="channel") + @_remove.command(name="channel") # type: ignore async def _remove_channel(self, ctx, channel: discord.TextChannel): """Remove reaction channels @@ -233,7 +239,6 @@ async def _remove_channel(self, ctx, channel: discord.TextChannel): - `[p]autoreact remove channel ` """ async with self.config.guild(ctx.guild).channels() as channels: - if str(channel.id) not in channels.keys(): error_embed = await self.make_error_embed(ctx, error_type="ChannelNotFound") await ctx.send(embed=error_embed) @@ -258,7 +263,7 @@ async def _remove_channel(self, ctx, channel: discord.TextChannel): await ctx.send(embed=success_embed) @commands.guild_only() - @_remove.command(name="whitelisted_channel") + @_remove.command(name="whitelisted_channel") # type: ignore async def _remove_whitelisted(self, ctx, channel: discord.TextChannel): """Remove whitelisted channels @@ -266,7 +271,6 @@ async def _remove_whitelisted(self, ctx, channel: discord.TextChannel): - `[p]autoreact remove whitelisted_channel ` """ async with self.config.guild(ctx.guild).whitelisted_channels() as channels: - if channel.id not in channels: error_embed = await self.make_error_embed(ctx, error_type="ChannelNotFound") await ctx.send(embed=error_embed) @@ -321,7 +325,8 @@ async def ordered_list_from_config(self, guild, object_type="reactions"): async def make_error_embed(self, ctx, error_type: str = ""): error_msgs = { - "InvalidObjectType": "Invalid object. Please provide a valid object type from reactions, channels, whitelisted channels", + "InvalidObjectType": "Invalid object. Please provide a valid object " + "type from reactions, channels, whitelisted channels", "ChannelInWhitelist": "This channel is already in the whitelist", "ChannelNotFound": "Channel not found in config", "NoConfiguration": "No configuration has been set for this object", @@ -351,7 +356,7 @@ async def make_embed_list(self, ctx, object_type: str, items: list): for section in sectioned_list: embed = discord.Embed(title=object_type.capitalize(), colour=await ctx.embed_colour()) for elem in section: - embed.add_field(name="Index", value=count, inline=True) + embed.add_field(name="Index", value=str(count), inline=True) embed.add_field(name="Phrase", value=elem["phrase"], inline=True) embed.add_field(name="Reaction", value=elem["reaction"], inline=True) count += 1 @@ -368,7 +373,7 @@ async def make_embed_list(self, ctx, object_type: str, items: list): value=" ".join(elem["reactions"]), inline=True, ) - embed.add_field(name="​", value="​", inline=True) # ZWJ field + embed.add_field(name="\u200b", value="\u200b", inline=True) # ZWJ field embed_list.append(embed) elif object_type in ("whitelisted channels", "whitelisted_channels"): diff --git a/autoreply/autoreply.py b/autoreply/autoreply.py index 604c78af..be4b2a6d 100644 --- a/autoreply/autoreply.py +++ b/autoreply/autoreply.py @@ -1,4 +1,5 @@ """discord red-bot autoreply""" + import asyncio from typing import Optional @@ -10,6 +11,8 @@ CUSTOM_CONTROLS = {"⬅️": prev_page, "➡️": next_page} +EMBED_TRIM_SIZE = 1010 + class AutoReplyCog(commands.Cog): """AutoReply Cog""" @@ -44,7 +47,7 @@ async def _autoreply(self, ctx): # Commands - @_autoreply.command(name="add") + @_autoreply.command(name="add") # type: ignore async def _add(self, ctx, trigger: str = "", response: str = ""): """Add autoreply trigger""" if not trigger and not response: @@ -80,7 +83,7 @@ def reply_check(message): await ctx.send("✅ Autoreply trigger successfully added") @commands.guild_only() - @_autoreply.command(name="view") + @_autoreply.command(name="view") # type: ignore async def _view(self, ctx): """View the configuration for the autoreply cog""" triggers = await self.ordered_list_from_config(ctx.guild) @@ -107,7 +110,7 @@ async def _view(self, ctx): await ctx.send(embed=error_embed) @commands.guild_only() - @_autoreply.command(name="remove", aliases=["delete"]) + @_autoreply.command(name="remove", aliases=["delete"]) # type: ignore async def _remove(self, ctx, num: int): """Remove a reaction pair @@ -149,8 +152,16 @@ async def make_error_embed(self, ctx, error_type: str = ""): return error_embed async def make_removal_success_embed(self, ctx, trigger_dict: dict): - trigger = trigger_dict["trigger"][:1010] if len(trigger_dict["trigger"]) > 1010 else trigger_dict["trigger"] - response = trigger_dict["response"][:1010] if len(trigger_dict["response"]) > 1010 else trigger_dict["response"] + trigger = ( + trigger_dict["trigger"][:EMBED_TRIM_SIZE] + if len(trigger_dict["trigger"]) > EMBED_TRIM_SIZE + else trigger_dict["trigger"] + ) + response = ( + trigger_dict["response"][:EMBED_TRIM_SIZE] + if len(trigger_dict["response"]) > EMBED_TRIM_SIZE + else trigger_dict["response"] + ) desc = f"**Trigger:**\n{trigger}\n**Response:**\n{response}" embed = discord.Embed( title="Autoreply trigger removed", @@ -160,8 +171,16 @@ async def make_removal_success_embed(self, ctx, trigger_dict: dict): return embed async def make_trigger_embed(self, ctx, trigger_dict: dict, index=None): - trigger = trigger_dict["trigger"][:1010] if len(trigger_dict["trigger"]) > 1010 else trigger_dict["trigger"] - response = trigger_dict["response"][:1010] if len(trigger_dict["response"]) > 1010 else trigger_dict["response"] + trigger = ( + trigger_dict["trigger"][:EMBED_TRIM_SIZE] + if len(trigger_dict["trigger"]) > EMBED_TRIM_SIZE + else trigger_dict["trigger"] + ) + response = ( + trigger_dict["response"][:EMBED_TRIM_SIZE] + if len(trigger_dict["response"]) > EMBED_TRIM_SIZE + else trigger_dict["response"] + ) desc = f"**Trigger:**\n{trigger}\n**Response:**\n{response}" embed = discord.Embed(description=desc, colour=await ctx.embed_colour()) if index: diff --git a/bancount/bancount.py b/bancount/bancount.py index a436c1a9..425ff7a3 100644 --- a/bancount/bancount.py +++ b/bancount/bancount.py @@ -16,18 +16,14 @@ def __init__(self, bot: Red, *args, **kwargs): super().__init__(*args, **kwargs) self.bot = bot - default_guild_config = { - "messages": [ - "Total users banned: $ban!" - ] - } + default_guild_config = {"messages": ["Total users banned: $ban!"]} self.config = Config.get_conf(self, identifier=1289862744207523842001) self.config.register_guild(**default_guild_config) @commands.guild_only() @commands.group(name="bancount", pass_context=True, invoke_without_command=True) - async def _bancount(self, ctx: commands.Context): + async def _bancount(self, ctx: commands.GuildContext): """Displays the total number of users banned.""" async with self.config.guild(ctx.guild).messages() as messages: if len(messages) < 1: @@ -44,11 +40,10 @@ async def _bancount(self, ctx: commands.Context): @checks.mod() @_bancount.command(name="add") - async def _bancount_add(self, ctx: commands.Context, *, message: str): + async def _bancount_add(self, ctx: commands.GuildContext, *, message: str): """Add a message to the message list.""" if self.REPLACER not in message: - await ctx.send( - f"You need to include `{self.REPLACER}` in your message so I know where to insert the count!") + await ctx.send(f"You need to include `{self.REPLACER}` in your message so I know where to insert the count!") return async with self.config.guild(ctx.guild).messages() as messages: messages.append(message) @@ -56,15 +51,12 @@ async def _bancount_add(self, ctx: commands.Context, *, message: str): @checks.mod() @_bancount.command(name="list") - async def _bancount_list(self, ctx: commands.Context): + async def _bancount_list(self, ctx: commands.GuildContext): """Lists the message list.""" async with self.config.guild(ctx.guild).messages() as messages: # Credit to the Notes cog author(s) for this pagify structure pages = list(pagify("\n".join(f"`{i}) {message}`" for i, message in enumerate(messages)))) - embed_opts = { - "title": "Guild's BanCount Message List", - "colour": await ctx.embed_colour() - } + embed_opts = {"title": "Guild's BanCount Message List", "colour": await ctx.embed_colour()} embeds = [ discord.Embed(**embed_opts, description=page).set_footer(text=f"Page {index} of {len(pages)}") for index, page in enumerate(pages, start=1) @@ -72,14 +64,12 @@ async def _bancount_list(self, ctx: commands.Context): if len(embeds) == 1: await ctx.send(embed=embeds[0]) else: - ctx.bot.loop.create_task( - menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, - timeout=180.0) - ) + controls = {"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page} + ctx.bot.loop.create_task(menu(ctx=ctx, pages=embeds, controls=controls, timeout=180.0)) @checks.mod() @_bancount.command(name="remove") - async def _bancount_remove(self, ctx: commands.Context, index: int): + async def _bancount_remove(self, ctx: commands.GuildContext, index: int): """Removes the specified message from the message list.""" async with self.config.guild(ctx.guild).messages() as messages: if index >= len(messages): diff --git a/betterping/betterping.py b/betterping/betterping.py index 4f920916..cf3d0d88 100644 --- a/betterping/betterping.py +++ b/betterping/betterping.py @@ -8,7 +8,7 @@ class BetterPing(commands.Cog): """Upgraded version of the built-in ping command""" - def __init__(self, bot: Red, old_ping: commands.Command): + def __init__(self, bot: Red, old_ping: commands.Command | None): self.bot = bot self.old_ping = old_ping diff --git a/convert/convert.py b/convert/convert.py index ce867a5c..0b82988f 100644 --- a/convert/convert.py +++ b/convert/convert.py @@ -27,39 +27,44 @@ async def convert(self, ctx, *, conversion: str): return arg1, end_unit = conversion.split(" to ") - - amount = decimal.search(arg1).group() - unit = arg1.replace(amount, "").strip() - - arg1 = f"{amount} {unit}" - - try: - result = await ctx.bot.loop.run_in_executor( - None, subprocess.check_output, ["units", arg1, end_unit] - ) - except subprocess.CalledProcessError as e: - error = e.output.decode("utf-8") - # grab the first line for the error type - error_type = error.splitlines()[0] + amount = decimal.search(arg1) + if amount is None: embed = discord.Embed( title="Error", - description=f"Error when converting `{conversion}`\n{error_type}", + description="Error no decimal found", color=discord.Color.red(), ) else: - # the result is line 1 - result = result.decode("utf-8").splitlines()[0] - # remove whitespace at the start and end - result = result.strip() - # check if first line has a * or / - if "*" in result or "/" in result: - # remove the first character - result = result[1:] - # create embed - embed = discord.Embed( - title="Convert", - description=f"`{conversion}`\n`{result.strip()}{end_unit}`", - color=discord.Color.green(), - ) + amount_group = amount.group() + unit = arg1.replace(amount_group, "").strip() + + arg1 = f"{amount_group} {unit}" + + try: + result = await ctx.bot.loop.run_in_executor(None, subprocess.check_output, ["units", arg1, end_unit]) + except subprocess.CalledProcessError as e: + error = e.output.decode("utf-8") + # grab the first line for the error type + error_type = error.splitlines()[0] + embed = discord.Embed( + title="Error", + description=f"Error when converting `{conversion}`\n{error_type}", + color=discord.Color.red(), + ) + else: + # the result is line 1 + result = result.decode("utf-8").splitlines()[0] + # remove whitespace at the start and end + result = result.strip() + # check if first line has a * or / + if "*" in result or "/" in result: + # remove the first character + result = result[1:] + # create embed + embed = discord.Embed( + title="Convert", + description=f"`{conversion}`\n`{result.strip()}{end_unit}`", + color=discord.Color.green(), + ) await ctx.send(embed=embed) diff --git a/custom_msg/custom_msg.py b/custom_msg/custom_msg.py index d0e2d5ec..e25dc1d9 100644 --- a/custom_msg/custom_msg.py +++ b/custom_msg/custom_msg.py @@ -14,11 +14,11 @@ class CustomMsgCog(commands.Cog): async def msg_cmd(self, ctx: commands.Context): pass - @msg_cmd.command(name="create", aliases=["send"]) - async def msg_create(self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None): - if channel is None: - channel = ctx.channel - + @msg_cmd.command(name="create", aliases=["send"]) # type: ignore + async def msg_create(self, ctx: commands.GuildContext, specified_channel: Optional[discord.TextChannel] = None): + channel = ctx.channel + if specified_channel is not None: + channel = specified_channel try: payload = await make_session(ctx) except asyncio.TimeoutError: @@ -26,12 +26,18 @@ async def msg_create(self, ctx: commands.Context, channel: Optional[discord.Text except SessionCancelled: return await ctx.send("Exiting...") - message = await channel.send(**payload) - await ctx.send("Message sent. " + - "For future reference, the message is here: " + - f"https://discord.com/channels/{ctx.guild.id}/{message.channel.id}/{message.id} (ID: {message.id})") + if payload["embed"] is None: + message = await channel.send(content=payload["content"]) + else: + message = await channel.send(content=payload["content"], embed=payload["embed"]) + + await ctx.send( + "Message sent. " + + "For future reference, the message is here: " + + f"https://discord.com/channels/{ctx.guild.id}/{message.channel.id}/{message.id} (ID: {message.id})" + ) - @msg_cmd.command(name="edit") + @msg_cmd.command(name="edit") # type: ignore async def msg_edit(self, ctx: commands.Context, message: discord.Message): if message.author != ctx.me: return await ctx.send("You must specify a message that was sent by the bot.") @@ -43,19 +49,17 @@ async def msg_edit(self, ctx: commands.Context, message: discord.Message): except SessionCancelled: return await ctx.send("Exiting...") - payload = {key: val for key, val in payload.items() if val is not None} - if not payload.get("content") and message.content: if not await InteractiveSession(ctx).get_boolean_answer( - "The original message has message content, but you have not specified any. " + - "Would you like to keep the original content?" + "The original message has message content, but you have not specified any. " + + "Would you like to keep the original content?" ): payload.update({"content": ""}) if not payload.get("embed") and message.embeds: if not await InteractiveSession(ctx).get_boolean_answer( - "The original message has an embed, but you have not specified one. " + - "Would you like to keep the original embed?" + "The original message has an embed, but you have not specified one. " + + "Would you like to keep the original embed?" ): payload.update({"embed": None}) diff --git a/custom_msg/interactive_session.py b/custom_msg/interactive_session.py index 783b3da3..7fbfa287 100644 --- a/custom_msg/interactive_session.py +++ b/custom_msg/interactive_session.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import List, Optional, TypedDict, Union +from typing import List, Optional, Self, TypedDict, Union import discord from redbot.core import commands @@ -50,13 +50,13 @@ async def get_boolean_answer(self, question: str) -> bool: return (await self.get_literal_answer(question, ["y", "n"])) == "y" @classmethod - def from_session(cls, session: InteractiveSession) -> InteractiveSession: + def from_session(cls, session: InteractiveSession) -> Self: return cls(session.ctx) @abstractmethod async def confirm_sample(self) -> bool: """Sends the constructed payload and confirms the user is happy with it.""" - pass + return False class MessageBuilder(InteractiveSession): @@ -77,18 +77,20 @@ async def confirm_sample(self) -> bool: class EmbedBuilder(InteractiveSession): async def get_title(self) -> str: title = await self.get_response("What should the title be?") - if len(title) > 256: + if len(title) > 256: # noqa: PLR2004 await self.ctx.send("The title must be 256 characters or less.") return await self.get_title() return title async def get_description(self, *, send_tutorial: bool = True) -> str: + # fixme: your function is rubbish max_length = 4096 if send_tutorial: await self.ctx.send( f"The description can be up to {max_length} characters in length.\n" - "For this section you may send multiple messages, and you can send `retry()` to clear the description and start again.\n" + "For this section you may send multiple messages, and you can send" + "`retry()` to clear the description and start again.\n" "Sending `finish()` will complete the description and move forward to the next stage." ) description: List[str] = [] @@ -101,7 +103,7 @@ async def get_description(self, *, send_tutorial: bool = True) -> str: elif response == "finish()": break - if sum(map(len, description + [response])) > max_length: + if sum(map(len, [*description, response])) > max_length: remaining_chars = max_length - len("\n".join(description)) - 1 if remaining_chars == 0: if not await self.get_boolean_answer("Max char limit reached. Do you want to submit this description?"): @@ -152,14 +154,11 @@ async def run(self) -> Payload: embed_payload = await EmbedBuilder.from_session(self).run() await self.ctx.send("Embed added.") - self.payload.update({ - "content": message_payload["content"], - "embed": embed_payload["embed"] - }) + self.payload.update({"content": message_payload["content"], "embed": embed_payload["embed"]}) return self.payload async def confirm_sample(self) -> bool: - pass + return False async def make_session(ctx: commands.Context) -> Payload: diff --git a/enforcer/enforcer.py b/enforcer/enforcer.py index ffe12cfd..46eab30d 100644 --- a/enforcer/enforcer.py +++ b/enforcer/enforcer.py @@ -1,6 +1,8 @@ """discord red-bot enforcer""" + import asyncio -from typing import Union +import logging +from typing import ClassVar, Optional, Union, cast import discord from redbot.core import Config, checks, commands @@ -18,11 +20,13 @@ CUSTOM_CONTROLS = {"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page} +log = logging.getLogger("red.rhomelab.enforcer") + class EnforcerCog(commands.Cog): """Enforcer Cog""" - ATTRIBUTES = { + ATTRIBUTES: ClassVar = { KEY_ENABLED: {"type": "bool"}, KEY_MINCHARS: {"type": "number"}, KEY_MAXCHARS: {"type": "number"}, @@ -45,8 +49,16 @@ def __init__(self, bot): self.config.register_guild(**default_guild_settings, force_registration=True) + def _is_valid_channel(self, channel: discord.guild.GuildChannel | None): + if channel is not None and not isinstance(channel, (discord.ForumChannel, discord.CategoryChannel)): + return channel + return False + @commands.Cog.listener() async def on_message(self, message: discord.Message): + if not isinstance(message.guild, discord.Guild): + # The user has DM'd us. Ignore. + return if not self.is_valid_message(message): return @@ -64,6 +76,15 @@ async def on_message(self, message: discord.Message): @commands.Cog.listener() async def on_msg_enforce(self, message: discord.Message, reason: str): + if ( + not isinstance(message.guild, discord.Guild) + or isinstance(message.channel, discord.GroupChannel) + or isinstance(message.channel, discord.DMChannel) + or isinstance(message.channel, discord.PartialMessageable) + ): + # The user has DM'd us. Ignore. + return + await message.delete() author = message.author @@ -76,33 +97,45 @@ async def on_msg_enforce(self, message: discord.Message, reason: str): log_id = await self.config.guild(message.guild).logchannel() if log_id: log_channel = message.guild.get_channel(log_id) - if log_channel: + if channel := self._is_valid_channel(log_channel): try: - await log_channel.send(embed=data) + await channel.send(embed=data) except discord.Forbidden: - await log_channel.send(f"**Message Enforced** - {author.id} - {author} - Reason: {reason}") + await channel.send(f"**Message Enforced** - {author.id} - {author} - Reason: {reason}") + else: + log.warning( + f"Could not find log channel for guild {message.guild.id}, message was: **Message Enforced** " + f"- {author.id} - {author} - Reason: {reason}" + ) if not author.dm_channel: await author.create_dm() - + dm_channel = cast(discord.DMChannel, author.dm_channel) + else: + dm_channel = author.dm_channel try: - await author.dm_channel.send(embed=data) + await dm_channel.send(embed=data) except discord.Forbidden: # User does not allow DMs inform_id = await self.config.guild(message.guild).userchannel() if inform_id: inform_channel = message.guild.get_channel(inform_id) - if inform_channel: - await inform_channel.send(content=author.mention, embed=data) - - @commands.group(name="enforcer") + if channel := self._is_valid_channel(inform_channel): + await channel.send(content=author.mention, embed=data) + else: + log.warning( + f"Could not find inform channel for guild {message.guild.id}, message was: **Message Enforced** " + f"- {author.id} - {author} - Reason: {reason}" + ) + + @commands.group(name="enforcer") # type: ignore @commands.guild_only() @checks.admin() async def _enforcer(self, ctx: commands.Context): pass @_enforcer.command("logchannel") - async def enforcer_logchannel(self, ctx: commands.Context, channel: discord.TextChannel): + async def enforcer_logchannel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Sets the channel to post the enforcer logs. Example: @@ -113,7 +146,7 @@ async def enforcer_logchannel(self, ctx: commands.Context, channel: discord.Text await ctx.send(f"Enforcer log message channel set to `{channel.name}`") @_enforcer.command("userchannel") - async def enforcer_userchannel(self, ctx: commands.Context, channel: discord.TextChannel): + async def enforcer_userchannel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Sets the channel to inform the user of deletion reason, if DMs are unavailable. Example: @@ -130,7 +163,7 @@ async def enforcer_configure( channel: discord.TextChannel, attribute: str, *, - value: str = None, + value: Optional[str] = None, ): """Allows configuration of a channel @@ -163,16 +196,16 @@ async def enforcer_configure( # Validate the input from the user try: - value = await self._validate_attribute_value(attribute, value) + validated_value = await self._validate_attribute_value(attribute, value) except ValueError: await ctx.send("The given value is invalid for that attribute.") return - await self._set_attribute(channel, attribute, value) + await self._set_attribute(channel, attribute, validated_value) await ctx.send(f"Channel has now configured the {attribute} attribute.") @_enforcer.command("status") - async def enforcer_status(self, ctx: commands.Context): + async def enforcer_status(self, ctx: commands.GuildContext): """Prints the status of the enforcement cog Example: @@ -182,10 +215,14 @@ async def enforcer_status(self, ctx: commands.Context): async with self.config.guild(ctx.guild).channels() as channels: for channel_obj in channels: channel = ctx.guild.get_channel(channel_obj["id"]) - conf_str = "\n".join(f"{key} - {channel_obj[key]}" for key in self.ATTRIBUTES if key in channel_obj) - - messages.append(f"📝{channel.mention} - Configuration\n{conf_str}") + if channel: + messages.append(f"📝{channel.mention} - Configuration\n{conf_str}") + else: + messages.append( + f"📝Channel ID {channel_obj['id']} no longer exists but has config, remove it... " + f"- Configuration\n{conf_str}" + ) # Pagify implementation # https://github.com/Cog-Creators/Red-DiscordBot/blob/9698baf6e74f6b34f946189f05e2559a60e83706/redbot/core/utils/chat_formatting.py#L208 @@ -208,7 +245,7 @@ async def enforcer_status(self, ctx: commands.Context): timeout=30.0, ) else: - ctx.send("No configurations found") + await ctx.send("No configurations found") async def _validate_attribute_value(self, attribute: str, value: str) -> Union[str, int, bool]: attribute_type = self.ATTRIBUTES[attribute]["type"] @@ -223,9 +260,8 @@ async def _validate_attribute_value(self, attribute: str, value: str) -> Union[s if attribute_type == "number": if not value.isdigit(): raise ValueError() - value = int(value) - - return value + return int(value) + return value async def _reset_attribute(self, channel: discord.TextChannel, attribute: str): async with self.config.guild(channel.guild).channels() as channels: @@ -264,45 +300,48 @@ def is_valid_message(self, message: discord.Message) -> bool: async def check_enforcer_rules(self, channel: dict, message: discord.Message) -> Union[bool, str]: """Check message against channel enforcer rules""" author = message.author + enforcer_error = "" if not channel.get(KEY_ENABLED): # Enforcing not enabled here return False - if KEY_MINDISCORDAGE in channel and author.created_at: + elif KEY_MINDISCORDAGE in channel and author.created_at: delta = discord.utils.utcnow() - author.created_at if delta.total_seconds() < channel[KEY_MINDISCORDAGE]: # They breached minimum discord age - return "User account not old enough" + enforcer_error = "User account not old enough" - if KEY_MINGUILDAGE in channel and author.joined_at: + elif KEY_MINGUILDAGE in channel and isinstance(author, discord.Member) and author.joined_at: delta = discord.utils.utcnow() - author.joined_at if delta.total_seconds() < channel[KEY_MINGUILDAGE]: # They breached minimum guild age - return "User not in server long enough" + enforcer_error = "User not in server long enough" - if channel.get(KEY_NOTEXT) and not message.content: + elif channel.get(KEY_NOTEXT) and not message.content: # They breached notext attribute - return "Message had no text" + enforcer_error = "Message had no text" - if (KEY_MINCHARS in channel) and (len(message.content) < channel[KEY_MINCHARS]): + elif (KEY_MINCHARS in channel) and (len(message.content) < channel[KEY_MINCHARS]): # They breached minchars attribute - return "Not enough characters" + enforcer_error = "Not enough characters" - if (KEY_MAXCHARS in channel) and (len(message.content) > channel[KEY_MAXCHARS]): + elif (KEY_MAXCHARS in channel) and (len(message.content) > channel[KEY_MAXCHARS]): # They breached maxchars attribute - return "Too many characters" + enforcer_error = "Too many characters" - if channel.get(KEY_NOMEDIA) or channel.get(KEY_REQUIREMEDIA): + elif channel.get(KEY_NOMEDIA) or channel.get(KEY_REQUIREMEDIA): # Check the embeds embeds = await self.check_embeds(message) if channel.get(KEY_NOMEDIA) and (embeds or message.attachments): # They breached nomedia attribute - return "No media allowed" + enforcer_error = "No media allowed" if channel.get(KEY_REQUIREMEDIA) and not (embeds or message.attachments): # They breached requiremedia attribute - return "Requires media attached" + enforcer_error = "Requires media attached" + if enforcer_error: + return enforcer_error return False async def check_embeds(self, message: discord.Message) -> bool: diff --git a/feed/__init__.py b/feed/__init__.py index 65647b8a..35c9cb75 100644 --- a/feed/__init__.py +++ b/feed/__init__.py @@ -11,6 +11,5 @@ async def setup(bot: Red): async def teardown(bot: Red): - await bot.remove_cog(FeedCog()) bot.tree.remove_command("Feed", type=AppCommandType.message) bot.tree.remove_command("Feed", type=AppCommandType.user) diff --git a/feed/feed.py b/feed/feed.py index a1a069e7..fc7d535c 100644 --- a/feed/feed.py +++ b/feed/feed.py @@ -1,4 +1,5 @@ """discord red-bot feed""" + import random import discord diff --git a/isitreadonlyfriday/isitreadonlyfriday.py b/isitreadonlyfriday/isitreadonlyfriday.py index 29c36230..392832ba 100644 --- a/isitreadonlyfriday/isitreadonlyfriday.py +++ b/isitreadonlyfriday/isitreadonlyfriday.py @@ -18,20 +18,18 @@ def __init__(self): async def get_isitreadonlyfriday(self, offset: int) -> discord.Embed: # Get readonly data from isitreadonlyfriday api try: - async with aiohttp.request( - "GET", f"https://isitreadonlyfriday.com/api/isitreadonlyfriday/{offset}" - ) as response: + async with aiohttp.request("GET", f"https://isitreadonlyfriday.com/api/isitreadonlyfriday/{offset}") as response: response.raise_for_status() try: readonly = await response.json() except aiohttp.ContentTypeError: readonly = {"error": "Response content is not JSON"} except aiohttp.ClientError as e: - readonly = {"error": f"Client error: {str(e)}"} + readonly = {"error": f"Client error: {e!s}"} except asyncio.TimeoutError: readonly = {"error": "Request timed out"} except Exception as e: - readonly = {"error": f"An unexpected error occurred: {str(e)}"} + readonly = {"error": f"An unexpected error occurred: {e!s}"} if readonly.get("error"): log.error(f"Error fetching data from API: {readonly['error']}") @@ -44,10 +42,7 @@ async def get_isitreadonlydecember(self, offset: int): utc_now = datetime.datetime.now(datetime.timezone.utc) offset_tz = datetime.timezone(datetime.timedelta(hours=offset)) local = utc_now.astimezone(offset_tz) - data = { - "offset": offset, - "readonly": local.month == 12 - } + data = {"offset": offset, "readonly": local.month == 12} # noqa: PLR2004 return await self.make_readonly_embed(data, "December") @commands.command() diff --git a/jail/abstracts.py b/jail/abstracts.py index 819e750e..b373cf3a 100644 --- a/jail/abstracts.py +++ b/jail/abstracts.py @@ -6,6 +6,7 @@ from redbot.core import Config, commands +# fixme: please use data classes class JailABC(ABC): datetime: int channel_id: int @@ -23,14 +24,24 @@ def __init__(self, **kwargs): for key, val in kwargs.items(): # expected_type: type = self.__annotations__[key] # if not isinstance(val, expected_type): - # raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") + # raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") setattr(self, key, val) @classmethod @abstractmethod - def new(cls, ctx: commands.Context, datetime: int, channel_id: int, role_id: int, active: bool, jailer: int, - user: int, user_roles: List[int], uuid: uuid.UUID): + def new( # noqa: PLR0913 + cls, + ctx: commands.Context, + datetime: int, + channel_id: int, + role_id: int, + active: bool, + jailer: int, + user: int, + user_roles: List[int], + uuid: uuid.UUID, + ): """Initialise the class in a command context""" pass @@ -46,6 +57,9 @@ def to_dict(self) -> dict: pass +# fixme: please use data classes + + class JailSetABC(ABC): jails: List[JailABC] @@ -56,7 +70,7 @@ def __init__(self, **kwargs): for key, val in kwargs.items(): # expected_type: type = self.__annotations__[key] # if not isinstance(val, expected_type): - # raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") + # raise TypeError(f"Expected type {expected_type} for kwarg {key!r}, got type {type(val)} instead") setattr(self, key, val) diff --git a/jail/jail.py b/jail/jail.py index 594ac2ad..423be479 100644 --- a/jail/jail.py +++ b/jail/jail.py @@ -77,10 +77,7 @@ async def _jail_archives(self, ctx: commands.Context, user: discord.User): # Create embeds from pagified data jails_target: Optional[str] = getattr(user, "display_name", str(user.id)) if user is not None else None base_embed_options = { - "title": ( - (f"Jail archives for {jails_target}" if jails_target else "All jails") - + f" - ({num_jails} jails)" - ), + "title": ((f"Jail archives for {jails_target}" if jails_target else "All jails") + f" - ({num_jails} jails)"), "colour": await ctx.embed_colour(), } embeds = [ @@ -92,8 +89,7 @@ async def _jail_archives(self, ctx: commands.Context, user: discord.User): await ctx.send(embed=embeds[0]) else: ctx.bot.loop.create_task( - menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, - timeout=180.0) + menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, timeout=180.0) ) @_jail_archives.command("fetch") @@ -105,11 +101,13 @@ async def _jail_archives_fetch(self, ctx: commands.Context, archive_id: uuid.UUI async with ctx.typing(): try: - with open(archive_path, "r") as file: + # fixme: this is rly bad, dont use possibly blocking functions in async. + with open(archive_path, "r") as file: # noqa: ASYNC230 data = file.read() transmit = discord.File(BytesIO(initial_bytes=data.encode()), filename=archive_file) await ctx.send(file=transmit) except Exception as e: - await ctx.send("Error fetching archive. Likely file not found, maybe a permissions issue. " - "Check the console for details.") + await ctx.send( + "Error fetching archive. Likely file not found, maybe a permissions issue. Check the console for details." + ) print(e) diff --git a/jail/utils.py b/jail/utils.py index 6f2474a8..5279c551 100644 --- a/jail/utils.py +++ b/jail/utils.py @@ -1,5 +1,3 @@ -import datetime -import json import uuid from io import BytesIO from os import path @@ -14,10 +12,19 @@ class Jail(JailABC): - @classmethod - def new(cls, ctx: commands.Context, datetime: int, channel_id: int, role_id: int, active: bool, jailer: int, - user: int, user_roles: List[int], archive_id: uuid.UUID): + def new( # noqa: PLR0913 + cls, + ctx: commands.Context, + datetime: int, + channel_id: int, + role_id: int, + active: bool, + jailer: int, + user: int, + user_roles: List[int], + archive_id: uuid.UUID, + ): return cls( datetime=datetime, channel_id=channel_id, @@ -26,14 +33,22 @@ def new(cls, ctx: commands.Context, datetime: int, channel_id: int, role_id: int jailer=jailer, user=user, user_roles=user_roles, - archive_id=archive_id + archive_id=archive_id, ) @classmethod def from_storage(cls, ctx: commands.Context, data: dict): - return Jail.new(ctx, datetime=data['datetime'], channel_id=data['channel_id'], role_id=data['role_id'], - active=data['active'], jailer=data['jailer'], user=data['user'], user_roles=data['user_roles'], - archive_id=data['archive_id']) + return Jail.new( + ctx, + datetime=data["datetime"], + channel_id=data["channel_id"], + role_id=data["role_id"], + active=data["active"], + jailer=data["jailer"], + user=data["user"], + user_roles=data["user_roles"], + archive_id=data["archive_id"], + ) def to_dict(self) -> dict: return { @@ -44,7 +59,7 @@ def to_dict(self) -> dict: "jailer": self.jailer, "user": self.user, "user_roles": self.user_roles, - "archive_id": str(self.archive_id) + "archive_id": str(self.archive_id), } def __str__(self) -> str: @@ -52,12 +67,9 @@ def __str__(self) -> str: class JailSet(JailSetABC): - @classmethod def new(cls, ctx: commands.Context, jails: List[JailABC]): - return cls( - jails=jails - ) + return cls(jails=jails) @classmethod def from_storage(cls, ctx: commands.Context, data: list): @@ -83,7 +95,6 @@ def deactivate_jail(self, archive_uuid: uuid.UUID): class JailConfigHelper(JailConfigHelperABC): - def __init__(self): self.config = Config.get_conf(self, identifier=1289862744207523842002, cog_name="JailCog") self.config.register_guild(jails={}) @@ -103,26 +114,24 @@ async def create_jail(self, ctx: commands.Context, datetime: int, member: discor return None reason = f"Jail: {ctx.author.name} created a jail for: {member.name}" - role = await ctx.guild.create_role( - name=f"Jail:{member.name}", - mentionable=False, - reason=reason + role = await ctx.guild.create_role(name=f"Jail:{member.name}", mentionable=False, reason=reason) + perms = discord.PermissionOverwrite( + view_channel=True, read_message_history=True, read_messages=True, send_messages=True ) - perms = discord.PermissionOverwrite(view_channel=True, read_message_history=True, read_messages=True, - send_messages=True) channel = await ctx.guild.create_text_channel( name=f"{member.name}-timeout", reason=reason, category=category, news=False, topic=f"{member.display_name} was bad and now we're here. DO NOT LEAVE! Leaving is evading and will " - f"result in an immediate ban.", - nsfw=False + f"result in an immediate ban.", + nsfw=False, ) await channel.set_permissions(role, overwrite=perms) async with self.config.guild(ctx.guild).jails() as jails: - jail = Jail.new(ctx, datetime, channel.id, role.id, True, ctx.author.id, member.id, - [r.id for r in member.roles], None) + jail = Jail.new( + ctx, datetime, channel.id, role.id, True, ctx.author.id, member.id, [r.id for r in member.roles], None + ) if str(member.id) in jails.keys(): jailset = JailSet.from_storage(ctx, jails[str(member.id)]) else: @@ -176,13 +185,11 @@ async def cleanup_jail(self, ctx: commands.Context, jail: JailABC): except NotFound: pass - async def archive_channel(self, ctx: commands.Context, channel: discord.TextChannel, - archive_uuid: uuid.UUID): + async def archive_channel(self, ctx: commands.Context, channel: discord.TextChannel, archive_uuid: uuid.UUID): """Archive supplied channel to an HTML file""" # Copied this from tig because the work was already done :) if ctx.guild is None: raise TypeError("ctx.guild is None") - time = datetime.datetime.utcnow() data_path = data_manager.cog_data_path(self) transcript_file_name = f"{archive_uuid}.html" transcript_path = path.join(data_path, transcript_file_name) @@ -190,7 +197,7 @@ async def archive_channel(self, ctx: commands.Context, channel: discord.TextChan async with ctx.typing(): transcript = await chat_exporter.export( channel=channel, - tz_info="UTC" # Original had this as tz_info= + tz_info="UTC", # Original had this as tz_info= ) if transcript is None: await ctx.send("None transcript") @@ -200,7 +207,8 @@ async def archive_channel(self, ctx: commands.Context, channel: discord.TextChan transcript_object = BytesIO(initial_bytes=transcript.encode()) # Write transcript to storage - with open(transcript_path, "wb") as file: + # fixme: this is rly bad, dont use possibly blocking functions in async. + with open(transcript_path, "wb") as file: # noqa: ASYNC230 file.write(transcript_object.getbuffer()) async def get_jail_by_user(self, ctx: commands.Context, user: discord.User) -> JailABC: diff --git a/letters/letters.py b/letters/letters.py index cf2d1eba..ef82f332 100644 --- a/letters/letters.py +++ b/letters/letters.py @@ -1,7 +1,6 @@ import re from typing import Optional -import discord from redbot.core import commands # Define numbers -> emotes tuple @@ -39,8 +38,10 @@ def correct_punctuation_spacing(input_str: str) -> str: def string_converter(input_str: str) -> str: """Convert a string to discord emojis""" # NOTE In future it would be ideal to convert this function to an advanced converter (https://discordpy.readthedocs.io/en/latest/ext/commands/commands.html#advanced-converters) - # So we can bootstrap the commands.clean_content converter and escape channel/user/role mentions (currently there is no ping exploit; it just looks odd when converted) - # However, the current version of the commands.clean_content converter doesn't actually work on an argument; it scans the whole message content. + # So we can bootstrap the commands.clean_content converter and escape channel/user/role mentions + # (currently there is no ping exploit; it just looks odd when converted) + # However, the current version of the commands.clean_content converter doesn't actually work on an argument; + # it scans the whole message content. # This has been fixed in discord.py 2.0 # Make the whole string lowercase @@ -71,7 +72,7 @@ class Letters(commands.Cog): """Letters cog""" @commands.command() - async def letters(self, ctx: commands.Context, raw: Optional[raw_flag] = False, *, msg: string_converter): + async def letters(self, ctx: commands.Context, raw: Optional[raw_flag] = False, *, msg: string_converter): # type: ignore """Outputs large emote letters (\"regional indicators\") from input text. The result can be outputted as raw emote code using `-raw` flag. @@ -83,7 +84,7 @@ async def letters(self, ctx: commands.Context, raw: Optional[raw_flag] = False, output = f"```{msg}```" if raw else msg # Ensure output isn't too long - if len(output) > 2000: + if len(output) > 2000: # noqa: PLR2004 return await ctx.send("Input too large.") # Send message diff --git a/markov/markov.py b/markov/markov.py index 06a50c28..818b5916 100644 --- a/markov/markov.py +++ b/markov/markov.py @@ -2,6 +2,7 @@ import random import re from io import BytesIO +from typing import Optional import discord from redbot.core import Config, checks, commands @@ -22,11 +23,7 @@ class Markov(commands.Cog): def __init__(self, bot): self.bot = bot self.conf = Config.get_conf(self, identifier=UNIQUE_ID, force_registration=True) - self.conf.register_user( - chains={}, - chain_depth=1, - mode="word", - enabled=False) + self.conf.register_user(chains={}, chain_depth=1, mode="word", enabled=False) self.conf.register_guild(channels=[]) # Red end user data management support @@ -46,12 +43,10 @@ async def red_get_data_for_user(self, *, user_id: int) -> dict[str, BytesIO]: data = f"No data is stored for user with ID {user_id}.\n" return {"user_data.txt": BytesIO(data.encode())} - - async def red_delete_data_for_user(self, *, requester, user_id): + async def red_delete_data_for_user(self, *, requester, user_id): # type: ignore """Delete a user's personal data.""" await self.conf.member(await self.bot.fetch_user(user_id)).clear() - @commands.Cog.listener() async def on_message(self, message): """Process messages from enabled channels for enabled users""" @@ -61,28 +56,27 @@ async def on_message(self, message): # Load the user's markov chain and settings _, chains, depth, mode = await self.get_user_config(message.author) + if not chains or not mode: + return + + model = chains.get(f"{mode}-{depth}", {}) + # Begin all state chains with the control marker + state = CONTROL - # Create a token cleaner - cleaner = lambda x: x + content = message.content.replace("`", "").strip() # Choose a tokenizer mode if mode == "word": - tokenizer = WORD_TOKENIZER - cleaner = lambda x: x.strip() + tokens = [x for x in WORD_TOKENIZER.split(content) if x.strip()] + # Add control character transition to end of token chain + tokens.append(CONTROL) elif mode.startswith("chunk"): - chunk_length = 3 if len(mode) == 5 else mode[5:] + chunk_length = 3 if len(mode) == 5 else mode[5:] # noqa: PLR2004 tokenizer = re.compile(rf"(.{{{chunk_length}}})") - - # Get or create chain for tokenizer settings - model = chains.get(f"{mode}-{depth}", {}) - # Begin all state chains with the control marker - state = CONTROL - # Remove code block formatting and outer whitespace - content = message.content.replace("`", "").strip() - # Split message into cleaned tokens - tokens = [t for x in tokenizer.split(content) if (t := cleaner(x))] - # Add control character transition to end of token chain - tokens.append(CONTROL) + tokens = [x for x in tokenizer.split(content) if x] + else: + # fixme: what to do if mode is set wrong + return # Iterate over the tokens in the message for i, token in enumerate(tokens): @@ -94,7 +88,7 @@ async def on_message(self, message): # Produce sliding state window (ngram) j = 1 + i - depth if i >= depth else 0 - state = "".join(cleaner(x) for x in tokens[j : i + 1]) + state = "".join(x for x in tokens[j : i + 1]) # Store the model chains[f"{mode}-{depth}"] = model @@ -106,13 +100,16 @@ async def on_message(self, message): async def markov(self, ctx: commands.Context): """New users must `enable` and say some words before using `generate`""" - @markov.command() - async def generate(self, ctx: commands.Context, user: discord.abc.User = None): + async def generate(self, ctx: commands.Context, user: Optional[discord.abc.User] = None): """Generate text based on user language models""" if not isinstance(user, discord.abc.User): user = ctx.message.author enabled, chains, depth, mode = await self.get_user_config(user) + + if not chains or not mode: + await ctx.send("Sorry, I don't have any models to use") + return if not enabled: await ctx.send(f"Sorry, {user} won't let me model their speech") return @@ -120,20 +117,18 @@ async def generate(self, ctx: commands.Context, user: discord.abc.User = None): i = 0 while not text: text = await self.generate_text(chains, depth, mode) - if i > 3: + if i > 3: # noqa: PLR2004 await ctx.send("I tried to generate text 3 times, now I'm giving up.") return i += 1 await ctx.send(text[:2000]) - @markov.command() async def enable(self, ctx: commands.Context): """Allow the bot to model your messages and generate text based on that""" await self.conf.user(ctx.author).enabled.set(True) await ctx.send("Markov modelling enabled!") - @markov.command() async def disable(self, ctx: commands.Context): """Disallow the bot from modelling your message or generating text based on your models""" @@ -144,7 +139,6 @@ async def disable(self, ctx: commands.Context): "You may use `[p]markov` reset to delete them.\n" ) - @markov.command() async def mode(self, ctx: commands.Context, mode: str): """Set the tokenization mode for model building @@ -155,19 +149,19 @@ async def mode(self, ctx: commands.Context, mode: str): Separate models will be stored for each combination of mode and depth that you choose. """ + # fixme: error handle mode being wrong + await self.conf.user(ctx.author).mode.set(mode) await ctx.send(f"Token mode set to '{mode}'.") - @markov.command() async def depth(self, ctx: commands.Context, depth: int): """Set the modelling depth (the "n" in "ngrams")""" await self.conf.user(ctx.author).chain_depth.set(depth) await ctx.send(f"Ngram modelling depth set to {depth}.") - @markov.command(aliases=["user_settings"]) - async def show_user(self, ctx: commands.Context, user: discord.abc.User = None): + async def show_user(self, ctx: commands.Context, user: Optional[discord.abc.User] = None): """Show your current settings and models Moderators can also view the settings and models of another member if they specify one. @@ -189,6 +183,11 @@ async def show_user(self, ctx: commands.Context, user: discord.abc.User = None): # Get user configs enabled, chains, depth, mode = await self.get_user_config(user, lazy=False) + + if not chains or not mode: + await ctx.send("Sorry, I don't have any models to use") + return + models = "\n".join(chains.keys()) # Build & send embed @@ -198,18 +197,16 @@ async def show_user(self, ctx: commands.Context, user: discord.abc.User = None): embed.add_field(name="Stored Models", value=models, inline=False) await ctx.send(embed=embed) - @checks.mod() @commands.guild_only() @markov.command(aliases=["guild_settings"]) - async def show_guild(self, ctx: commands.Context): + async def show_guild(self, ctx: commands.GuildContext): """Show current guild settings""" await ctx.send(embed=await self.gen_guild_settings_embed(ctx.guild)) - @checks.is_owner() @markov.command(aliases=["show_config"]) - async def show_global(self, ctx: commands.Context, guild_id: int = None): + async def show_global(self, ctx: commands.Context, guild_id: Optional[int] = None): """Show global summary info or info for `guild_id`""" embed = discord.Embed(title="Markov settings", colour=await ctx.embed_colour()) enabled_channels = "" @@ -247,7 +244,6 @@ async def show_global(self, ctx: commands.Context, guild_id: int = None): embed.add_field(name=f"Enabled {'Members' if guild_id else 'Users'}", value=enabled_users, inline=False) await ctx.send(embed=embed) - @markov.command() async def delete(self, ctx: commands.Context, model: str): """Delete a specific model from your profile""" @@ -259,27 +255,28 @@ async def delete(self, ctx: commands.Context, model: str): else: await ctx.send("Model not found") - @markov.command() async def reset(self, ctx: commands.Context): """Remove all language models from your profile""" await self.conf.user(ctx.author).chains.set({}) - @checks.mod() @commands.guild_only() @markov.command() - async def channelenable(self, ctx: commands.Context, channel: discord.TextChannel = None): + async def channelenable(self, ctx: commands.GuildContext, specified_channel: Optional[discord.TextChannel] = None): """Enable modelling of messages in a channel for enabled users""" - await self.channels_update(ctx, channel or ctx.channel, True) - + channel = specified_channel or ctx.channel + if not isinstance(channel, (discord.StageChannel, discord.Thread, discord.VoiceChannel)): + await self.channels_update(ctx, channel, True) @checks.mod() @commands.guild_only() @markov.command() - async def channeldisable(self, ctx: commands.Context, channel: discord.TextChannel = None): + async def channeldisable(self, ctx: commands.GuildContext, specified_channel: Optional[discord.TextChannel] = None): """Disable modelling of messages in a channel""" - await self.channels_update(ctx, channel or ctx.channel, False) + channel = specified_channel or ctx.channel + if not isinstance(channel, (discord.StageChannel, discord.Thread, discord.VoiceChannel)): + await self.channels_update(ctx, channel, False) # Markov generation functions @@ -314,7 +311,6 @@ async def generate_text(self, chains: dict, depth: int, mode: str): return return "".join(output[:-1]) - async def generate_word_gram(self, model: dict, state: str): """Generate text for word-mode vectorization""" # Remove word boundaries from ngram; whitespace is added back later @@ -322,18 +318,14 @@ async def generate_word_gram(self, model: dict, state: str): # Choose the next word taking into account recorded vector weights gram = await self.choose_gram(model, state) # Don't worry about it ;) - prepend_space = all((state != CONTROL, - gram[-1].isalnum() or gram in "\"([{|", - state[-1] not in "\"([{'/-_")) + prepend_space = all((state != CONTROL, gram[-1].isalnum() or gram in '"([{|', state[-1] not in "\"([{'/-_")) # Format gram return f"{' ' if prepend_space else ''}{gram}" - async def generate_chunk_gram(self, *args): """Generate text for chunk-mode vectorization""" return await self.choose_gram(*args) - async def choose_gram(self, model: dict, state: str): """Here lies the secret sauce""" (gram,) = random.choices( @@ -343,7 +335,7 @@ async def choose_gram(self, model: dict, state: str): # Helper functions - async def channels_update(self, ctx: commands.Context, channel: discord.TextChannel, enable: bool): + async def channels_update(self, ctx: commands.GuildContext, channel: discord.TextChannel, enable: bool): """Update list of channels in which modelling is allowed""" phrase = "enable" if enable else "disable" updated = False @@ -380,7 +372,6 @@ async def get_user_config(self, user: discord.abc.User, lazy: bool = True): mode = (await user_config.mode() or "word").lower() return enabled, chains, depth, mode - async def should_process_message(self, message: discord.Message) -> bool: """Returns true if a message should be processed""" @@ -417,16 +408,14 @@ def no_process(reason: str) -> bool: # Return true (i.e. should process message) if all checks passed return True - - async def get_enabled_channels(self, guild: discord.Guild) -> list[discord.abc.GuildChannel]: + async def get_enabled_channels(self, guild: discord.Guild) -> list[discord.guild.GuildChannel]: """Retrieve a list of enabled channels in a given guild""" # Retrieve and iterate over enabled channels in specified guild, # appending each channel to the list of enabled channels. async with self.conf.guild(guild).channels() as channels: - enabled_channels = [guild.get_channel(channel) for channel in channels] + enabled_channels = [channel for channel_name in channels if (channel := guild.get_channel(channel_name))] return enabled_channels - async def get_enabled_users(self, guild_id: int) -> dict: """Retrieve a list of enabled users in a given guild""" enabled_users = 0 @@ -456,7 +445,6 @@ async def get_enabled_users(self, guild_id: int) -> dict: # Return dict of enabled users and users with no mutual guild return {"enabled": enabled_users, "no_mutual": users_no_mutual} - async def gen_guild_settings_embed(self, guild: discord.Guild) -> discord.Embed: """Generate guild settings embed""" enabled_channels = "" diff --git a/notes/abstracts.py b/notes/abstracts.py index 64713f14..bdb0ec13 100644 --- a/notes/abstracts.py +++ b/notes/abstracts.py @@ -6,6 +6,8 @@ MAYBE_MEMBER = Union[discord.Member, discord.Object] +# fixme: please use data classes + class NoteABC(ABC): """Represents a note record""" diff --git a/notes/notes.py b/notes/notes.py index 3569c58c..73184931 100644 --- a/notes/notes.py +++ b/notes/notes.py @@ -1,4 +1,5 @@ """discord red-bot notes""" + from __future__ import annotations from typing import Optional @@ -12,7 +13,8 @@ def invoked_warning_cmd(ctx: commands.Context) -> bool: - """Useful for finding which alias triggered the command. Checks against the invoked parents attribute. Can only be used in subcommands.""" + """Useful for finding which alias triggered the command. Checks + against the invoked parents attribute. Can only be used in subcommands.""" return ctx.invoked_parents[0].startswith("warning") @@ -138,8 +140,7 @@ async def notes_list(self, ctx: commands.Context, *, user: Optional[MAYBE_MEMBER await ctx.send(embed=embeds[0]) else: ctx.bot.loop.create_task( - menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, - timeout=180.0) + menu(ctx=ctx, pages=embeds, controls={"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page}, timeout=180.0) ) @checks.bot_has_permissions(embed_links=True) @@ -154,7 +155,7 @@ async def notes_status(self, ctx: commands.Context): await ctx.send( embed=( discord.Embed(title="Notes Status", colour=await ctx.embed_colour()) - .add_field(name="Notes", value=str(len([n for n in all_notes if not n.is_warning]))) - .add_field(name="Warnings", value=str(len([n for n in all_notes if n.is_warning]))) + .add_field(name="Notes", value=str(len([n for n in all_notes if not n.is_warning]))) + .add_field(name="Warnings", value=str(len([n for n in all_notes if n.is_warning]))) ) ) diff --git a/notes/utils.py b/notes/utils.py index 16f7abd0..3a33ea45 100644 --- a/notes/utils.py +++ b/notes/utils.py @@ -36,7 +36,7 @@ def from_storage(cls, ctx: commands.Context, data: dict, *, is_warning: bool = F message=data["message"], reporter_id=data["reporter"], reporter_name=data["reporterstr"], - created_at=int(data["date"]), # FIXME: Migrate all stored values to int + created_at=int(data["date"]), # FIXME: Migrate all stored values to int deleted=data["deleted"], is_warning=is_warning, _guild=ctx.guild, @@ -46,7 +46,10 @@ def __str__(self) -> str: icon = "\N{WARNING SIGN}" if self.is_warning else "\N{MEMO}" member_name = self._guild.get_member(self.member_id) or self.member_id reporter_name = self._guild.get_member(self.reporter_id) or self.reporter_name - return f"{icon} #{self.note_id} **{member_name} - Added by {reporter_name}** - \n{self.message}" + return ( + f"{icon} #{self.note_id} **{member_name} - Added by {reporter_name}** " + "- \n{self.message}" + ) def __lt__(self, other) -> bool: return self.created_at < other.created_at diff --git a/penis/penis.py b/penis/penis.py index ef324827..a5816380 100644 --- a/penis/penis.py +++ b/penis/penis.py @@ -33,7 +33,7 @@ async def penis(self, ctx, *users: discord.Member): dongs = sorted(dongs.items(), key=lambda x: x[1]) for user, dong in dongs: - if len(dong) <= 6: + if len(dong) <= 6: # noqa: PLR2004 msg += "**{}'s size:**\n{}\nlol small\n".format(user.display_name, dong) else: msg += "**{}'s size:**\n{}\n".format(user.display_name, dong) diff --git a/phishingdetection/phishingdetection.py b/phishingdetection/phishingdetection.py index fc3907ba..8cd5bfa6 100644 --- a/phishingdetection/phishingdetection.py +++ b/phishingdetection/phishingdetection.py @@ -1,4 +1,5 @@ """discord red-bot phishing link detection""" + import re from typing import Callable, List, Literal, Optional, Set, TypedDict @@ -46,19 +47,21 @@ async def get_updates_from_timeframe(session: aiohttp.ClientSession, num_seconds class PhishingDetectionCog(commands.Cog): """Phishing link detection cog""" - bot: Red + predicate: Optional[Callable[[str], bool]] = None urls: Set[str] session: aiohttp.ClientSession def __init__(self, bot: Red): self.bot = bot - self.session = aiohttp.ClientSession(headers={ - "X-Identity": "A Red-DiscordBot instance using the phishingdetection cog from https://github.com/rhomelab/labbot-cogs" - }) + self.session = aiohttp.ClientSession( + headers={ + "X-Identity": "A Red-DiscordBot instance using the phishingdetection cog from https://github.com/rhomelab/labbot-cogs" + } + ) self.initialise_url_set.start() - def cog_unload(self): + async def cog_unload(self): self.initialise_url_set.cancel() self.update_urls.cancel() self.bot.loop.run_until_complete(self.session.close()) @@ -75,14 +78,15 @@ async def initialise_url_set(self): self.predicate = generate_predicate_from_urls(self.urls) self.update_urls.start() - self.initialise_url_set.cancel() + self.initialise_url_set.cancel() # type: ignore @tasks.loop(hours=1.0) async def update_urls(self): """Fetch the list of phishing URLs and update the regex pattern""" # TODO: Use the websocket API to get live updates # Using 3660 (1 hour + 1 minute) instead of 3600 (1 hour) to prevent missing updates - # This is fine, as we store the URLs in a set, so duplicate add/remove operations do not result in missing/duplicate data + # This is fine, as we store the URLs in a set, + # so duplicate add/remove operations do not result in missing/duplicate data updates = await get_updates_from_timeframe(self.session, 3600) for update in updates: if update["type"] == "add": diff --git a/prometheus_exporter/main.py b/prometheus_exporter/main.py index 9e848a29..2069ed86 100644 --- a/prometheus_exporter/main.py +++ b/prometheus_exporter/main.py @@ -40,9 +40,7 @@ def create_server(address: str, port: int): return promServer(address, port) @staticmethod - def create_stat_api( - prefix: str, poll_frequency: int, bot: Red, server: PrometheusMetricsServer - ) -> statApi: + def create_stat_api(prefix: str, poll_frequency: int, bot: Red, server: PrometheusMetricsServer) -> statApi: return Poller(prefix, poll_frequency, bot, server) @commands.group() @@ -96,16 +94,17 @@ async def show_config(self, ctx: commands.Context): def start(self): self.prom_server = self.create_server(self.address, self.port) - self.stat_api = self.create_stat_api( - "discord_metrics", self.poll_frequency, self.bot, self.prom_server - ) + self.stat_api = self.create_stat_api("discord_metrics", self.poll_frequency, self.bot, self.prom_server) self.prom_server.serve() self.stat_api.start() def stop(self): - self.prom_server.stop() - self.stat_api.stop() + if self.prom_server: + self.prom_server.stop() + if self.stat_api: + self.stat_api.stop() + logger.info("stopped server process") def reload(self): @@ -114,6 +113,6 @@ def reload(self): self.start() logger.info("reloading complete") - def cog_unload(self): + async def cog_unload(self): self.stop() logger.info("cog unloading") diff --git a/prometheus_exporter/prom_server.py b/prometheus_exporter/prom_server.py index 6e912114..af11da1b 100644 --- a/prometheus_exporter/prom_server.py +++ b/prometheus_exporter/prom_server.py @@ -18,17 +18,14 @@ def log_message(self, format, *args): class MetricsServer(Protocol): - def serve(self) -> None: - ... + def serve(self) -> None: ... - def stop(self) -> None: - ... + def stop(self) -> None: ... class PrometheusMetricsServer(MetricsServer, Protocol): @property - def registry(self) -> CollectorRegistry: - ... + def registry(self) -> CollectorRegistry: ... class promServer(PrometheusMetricsServer): @@ -73,4 +70,3 @@ def stop(self) -> None: self.server.server_close() self.server_thread.join() logger.debug("prom server thread joined") - diff --git a/prometheus_exporter/stats.py b/prometheus_exporter/stats.py index 4e79caa8..080abff0 100644 --- a/prometheus_exporter/stats.py +++ b/prometheus_exporter/stats.py @@ -16,14 +16,11 @@ class statApi(Protocol): - def __init__(self, prefix: str, poll_frequency: int, bot: Red, server: "PrometheusMetricsServer"): - ... + def __init__(self, prefix: str, poll_frequency: int, bot: Red, server: "PrometheusMetricsServer"): ... - def start(self) -> None: - ... + def start(self) -> None: ... - def stop(self) -> None: - ... + def stop(self) -> None: ... class Poller(statApi): @@ -33,23 +30,14 @@ def __init__(self, prefix: str, poll_frequency: int, bot: Red, server: "Promethe self.poll_frequency = poll_frequency self.poll_task: Optional[asyncio.Task] = None - self.bot_latency_gauge = Gauge( - f"{prefix}_bot_latency_seconds", - "the latency to discord", - registry=self.registry - ) + self.bot_latency_gauge = Gauge(f"{prefix}_bot_latency_seconds", "the latency to discord", registry=self.registry) self.total_guild_gauge = Gauge( - f"{prefix}_total_guilds_count", - "the total number of guilds this bot is in", - registry=self.registry + f"{prefix}_total_guilds_count", "the total number of guilds this bot is in", registry=self.registry ) self.guild_stats_gauge = Gauge( - f"{prefix}_guild_stats_count", - "counter stats for each guild", - ["server_id", "stat_type"], - registry=self.registry + f"{prefix}_guild_stats_count", "counter stats for each guild", ["server_id", "stat_type"], registry=self.registry ) self.guild_user_status_gauge = Gauge( @@ -111,20 +99,25 @@ async def gather_user_status_stats(self, guild: discord.Guild): for client_type, statuses in data_types.items(): for status, count in statuses.items(): - logger.debug("setting user status gauge server_id:%d, client_type:%s, status:%s, data:%d", guild.id, client_type, status, count) + logger.debug( + "setting user status gauge server_id:%d, client_type:%s, status:%s, data:%d", + guild.id, + client_type, + status, + count, + ) self.guild_user_status_gauge.labels(server_id=guild.id, client_type=client_type, status=status).set(count) @timeout async def gather_user_activity_stats(self, guild: discord.Guild): logger.debug("gathering user activity stats") - data_types = {value.name: 0 for value in discord.ActivityType if "unknown" not in value.name } + data_types = {value.name: 0 for value in discord.ActivityType if "unknown" not in value.name} for member in guild.members: if member.activity is not None and member.activity.type.name in data_types: data_types[member.activity.type.name] += 1 logger.debug("post user activity stats collection") - for data_type, data in data_types.items(): logger.debug( "setting user activity gauge server_id:%d, activity:%s, data:%d", @@ -140,9 +133,7 @@ async def gather_voice_stats(self, guild: discord.Guild): logger.debug("voice channel count: %d", len(guild.voice_channels)) for vc in guild.voice_channels: - data_types = { - "capacity": len(vc.members) - } + data_types = {"capacity": len(vc.members)} for data_type, data in data_types.items(): logger.debug( @@ -183,6 +174,7 @@ async def poll_loop(): while True: await self.poll() await asyncio.sleep(self.poll_frequency) + logger.debug("creating polling loop") self.poll_task = self.bot.loop.create_task(poll_loop()) diff --git a/prometheus_exporter/utils.py b/prometheus_exporter/utils.py index 679cd7c1..7a911e41 100644 --- a/prometheus_exporter/utils.py +++ b/prometheus_exporter/utils.py @@ -1,10 +1,10 @@ import asyncio import logging from functools import wraps -from typing import TYPE_CHECKING, Awaitable, Self +from typing import TYPE_CHECKING, Awaitable if TYPE_CHECKING: - from stats import Poller + pass logger = logging.getLogger("red.rhomelab.prom.utils") @@ -18,5 +18,5 @@ async def inner(self, *args, **kwargs) -> None: return await f(self, *args, **kwargs) except Exception as e: logger.exception(e) - return inner + return inner diff --git a/purge/purge.py b/purge/purge.py index e3f4a84f..9cac75f5 100644 --- a/purge/purge.py +++ b/purge/purge.py @@ -1,4 +1,5 @@ """discord red-bot purge""" + import asyncio from datetime import datetime, timedelta @@ -29,7 +30,7 @@ def __init__(self, bot): self.purge_task = self.bot.loop.create_task(self.check_purgeable_users()) - def cog_unload(self): + async def cog_unload(self): self.purge_task.cancel() async def set_crontab(self, guild, crontab): @@ -99,14 +100,11 @@ async def _purge_users(self, guild: discord.Guild, title: str): if not result: pass new_list = users_kicked + "\n" + await self._get_safe_username(user) - if len(new_list) > 2048: + if len(new_list) > 2048: # noqa: PLR2004 break users_kicked = new_list - data = discord.Embed( - colour=discord.Colour.orange(), - timestamp=datetime.utcnow() - ) + data = discord.Embed(colour=discord.Colour.orange(), timestamp=datetime.utcnow()) data.title = f"{title} Purge - Purged {len(users)}" data.description = users_kicked @@ -156,14 +154,14 @@ async def get_purgeable_users(self, guild): return members - @commands.group(name="purge") + @commands.group(name="purge") # type: ignore @commands.guild_only() @checks.mod() async def _purge(self, ctx: commands.Context): pass @_purge.command("logchannel") - async def purge_logchannel(self, ctx: commands.Context, channel: discord.TextChannel): + async def purge_logchannel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Logs details of purging to this channel. The bot must have permission to write to this channel. @@ -174,7 +172,7 @@ async def purge_logchannel(self, ctx: commands.Context, channel: discord.TextCha await ctx.send("Purge log channel set.") @_purge.command("execute") - async def purge_execute(self, ctx: commands.Context): + async def purge_execute(self, ctx: commands.GuildContext): """Executes a purge. Users will be **kicked** if they haven't verified. @@ -193,7 +191,7 @@ async def purge_execute(self, ctx: commands.Context): await ctx.send("I need the `Embed links` permission to send " + "a purge board.") @_purge.command("simulate") - async def purge_simulate(self, ctx: commands.Context): + async def purge_simulate(self, ctx: commands.GuildContext): """Simulates a purge. Users will be **detected** if they haven't verified. @@ -208,7 +206,7 @@ async def purge_simulate(self, ctx: commands.Context): for user in users: new_desc = data.description + "\n" + await self._get_safe_username(user) - if len(new_desc) > 2048: + if len(new_desc) > 2048: # noqa: PLR2004 break data.description = new_desc @@ -218,7 +216,7 @@ async def purge_simulate(self, ctx: commands.Context): await ctx.send("I need the `Embed links` permission to " + "send a purge simulation board.") @_purge.command("exclude") - async def purge_exclude_user(self, ctx: commands.Context, user: discord.Member): + async def purge_exclude_user(self, ctx: commands.GuildContext, user: discord.Member): """Excludes an otherwise eligible user from the purge. Example: @@ -238,7 +236,7 @@ async def purge_exclude_user(self, ctx: commands.Context, user: discord.Member): await ctx.send("That user is already safe from pruning!") @_purge.command("include") - async def purge_include_user(self, ctx: commands.Context, user: discord.Member): + async def purge_include_user(self, ctx: commands.GuildContext, user: discord.Member): """Includes a possibly-eligible user in the purge checks. Example: @@ -258,7 +256,7 @@ async def purge_include_user(self, ctx: commands.Context, user: discord.Member): await ctx.send("That user is already not safe from pruning!") @_purge.command("minage") - async def purge_minage(self, ctx: commands.Context, minage: int): + async def purge_minage(self, ctx: commands.GuildContext, minage: int): """Sets the number of days a user can remain in the server with no roles before being purged. @@ -272,7 +270,7 @@ async def purge_minage(self, ctx: commands.Context, minage: int): await ctx.send(f"Set the new minimum age to {minage} days.") @_purge.command("schedule") - async def purge_schedule(self, ctx: commands.Context, schedule: str): + async def purge_schedule(self, ctx: commands.GuildContext, schedule: str): """Sets how often the bot should purge users. Accepts cron syntax. For instance `30 02 */2 * *` would be every 2 days at 02:30. @@ -288,7 +286,7 @@ async def purge_schedule(self, ctx: commands.Context, schedule: str): await ctx.send(f"Set the schedule to `{new_shedule}`.") @_purge.command("enable") - async def purge_enable(self, ctx: commands.Context): + async def purge_enable(self, ctx: commands.GuildContext): """Enables automated purges based on the schedule. Example: @@ -298,7 +296,7 @@ async def purge_enable(self, ctx: commands.Context): await ctx.send("Enabled the purge task.") @_purge.command("disable") - async def purge_disable(self, ctx: commands.Context): + async def purge_disable(self, ctx: commands.GuildContext): """Disables automated purges based on the schedule. Example: @@ -308,7 +306,7 @@ async def purge_disable(self, ctx: commands.Context): await ctx.send("Disabled the purge task.") @_purge.command("status") - async def purge_status(self, ctx: commands.Context): + async def purge_status(self, ctx: commands.GuildContext): """Status of the bot. The bot will display how many users it has kicked since it's inception. diff --git a/pyproject.toml b/pyproject.toml index 7b2c51b4..e7e57f5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,37 +1,13 @@ -[tool.black] - line-length = 127 - target-version = ["py38"] - exclude = "env" - -[tool.isort] - multi_line_output = 3 - include_trailing_comma = true - force_grid_wrap = 0 - use_parentheses = true - ensure_newline_before_comments = true - line_length = 127 - skip = "env" - profile = "black" - -[tool.pylint.MASTER] - disable = [ - "C0114", # Missing module docstring - ] - max-attributes = 12 - max-branches = 20 +[tool.pytest.ini_options] +asyncio_mode = "auto" -[tool.pylint.FORMAT] - max-line-length = 127 +[tool.ruff.lint] +select = ["F", "E", "W", "I", "ASYNC", "PL", "RUF"] -[tool.pylint.SIMILARITIES] - # Minimum lines number of a similarity. - min-similarity-lines = 10 - # Ignore comments when computing similarities. - ignore-comments = "yes" - # Ignore docstrings when computing similarities. - ignore-docstrings = "yes" - # Ignore imports when computing similarities. - ignore-imports = "yes" +[tool.ruff] +line-length = 127 -[tool.pytest.ini_options] -asyncio_mode = "auto" +[tool.pyright] +venvPath = "." +venv = ".venv" +exclude = [".venv", "jail", "notes", "tags", "prometheus_exporter"] diff --git a/quotes/quotes.py b/quotes/quotes.py index 189dae97..0901275a 100644 --- a/quotes/quotes.py +++ b/quotes/quotes.py @@ -1,6 +1,7 @@ """discord red-bot quotes""" + import asyncio -from typing import List, Optional, Tuple +from typing import List, Optional, Sequence import discord from redbot.core import Config, checks, commands @@ -32,7 +33,7 @@ async def _quotes(self, ctx: commands.Context): @commands.guild_only() @checks.mod() @_quotes.command(name="setchannel") - async def set_quotes_channel(self, ctx: commands.Context, channel: discord.TextChannel): + async def set_quotes_channel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Set the quotes channel for this server Usage: @@ -47,9 +48,10 @@ async def set_quotes_channel(self, ctx: commands.Context, channel: discord.TextC ) await ctx.send(embed=success_embed) + # fixme: too many branches, i cba to refactor this now @commands.guild_only() @_quotes.command(name="add") - async def add_quote(self, ctx: commands.Context, *message_ids: Tuple[str]): + async def add_quote(self, ctx: commands.GuildContext, *message_ids: str): """Add a message or set of messages to the quotes channel Usage: @@ -61,35 +63,20 @@ async def add_quote(self, ctx: commands.Context, *message_ids: Tuple[str]): if not message_ids: return await self.send_error(ctx, error_type="NoArgs") - messages = [] # Collect the messages async with ctx.channel.typing(): - for i, elem in enumerate(message_ids): - if len(messages) != i: - return await self.send_error( - ctx, - custom_msg=f"Could not find message with ID `{message_ids[i - 1]}`", - ) - for channel in ctx.guild.channels: - try: - message = await channel.fetch_message(int(elem)) - messages.append(message) - # Could be ValueError if the ID isn't int convertible or NotFound if it's not a valid ID - except ValueError: - continue - except discord.NotFound: - continue + messages = await self._get_messages(ctx, message_ids) - authors = set([m.author for m in messages]) + if not messages: + return - if len(authors) > 1: - formatted_quote = "\n".join( - map(lambda m: f"**{m.author.nick if m.author.nick else m.author.name}:** {m.content}", messages) - ) - else: - formatted_quote = "\n".join(map(lambda m: m.content, messages)) + quote_fragments = [] + for message in messages: + quote_fragments.append(f"**{self._get_author_name(message)}:** {message.content}") + + formatted_quote = "\n".join(quote_fragments) - quote_embed = await self.make_quote_embed(ctx, formatted_quote, messages, authors) + quote_embed = await self.make_quote_embed(ctx, formatted_quote, messages) quote_channel = await self.config.guild(ctx.guild).quote_channel() if not quote_channel: @@ -104,7 +91,7 @@ async def add_quote(self, ctx: commands.Context, *message_ids: Tuple[str]): msg = await ctx.send(embed=quote_embed, content="Are you sure you want to send this quote?") # If sending the quote failed for any reason. For example, quote exceeded the character limit except Exception as err: - return await self.send_error(ctx, custom_msg=err) + return await self.send_error(ctx, custom_msg=str(err)) confirmation = await self.get_confirmation(ctx, msg) if confirmation: @@ -114,21 +101,53 @@ async def add_quote(self, ctx: commands.Context, *message_ids: Tuple[str]): # Helper functions + async def _get_author_name(self, message: discord.Message) -> str: + author = message.author + if isinstance(author, discord.Member): + return f"{author.nick if author.nick else author.name}" + return f"{author.name}" + + async def _get_messages(self, ctx: commands.GuildContext, message_ids: Sequence[str]) -> List[discord.Message]: + messages: list[discord.Message] = [] + errored_mids: list[str] = [] + # FIXME: dont do it like this + for elem in message_ids: + for _channel in ctx.guild.channels: + if channel := self._is_valid_channel(_channel): + try: + message = await channel.fetch_message(int(elem)) + messages.append(message) + break + # Could be ValueError if the ID isn't int convertible or NotFound if it's not a valid ID + except (ValueError, discord.NotFound): + pass + else: + # message not found in any channel + errored_mids.append(elem) + + if errored_mids and len(errored_mids) < len(message_ids): + error_msg = f"The following message IDs were not found: {', '.join(errored_mids)}" + await self.send_error(ctx, custom_msg=error_msg) + elif errored_mids and len(errored_mids) == len(message_ids): + await self.send_error(ctx, custom_msg="None of the provided message IDs were found!") + return messages + async def make_quote_embed( self, ctx: commands.Context, formatted_quote: str, messages: List[discord.Message], - authors: List[discord.Member], ) -> discord.Embed: """Generate the quote embed to be sent""" + authors = [message.author for message in messages] author_list = " ".join([i.mention for i in authors]) # List of channel mentions channels: List[str] = [] - for channel in [i.channel for i in messages]: - if channel.mention not in channels: + for _channel in [i.channel for i in messages]: + if channel := self._is_valid_channel(_channel): channels.append(channel.mention) + unique_channels = set(channels) return ( discord.Embed( @@ -137,23 +156,25 @@ async def make_quote_embed( ) .add_field(name="Authors", value=author_list, inline=False) .add_field(name="Submitted by", value=ctx.author.mention) - .add_field( - **{"name": "Channels", "value": "\n".join(channels)} - if len(channels) > 1 - else {"name": "Channel", "value": channels[0]} - ) + .add_field(name="Channels", value="\n".join(unique_channels)) .add_field(name="Link", value=f"[Jump to quote]({messages[0].jump_url})") .add_field(name="Timestamp", value=f"") ) - async def send_error(self, ctx, error_type: str = "", custom_msg: str = None) -> discord.Embed: + async def send_error(self, ctx, error_type: str = "", custom_msg: str = "") -> None: """Generate error message embeds""" error_msgs = { - "NoChannelSet": f"""There is no quotes channel configured for this server. - A moderator must set a quotes channel for this server using the command `{ctx.prefix}quote set_quotes_channel `""", - "ChannelNotFound": f"""Unable to find the quotes channel for this server. This could be due to a permissions issue or because the channel no longer exists. - - A moderator must set a valid quotes channel for this server using the command `{ctx.prefix}quote set_quotes_channel `""", + "NoChannelSet": ( + "There is no quotes channel configured for this server. " + "A moderator must set a quotes channel for this server using the " + f"command `{ctx.prefix}quote set_quotes_channel `" + ), + "ChannelNotFound": ( + "Unable to find the quotes channel for this server. This could " + "be due to a permissions issue or because the channel no longer exists." + "A moderator must set a valid quotes channel for this server using the command " + f"`{ctx.prefix}quote set_quotes_channel `" + ), "NoArgs": "You must provide 1 or more message IDs for this command!", } @@ -161,10 +182,26 @@ async def send_error(self, ctx, error_type: str = "", custom_msg: str = None) -> error_msg = error_msgs[error_type] elif custom_msg: error_msg = custom_msg - + else: + error_msg = "An unknown error has occurred" error_embed = discord.Embed(title="Error", description=error_msg, colour=await ctx.embed_colour()) await ctx.send(embed=error_embed) + def _is_valid_channel(self, channel: discord.guild.GuildChannel | discord.abc.MessageableChannel | None): + if channel is not None and not isinstance( + channel, + ( + discord.ForumChannel, + discord.CategoryChannel, + discord.DMChannel, + discord.ForumChannel, + discord.PartialMessageable, + discord.GroupChannel, + ), + ): + return channel + return False + async def get_confirmation(self, ctx: commands.Context, msg: discord.Message) -> Optional[bool]: """Get confirmation from user with reactions""" emojis = ["❌", "✅"] diff --git a/reactrole/reactrole.py b/reactrole/reactrole.py index ac4f86b3..51a66d2a 100644 --- a/reactrole/reactrole.py +++ b/reactrole/reactrole.py @@ -1,4 +1,7 @@ """discord red-bot reactrole cog""" + +import logging + import discord from redbot.core import Config, checks, commands from redbot.core.bot import Red @@ -7,6 +10,8 @@ CUSTOM_CONTROLS = {"⬅️": prev_page, "⏹️": close_menu, "➡️": next_page} +log = logging.getLogger("red.rhomelab.reactrole") + class ReactRoleCog(commands.Cog): """ReactRole Cog""" @@ -22,6 +27,11 @@ def __init__(self, bot: Red): self.config.register_guild(**default_guild_settings) + def _is_valid_channel(self, channel: discord.guild.GuildChannel | None): + if channel is not None and not isinstance(channel, (discord.ForumChannel, discord.CategoryChannel)): + return channel + return False + @commands.Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): """ @@ -77,7 +87,7 @@ async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent): role = guild.get_role(item["role"]) await member.remove_roles(role) - @commands.group(name="reactrole") + @commands.group(name="reactrole") # type: ignore @commands.guild_only() @checks.mod() async def _reactrole(self, ctx: commands.Context): @@ -87,7 +97,7 @@ async def _reactrole(self, ctx: commands.Context): @checks.admin() async def add_reactrole( self, - ctx: commands.Context, + ctx: commands.GuildContext, message: discord.Message, reaction: str, role: discord.Role, @@ -115,7 +125,7 @@ async def add_reactrole( @_reactrole.command("remove") async def remove_reactrole( self, - ctx: commands.Context, + ctx: commands.GuildContext, message: discord.Message, reaction: str, role: discord.Role, @@ -140,7 +150,7 @@ async def remove_reactrole( return await ctx.send("React role doesn't exist.") @_reactrole.command("list") - async def reactrole_list(self, ctx: commands.Context): + async def reactrole_list(self, ctx: commands.GuildContext): """Shows a list of react roles configured Example: @@ -152,14 +162,18 @@ async def reactrole_list(self, ctx: commands.Context): async with self.config.guild(ctx.guild).roles() as roles: for item in roles: - try: - role = ctx.guild.get_role(item["role"]) - channel = ctx.guild.get_channel(item["channel"]) + role = ctx.guild.get_role(item["role"]) + if not role: + messages.append(f"Role {item['role']} not found.") + + _channel = ctx.guild.get_channel(item["channel"]) + if (channel := self._is_valid_channel(_channel)) and role: message = await channel.fetch_message(item["message"]) - messages.append(f"📝 {message.jump_url} " f'- {role.name} - {item["reaction"]}\n') - except Exception as exc: - print(exc) - messages.append("Failed to retrieve 1 result.") + messages.append(f"📝 {message.jump_url} - {role.name} - {item['reaction']}\n") + else: + messages.append( + f"Channel {_channel.mention if _channel else item['channel']} is not a searchable channel." + ) # Pagify implementation # https://github.com/Cog-Creators/Red-DiscordBot/blob/9698baf6e74f6b34f946189f05e2559a60e83706/redbot/core/utils/chat_formatting.py#L208 @@ -182,7 +196,7 @@ async def reactrole_list(self, ctx: commands.Context): ) @_reactrole.command("enable") - async def reactrole_enable(self, ctx: commands.Context): + async def reactrole_enable(self, ctx: commands.GuildContext): """Enables the ReactRole's functionality Example: @@ -192,7 +206,7 @@ async def reactrole_enable(self, ctx: commands.Context): await ctx.send("Enabled ReactRole.") @_reactrole.command("disable") - async def reactrole_disable(self, ctx: commands.Context): + async def reactrole_disable(self, ctx: commands.GuildContext): """Disables the ReactRole's functionality Example: diff --git a/report/report.py b/report/report.py index 047c0422..b4fff557 100644 --- a/report/report.py +++ b/report/report.py @@ -1,10 +1,14 @@ """discord red-bot report cog""" +import logging + import discord from redbot.core import Config, checks, commands from redbot.core.bot import Red from redbot.core.utils.chat_formatting import escape +logger = logging.getLogger("red.rhomelab.report") + class ReportCog(commands.Cog): """Report Cog""" @@ -25,14 +29,19 @@ def __init__(self, bot: Red): self.config.register_guild(**default_guild_settings) - @commands.group("reports") + def _is_valid_channel(self, channel: discord.guild.GuildChannel | None): + if channel is not None and not isinstance(channel, (discord.ForumChannel, discord.CategoryChannel)): + return channel + return False + + @commands.group("reports") # type: ignore @commands.guild_only() @checks.mod() async def _reports(self, ctx: commands.Context): pass @_reports.command("logchannel") - async def reports_logchannel(self, ctx: commands.Context, channel: discord.TextChannel): + async def reports_logchannel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Sets the channel to post the reports Example: @@ -43,23 +52,23 @@ async def reports_logchannel(self, ctx: commands.Context, channel: discord.TextC await ctx.send(f"Reports log message channel set to `{channel.name}`") @_reports.command("confirm") - async def reports_confirm(self, ctx: commands.Context, option: str): + async def reports_confirm(self, ctx: commands.GuildContext, option: str): """Changes if confirmations should be sent to reporters upon a report/emergency. Example: - `[p]reports confirm ` """ try: - option = bool(strtobool(option)) + confirmation = strtobool(option) except ValueError: await ctx.send("Invalid option. Use: `[p]reports confirm `") return - await self.config.guild(ctx.guild).confirmations.set(option) - await ctx.send(f"Send report confirmations: `{option}`") + await self.config.guild(ctx.guild).confirmations.set(confirmation) + await ctx.send(f"Send report confirmations: `{confirmation}`") @commands.command("report") @commands.guild_only() - async def cmd_report(self, ctx: commands.Context, *, message: str = None): + async def cmd_report(self, ctx: commands.GuildContext, *, message: str): """Sends a report to the mods for possible intervention Example: @@ -76,12 +85,18 @@ async def cmd_report(self, ctx: commands.Context, *, message: str = None): log = None if log_id: log = ctx.guild.get_channel(log_id) + else: + logger.warning(f"No log channel set for guild {ctx.guild}") if not log: # Failed to get the channel + logger.warning(f"Failed to get log channel {log_id}, in guild {ctx.guild}") return data = self.make_report_embed(ctx, message, emergency=False) - await log.send(embed=data) + if log_channel := self._is_valid_channel(log): + await log_channel.send(embed=data) + else: + logger.warning(f"Failed to get log channel {log_id}, is a invalid channel") confirm = await self.config.guild(ctx.guild).confirmations() if confirm: @@ -93,7 +108,7 @@ async def cmd_report(self, ctx: commands.Context, *, message: str = None): @commands.command("emergency") @commands.guild_only() - async def cmd_emergency(self, ctx: commands.Context, *, message: str = None): + async def cmd_emergency(self, ctx: commands.GuildContext, *, message: str): """Pings the mods with a report for possible intervention Example: @@ -110,26 +125,32 @@ async def cmd_emergency(self, ctx: commands.Context, *, message: str = None): log = None if log_id: log = ctx.guild.get_channel(log_id) + else: + logger.warning(f"No log channel set for guild {ctx.guild}") if not log: # Failed to get the channel + logger.warning(f"Failed to get log channel {log_id}, in guild {ctx.guild}") return data = self.make_report_embed(ctx, message, emergency=True) - mod_pings = " ".join([i.mention for i in log.members if not i.bot and str(i.status) in ["online", "idle"]]) - if not mod_pings: # If no online/idle mods - mod_pings = " ".join([i.mention for i in log.members if not i.bot]) - await log.send(content=mod_pings, embed=data) - - confirm = await self.config.guild(ctx.guild).confirmations() - if confirm: - report_reply = self.make_reporter_reply(ctx, message, True) - try: - await ctx.author.send(embed=report_reply) - except discord.Forbidden: - pass + if channel := self._is_valid_channel(log): + mod_pings = " ".join([i.mention for i in channel.members if not i.bot and str(i.status) in ["online", "idle"]]) + if not mod_pings: # If no online/idle mods + mod_pings = " ".join([i.mention for i in channel.members if not i.bot]) + await channel.send(content=mod_pings, embed=data) + + confirm = await self.config.guild(ctx.guild).confirmations() + if confirm: + report_reply = self.make_reporter_reply(ctx, message, True) + try: + await ctx.author.send(embed=report_reply) + except discord.Forbidden: + pass + else: + logger.warning(f"Failed to get log channel {log_id}, is a invalid channel") @_reports.command("channel") - async def reports_channel(self, ctx: commands.Context, rule: str, channel: discord.TextChannel): + async def reports_channel(self, ctx: commands.GuildContext, rule: str, channel: discord.TextChannel): """Allows/denies the use of reports/emergencies in specific channels Example: @@ -157,7 +178,7 @@ async def reports_channel(self, ctx: commands.Context, rule: str, channel: disco await ctx.send("Reports {} in {}".format("allowed" if bool_conversion else "denied", channel.mention)) - async def enabled_channel_check(self, ctx: commands.Context) -> bool: + async def enabled_channel_check(self, ctx: commands.GuildContext) -> bool: """Checks that reports/emergency commands are enabled in the current channel""" async with self.config.guild(ctx.guild).channels() as channels: channel = [c for c in channels if c["id"] == str(ctx.channel.id)] @@ -169,7 +190,7 @@ async def enabled_channel_check(self, ctx: commands.Context) -> bool: channels.append({"id": str(ctx.channel.id), "allowed": True}) return True - def make_report_embed(self, ctx: commands.Context, message: str, emergency: bool) -> discord.Embed: + def make_report_embed(self, ctx: commands.GuildContext, message: str, emergency: bool) -> discord.Embed: """Construct the embed to be sent""" return ( discord.Embed( @@ -182,7 +203,7 @@ def make_report_embed(self, ctx: commands.Context, message: str, emergency: bool .add_field(name="Timestamp", value=f"") ) - def make_reporter_reply(self, ctx: commands.Context, message: str, emergency: bool) -> discord.Embed: + def make_reporter_reply(self, ctx: commands.GuildContext, message: str, emergency: bool) -> discord.Embed: """Construct the reply embed to be sent""" return ( discord.Embed( diff --git a/requirements-ci.txt b/requirements-ci.txt deleted file mode 100644 index 9627d8e4..00000000 --- a/requirements-ci.txt +++ /dev/null @@ -1,8 +0,0 @@ -black -flake8 -pylint -isort -fastjsonschema -pytest -pytest-asyncio -pytest-aiohttp diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..237a1e11 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +fastjsonschema==2.19.1 +pre-commit==3.7.0 +pyright==1.1.394 +pytest-aiohttp==1.0.5 +pytest-asyncio==0.23.6 +pytest==8.2.0 +ruff==0.9.6 diff --git a/role_welcome/role_welcome.py b/role_welcome/role_welcome.py index 10d4cd14..dc18deb0 100644 --- a/role_welcome/role_welcome.py +++ b/role_welcome/role_welcome.py @@ -29,7 +29,7 @@ def __init__(self, bot): self.config.register_guild(**default_guild_settings) @commands.Cog.listener() - async def on_member_update(self, before: discord.Member, after: discord.Member): + async def on_member_update(self, before: discord.Member, after: discord.Member): # noqa: PLR0911 """Role addition event""" if after.bot: # Member is a bot @@ -47,9 +47,7 @@ async def on_member_update(self, before: discord.Member, after: discord.Member): # Roles haven't changed return - if role in [role.id for role in before.roles] or role not in [ - role.id for role in after.roles - ]: + if role in [role.id for role in before.roles] or role not in [role.id for role in after.roles]: # Member already had role or does not have role return @@ -69,9 +67,7 @@ async def on_member_update(self, before: discord.Member, after: discord.Member): async with self.config.guild(guild).welcomed_users() as welcomed_users: if after.id in welcomed_users and not always_welcome: - log.debug( - f"User {after.id} ({after.global_name}) has already been welcomed" - ) + log.debug(f"User {after.id} ({after.global_name}) has already been welcomed") return if after.id not in welcomed_users: welcomed_users.append(after.id) @@ -94,7 +90,7 @@ async def on_raw_member_remove(self, event: discord.RawMemberRemoveEvent): # Command groups - @commands.group(name="rolewelcome") + @commands.group(name="rolewelcome") # type: ignore @commands.guild_only() @checks.mod() async def welcome(self, ctx: commands.Context): @@ -103,14 +99,13 @@ async def welcome(self, ctx: commands.Context): This cog will send a configurable welcome message to a specified channel when a user receives a specified role. - The specific logic used to decide when to welcome a user can be adjusted with the `always_welcome` and `reset_on_leave` settings. - """ + The specific logic used to decide when to welcome a user can be adjusted with the `always_welcome` and `reset_on_leave` settings.""" # noqa: E501 pass # Commands @welcome.command("status") - async def send_welcome_status(self, ctx: commands.Context): + async def send_welcome_status(self, ctx: commands.GuildContext): """Status of the cog.""" guild_role = "Unset" channel = "Unset" @@ -133,9 +128,7 @@ async def send_welcome_status(self, ctx: commands.Context): if guild_role: guild_role = guild_role.name else: - guild_role = ( - f"Set to role with ID `{role_id}`, but could not find role!" - ) + guild_role = f"Set to role with ID `{role_id}`, but could not find role!" if channel_id: guild_channel = ctx.guild.get_channel(channel_id) @@ -157,7 +150,7 @@ async def send_welcome_status(self, ctx: commands.Context): await ctx.send("I need the `Embed links` permission to send status.") @welcome.command("role") - async def set_welcome_role(self, ctx: commands.Context, role: discord.Role): + async def set_welcome_role(self, ctx: commands.GuildContext, role: discord.Role): """Set the role to be watched for new users. **Example:** @@ -166,7 +159,8 @@ async def set_welcome_role(self, ctx: commands.Context, role: discord.Role): ⚠️ **NOTE** When changing the role, you will be prompted to reset the list of welcomed users. - It is advisable to proceed with this to ensure all users are welcomed to the new role, however it may not be necessary in cases such as the recreation of a role or usage of a new role for the same purpose. + It is advisable to proceed with this to ensure all users are welcomed to the new role, however it may not be necessary + in cases such as the recreation of a role or usage of a new role for the same purpose. See `[p]rolewelcome always_welcome` and `[p]rolewelcome reset_on_leave` for more information. """ old_role = await self.config.guild(ctx.guild).role() @@ -183,9 +177,7 @@ async def set_welcome_role(self, ctx: commands.Context, role: discord.Role): await self.clear_welcomed_users(ctx) @welcome.command("channel") - async def set_welcome_channel( - self, ctx: commands.Context, channel: discord.abc.GuildChannel - ): + async def set_welcome_channel(self, ctx: commands.GuildContext, channel: discord.abc.GuildChannel): """Set the channel to send welcome messages to. **Example:** @@ -196,9 +188,7 @@ async def set_welcome_channel( await ctx.send("Welcome channel must be a text channel.") return if not channel.permissions_for(ctx.guild.me).send_messages: - await ctx.send( - f"I need the `Send messages` permission before I can send messages in {channel.mention}." - ) + await ctx.send(f"I need the `Send messages` permission before I can send messages in {channel.mention}.") return await self.config.guild(ctx.guild).channel.set(channel.id) await ctx.tick(message=f"Welcome channel set to {channel.mention}.") @@ -206,7 +196,7 @@ async def set_welcome_channel( @welcome.command("message") async def set_welcome_message( self, - ctx: commands.Context, + ctx: commands.GuildContext, *, message: str, ): @@ -227,7 +217,7 @@ async def set_welcome_message( await ctx.tick(message=f"Welcome message set to `{message}`.") @welcome.command("test") - async def test_welcome_message(self, ctx: commands.Context): + async def test_welcome_message(self, ctx: commands.GuildContext): """Test the welcome message in the current channel.""" if not isinstance(ctx.channel, discord.TextChannel): await ctx.send("Test channel (current) must be a text channel.") @@ -235,11 +225,12 @@ async def test_welcome_message(self, ctx: commands.Context): await self.send_welcome_message(ctx.guild, ctx.channel, ctx.author) @welcome.command("always_welcome") - async def set_always_welcome(self, ctx: commands.Context): + async def set_always_welcome(self, ctx: commands.GuildContext): """ Toggle whether users receive a welcome message every time they are assigned the role. - - **If set to `true`**: Users will receive a welcome message **every time** they receive the role, even if they have had it before. + - **If set to `true`**: Users will receive a welcome message **every time** they receive the role, even if they have + had it before. - **If set to `false`**: Users will only receive a welcome message the **first time** they receive the role. **Default:** `true` @@ -249,10 +240,12 @@ async def set_always_welcome(self, ctx: commands.Context): - `[p]rolewelcome status` - Shows the current status of this setting. ⚠️ **NOTE** - This offers similar functionality to `reset_on_leave`. You should review both settings carefully to understand how they interact. + This offers similar functionality to `reset_on_leave`. You should review both settings carefully to understand how they + interact. - If `always_welcome` is `false`, a user will not receive another welcome message if they lose and regain the role. - - If `always_welcome` is `false` but you still want users to be welcomed again after rejoining the guild, ensure that `reset_on_leave` is set to `true`. + - If `always_welcome` is `false` but you still want users to be welcomed again after rejoining the guild, ensure that + `reset_on_leave` is set to `true`. Run `[p]help rolewelcome reset_on_leave` for more information. """ current_value = await self.config.guild(ctx.guild).always_welcome() @@ -261,12 +254,14 @@ async def set_always_welcome(self, ctx: commands.Context): await ctx.send(f"✅ Always welcome is now `{new_value}`.") @welcome.command("reset_on_leave") - async def set_reset_on_leave(self, ctx: commands.Context): + async def set_reset_on_leave(self, ctx: commands.GuildContext): """ Toggle whether a user's welcome status is reset when they leave the guild. - - **If set to `true`**: When a user leaves the guild, their welcome status is reset, meaning they will receive a welcome message again if they rejoin and receive the role again. - - **If `false`**: Their welcome status is retained, so they **will not** be welcomed again unless `always_welcome` is left set to the default value of `true`. + - **If set to `true`**: When a user leaves the guild, their welcome status is reset, meaning they will receive a + welcome message again if they rejoin and receive the role again. + - **If `false`**: Their welcome status is retained, so they **will not** be welcomed again unless `always_welcome` is + left set to the default value of `true`. **Default:** `true` @@ -275,10 +270,12 @@ async def set_reset_on_leave(self, ctx: commands.Context): - `[p]rolewelcome status` - Shows the current status of this setting. ⚠️ **NOTE** - This offers similar functionality to `always_welcome`. You should review both settings carefully to understand how they interact. + This offers similar functionality to `always_welcome`. You should review both settings carefully to understand how they + interact. - If both `reset_on_leave` and `always_welcome` are `false`, users who leave and rejoin will **not** be welcomed again. - - If `always_welcome` is `true`, they will receive a welcome message each time they gain the role, regardless of the state of this setting or whether they have left and rejoined the guild. + - If `always_welcome` is `true`, they will receive a welcome message each time they gain the role, regardless of the + state of this setting or whether they have left and rejoined the guild. Run `[p]help rolewelcome always_welcome` for more information. """ current_value = await self.config.guild(ctx.guild).reset_on_leave() @@ -287,7 +284,7 @@ async def set_reset_on_leave(self, ctx: commands.Context): await ctx.send(f"✅ Reset on leave is now `{new_value}`.") @welcome.command() - async def clear_welcomed_users(self, ctx: commands.Context): + async def clear_welcomed_users(self, ctx: commands.GuildContext): """ Clear the list of welcomed users. @@ -307,8 +304,10 @@ async def clear_welcomed_users(self, ctx: commands.Context): confirm_message = f"Do you wish to clear all {num_welcomed_users} users from the welcomed users list?" if ctx.command.name == "clear_welcomed_users": confirm_message += ( - "\n⚠️ Clearing the list of welcomed users will cause all users to be welcomed again if they receive the role again." - + f"\nSee `{ctx.clean_prefix}rolewelcome always_welcome` and `{ctx.clean_prefix}rolewelcome reset_on_leave` for more information on welcome logic." + "\n⚠️ Clearing the list of welcomed users will cause all users " + "to be welcomed again if they receive the role again." + f"\nSee `{ctx.clean_prefix}rolewelcome always_welcome` and `{ctx.clean_prefix}rolewelcome reset_on_leave`" + "for more information on welcome logic." ) view = ConfirmView(ctx.author) @@ -316,17 +315,15 @@ async def clear_welcomed_users(self, ctx: commands.Context): await view.wait() if view.result: await self.config.guild(ctx.guild).welcomed_users.set(value=[]) - await ctx.send( - f"✅ Cleared {num_welcomed_users} entries from the list of welcomed users." - ) + await ctx.send(f"✅ Cleared {num_welcomed_users} entries from the list of welcomed users.") else: await ctx.send("Welcomed users list was not cleared.") @welcome.command() - async def backfill_welcomed_users(self, ctx: commands.Context, role: discord.Role): + async def backfill_welcomed_users(self, ctx: commands.GuildContext, role: discord.Role): """ Backfill the list of welcomed users with all members of a role. - + **Example:** - `[p]rolewelcome backfill_welcomed_users ` - `[p]rolewelcome backfill_welcomed_users @members` @@ -343,7 +340,6 @@ async def backfill_welcomed_users(self, ctx: commands.Context, role: discord.Rol await ctx.send(f"✅ Added {num_added_users} members of {role.name} to the list of welcomed users.") # Helpers - async def send_welcome_message( self, guild: discord.Guild, @@ -352,20 +348,14 @@ async def send_welcome_message( ): """Send welcome message""" if not channel.permissions_for(guild.me).send_messages: - log.error( - f"Missing send messages permission for {channel.name} ({channel.id})" - ) + log.error(f"Missing send messages permission for {channel.name} ({channel.id})") # type: ignore return role_name = "role_unset" - welcome_role_id = await self.config.guild(guild).role() - if welcome_role_id: - role_name = "role_unknown" - welcome_role = guild.get_role(welcome_role_id) - if welcome_role: - role_name = welcome_role.name + welcome_role_id = await self.config.guild(guild).role() # type: ignore + role_name = "role_unknown" + if welcome_role_id and (welcome_role := guild.get_role(welcome_role_id)): + role_name = welcome_role.name welcome_message = await self.config.guild(guild).message() - welcome_message = welcome_message.format( - user=member.mention, role=role_name, guild=guild.name - ) + welcome_message = welcome_message.format(user=member.mention, role=role_name, guild=guild.name) await channel.send(welcome_message) diff --git a/roleinfo/roleinfo.py b/roleinfo/roleinfo.py index d9c8d496..c30ea3a0 100644 --- a/roleinfo/roleinfo.py +++ b/roleinfo/roleinfo.py @@ -1,4 +1,5 @@ """discord red-bot roleinfo cog""" + import discord from redbot.core import commands from redbot.core.bot import Red @@ -23,10 +24,13 @@ async def role_info_cmd(self, ctx: commands.Context, role: discord.Role): - `[p]roleinfo verified` - `[p]roleinfo @verified` """ - role_check = await is_mod(self.bot, ctx.author) or role <= max(ctx.author.roles) - if role_check: - embed = await self.make_role_embed(role) - await ctx.send(embed=embed) + if isinstance(ctx.author, discord.Member): + role_check = await is_mod(self.bot, ctx.author) or role <= max(ctx.author.roles) + if role_check: + embed = await self.make_role_embed(role) + await ctx.send(embed=embed) + else: + await ctx.send("You can only use this command in a server.") async def make_role_embed(self, role: discord.Role) -> discord.Embed: """Generate the role info embed""" diff --git a/sentry/sentry.py b/sentry/sentry.py index 1541f5d9..04e30620 100644 --- a/sentry/sentry.py +++ b/sentry/sentry.py @@ -77,13 +77,13 @@ def cog_unload(self): async def _sentry(self, ctx: commands.context.Context): """Command group for sentry settings""" - @_sentry.command(name="set_env") + @_sentry.command(name="set_env") # type: ignore async def sentry_set_env(self, context: commands.context.Context, new_value: str): """Set sentry environment""" await self.config.environment.set(new_value) await context.send(f"Sentry environment has been changed to '{new_value}'") - @_sentry.command(name="get_env") + @_sentry.command(name="get_env") # type: ignore async def sentry_get_env(self, context: commands.context.Context): """Get sentry environment""" environment_val = await self.config.environment() @@ -93,29 +93,30 @@ async def sentry_get_env(self, context: commands.context.Context): message = f"The Sentry environment is unset. See `{context.prefix}sentry set_env`." await context.send(message) - @_sentry.command(name="set_log_level") + @_sentry.command(name="set_log_level") # type: ignore async def sentry_set_log_level(self, context: commands.context.Context, new_value: str): """Set sentry log_level""" new_value = new_value.upper() + if self.client: + self.client.options["debug"] = new_value == "DEBUG" + else: + self.logger.warning("Sentry client not initialised yet") + await context.send("Sentry client not initialised yet") try: self.logger.setLevel(new_value) - self.client.options["debug"] = new_value == "DEBUG" await self.config.log_level.set(new_value) await context.send(f"Sentry log_level has been changed to '{new_value}'") except ValueError as error: self.logger.warning(f"Could not change log level to '{new_value}': ", exc_info=error) - await context.send( - f"Sentry log_level could not be changed.\n" + - f"{new_value} is not a valid logging level." - ) + await context.send("Sentry log_level could not be changed.\n" + f"{new_value} is not a valid logging level.") - @_sentry.command(name="get_log_level") + @_sentry.command(name="get_log_level") # type: ignore async def sentry_get_log_level(self, context: commands.context.Context): """Get sentry log_level""" log_level_val = await self.config.log_level() await context.send(f"The Sentry log_level is '{log_level_val}'") - @_sentry.command(name="test") + @_sentry.command(name="test") # type: ignore async def sentry_test(self, context: commands.context.Context): """Test sentry""" await context.send("An exception will now be raised. Check Sentry to confirm.") @@ -133,7 +134,7 @@ async def before_invoke(self, context: commands.context.Context): "username": msg.author.display_name, } ) - transaction: Transaction = hub.start_transaction(op="command", name="Command %s" % context.command.name) + transaction = hub.start_transaction(op="command", name=f"Command {context.command.name}") transaction.set_tag("discord_message", msg.content) if context.command: transaction.set_tag("discord_command", context.command.name) diff --git a/tags/abstracts.py b/tags/abstracts.py index ac69af02..42ad13b5 100644 --- a/tags/abstracts.py +++ b/tags/abstracts.py @@ -7,8 +7,8 @@ from redbot.core import Config, commands +# fixme: please use data classes class BaseABC(ABC): - def __init__(self, **kwargs): if kwargs.keys() != self.__annotations__.keys(): raise Exception("Invalid kwargs provided") @@ -85,7 +85,7 @@ def __init__(self, **kwargs): @classmethod @abstractmethod - def new(cls, ctx: commands.Context, alias: str, creator: int, created: int, tag: str, uses: List[UseABC]): + def new(cls, ctx: commands.Context, alias: str, creator: int, created: int, tag: str, uses: List[UseABC]): # noqa: PLR0913 """Initialise the class in a command context""" pass @@ -115,7 +115,7 @@ def __init__(self, **kwargs): @classmethod @abstractmethod - def new(cls, ctx: commands.Context, creator: int, owner: int, created: int, tag: str, content: str): + def new(cls, ctx: commands.Context, creator: int, owner: int, created: int, tag: str, content: str): # noqa: PLR0913 """Initialise the class in a command context""" pass diff --git a/tags/tags.py b/tags/tags.py index dd610da6..88cdb586 100644 --- a/tags/tags.py +++ b/tags/tags.py @@ -35,7 +35,7 @@ async def make_tag_info_embed(tag: Tag, aliases: [Alias]) -> discord.Embed: if len(aliases) > 0: result.add_field(name="Aliases", value=f"`{', '.join(alias_list)}`") if len(transfers) > 0: - result.add_field(name="Prior Owners", value=', '.join(transfers)) + result.add_field(name="Prior Owners", value=", ".join(transfers)) return result @@ -92,7 +92,7 @@ async def _create(self, ctx: commands.Context, trigger: str, *, content: str): await ctx.send("That tag already exists as an alias!") return - tag = await self.config.create_tag(ctx, trigger, content) + await self.config.create_tag(ctx, trigger, content) await ctx.send("Tag successfully created!") @@ -141,12 +141,11 @@ async def _delete(self, ctx: commands.Context, trigger: str): tag = await self.config.get_tag(ctx, trigger) if tag is None: await ctx.send("That isn't a tag, sorry.") + elif tag.owner == ctx.author.id or await is_mod_or_superior(self.bot, ctx.author): + await self.config.delete_tag(ctx, trigger) + await ctx.send("Tag successfully deleted!") else: - if tag.owner == ctx.author.id or await is_mod_or_superior(self.bot, ctx.author): - await self.config.delete_tag(ctx, trigger) - await ctx.send("Tag successfully deleted!") - else: - await ctx.send("You can't delete that tag. Only the creator or mods can do that, and you're neither!") + await ctx.send("You can't delete that tag. Only the creator or mods can do that, and you're neither!") @_tag.command(name="claim") async def _claim(self, ctx: commands.Context, trigger: str): @@ -158,8 +157,10 @@ async def _claim(self, ctx: commands.Context, trigger: str): else: curr_owner = ctx.guild.get_member(tag.owner) if curr_owner is not None: - await ctx.send(f"That tag's owner is still in the guild! You can see if {curr_owner.mention} " - f"wants to transfer it to you.") + await ctx.send( + f"That tag's owner is still in the guild! You can see if {curr_owner.mention} " + f"wants to transfer it to you." + ) else: await self.config.transfer_tag(ctx, trigger, ctx.author.id, "Claim", int(datetime.utcnow().timestamp())) await ctx.send("Tag successfully claimed!") @@ -176,13 +177,13 @@ async def _transfer(self, ctx: commands.Context, trigger: str, member: discord.M if tag.owner == ctx.author.id: allowable = True reason = "Owner-initiated" - else: - if await is_mod_or_superior(self.bot, ctx.author): - allowable = True - reason = f"Mod-initiated by {ctx.author.mention}" + elif await is_mod_or_superior(self.bot, ctx.author): + allowable = True + reason = f"Mod-initiated by {ctx.author.mention}" if allowable: - await self.config.transfer_tag(ctx, trigger, member.id, f"Transfer: {reason}", - int(datetime.utcnow().timestamp())) + await self.config.transfer_tag( + ctx, trigger, member.id, f"Transfer: {reason}", int(datetime.utcnow().timestamp()) + ) await ctx.send("Tag successfully transferred!") else: await ctx.send("You can't transfer that tag. Ask the owner if they want to transfer it to you.") @@ -208,15 +209,15 @@ async def _list(self, ctx: commands.Context, user: Optional[discord.User]): aliases_pages = list(pagify(aliases_str, page_length=embed_page_length)) tags_embeds = [ - discord.Embed( - title="Tags", colour=await ctx.embed_colour(), description=page - ).set_footer(text=f"Page {index} of {len(tags_pages)}") + discord.Embed(title="Tags", colour=await ctx.embed_colour(), description=page).set_footer( + text=f"Page {index} of {len(tags_pages)}" + ) for index, page in enumerate(tags_pages, start=1) ] aliases_embeds = [ - discord.Embed( - title="Tag Aliases", colour=await ctx.embed_colour(), description=page - ).set_footer(text=f"Page {index} of {len(aliases_pages)}") + discord.Embed(title="Tag Aliases", colour=await ctx.embed_colour(), description=page).set_footer( + text=f"Page {index} of {len(aliases_pages)}" + ) for index, page in enumerate(aliases_pages, start=1) ] @@ -224,9 +225,7 @@ async def _list(self, ctx: commands.Context, user: Optional[discord.User]): if len(embed_list) == 1: await ctx.send(embed=embed_list[0]) else: - self.bot.loop.create_task( - menu(ctx=ctx, pages=embed_list, timeout=120.0) - ) + self.bot.loop.create_task(menu(ctx=ctx, pages=embed_list, timeout=120.0)) @_tag.group(name="alias") async def _alias(self, ctx: commands.Context): @@ -263,11 +262,8 @@ async def _alias_delete(self, ctx: commands.Context, trigger: str): alias = await self.config.get_alias(ctx, trigger) if alias is None: await ctx.send("That isn't an alias, sorry.") + elif alias.creator == ctx.author.id or await is_mod_or_superior(self.bot, ctx.author): + await self.config.delete_alias(ctx, trigger) + await ctx.send("Alias successfully deleted!") else: - if alias.creator == ctx.author.id or await is_mod_or_superior(self.bot, ctx.author): - await self.config.delete_alias(ctx, trigger) - await ctx.send("Alias successfully deleted!") - else: - await ctx.send("You can't delete that alias. Only the creator or mods can do that, and you're neither!") - - + await ctx.send("You can't delete that alias. Only the creator or mods can do that, and you're neither!") diff --git a/tags/utils.py b/tags/utils.py index 090c81ae..5241aa6a 100644 --- a/tags/utils.py +++ b/tags/utils.py @@ -10,108 +10,57 @@ class Transfer(TransferABC): @classmethod def new(cls, ctx: commands.Context, prior: int, reason: str, to: int, time: int): - return cls( - prior=prior, - reason=reason, - to=to, - time=time - ) + return cls(prior=prior, reason=reason, to=to, time=time) @classmethod def from_storage(cls, ctx: commands.Context, data: dict): - return cls( - prior=data['prior'], - reason=data['reason'], - to=data['to'], - time=data['time'] - ) + return cls(prior=data["prior"], reason=data["reason"], to=data["to"], time=data["time"]) def to_dict(self) -> dict: - return { - "prior": self.prior, - "reason": self.reason, - "to": self.to, - "time": self.time - } + return {"prior": self.prior, "reason": self.reason, "to": self.to, "time": self.time} class Use(UseABC): - @classmethod def new(cls, ctx: commands.Context, user: int, time: int): - return cls( - user=user, - time=time - ) + return cls(user=user, time=time) @classmethod def from_storage(cls, ctx: commands.Context, data: dict): - return cls( - user=data['user'], - time=data['time'] - ) + return cls(user=data["user"], time=data["time"]) def to_dict(self) -> dict: - return { - "user": self.user, - "time": self.time - } + return {"user": self.user, "time": self.time} class Alias(AliasABC): @classmethod - def new(cls, ctx: commands.Context, alias: str, creator: int, created: int, tag: str, uses: List[UseABC]): - return cls( - alias=alias, - creator=creator, - created=created, - tag=tag, - uses=uses - ) + def new(cls, ctx: commands.Context, alias: str, creator: int, created: int, tag: str, uses: List[UseABC]): # noqa: PLR0913 + return cls(alias=alias, creator=creator, created=created, tag=tag, uses=uses) @classmethod def from_storage(cls, ctx: commands.Context, data: dict): - return cls( - alias=data['alias'], - creator=data['creator'], - created=data['created'], - tag=data['tag'], - uses=data['uses'] - ) + return cls(alias=data["alias"], creator=data["creator"], created=data["created"], tag=data["tag"], uses=data["uses"]) def to_dict(self) -> dict: - return { - "alias": self.alias, - "creator": self.creator, - "created": self.created, - "tag": self.tag, - "uses": self.uses - } + return {"alias": self.alias, "creator": self.creator, "created": self.created, "tag": self.tag, "uses": self.uses} class Tag(TagABC): @classmethod - def new(cls, ctx: commands.Context, creator: int, owner: int, created: int, tag: str, content: str): - return cls( - tag=tag, - creator=creator, - owner=owner, - created=created, - content=content, - transfers=[], - uses=[] - ) + def new(cls, ctx: commands.Context, creator: int, owner: int, created: int, tag: str, content: str): # noqa: PLR0913 + return cls(tag=tag, creator=creator, owner=owner, created=created, content=content, transfers=[], uses=[]) @classmethod def from_storage(cls, ctx: commands.Context, data: dict): return cls( - tag=data['tag'], - creator=data['creator'], - owner=data['owner'], - created=data['created'], - content=data['content'], - transfers=data['transfers'], - uses=['uses'] + tag=data["tag"], + creator=data["creator"], + owner=data["owner"], + created=data["created"], + content=data["content"], + transfers=data["transfers"], + uses=["uses"], ) def to_dict(self) -> dict: @@ -122,12 +71,11 @@ def to_dict(self) -> dict: "created": self.created, "content": self.content, "transfers": [], - "uses": [] + "uses": [], } class TagConfigHelper(TagConfigHelperABC): - def __init__(self): self.config = Config.get_conf(None, identifier=128986274420752384002, cog_name="TagCog") self.config.register_guild(log={}, tags={}, aliases={}) @@ -215,7 +163,7 @@ async def add_tag_use(self, ctx: commands.Context, tag: Tag, user: int, time: in use = Use.new(ctx, user, time) async with self.config.guild(ctx.guild).tags() as tags: if tag.tag in tags: - tags[tag.tag]['uses'].append(use.to_dict()) + tags[tag.tag]["uses"].append(use.to_dict()) async def create_alias(self, ctx: commands.Context, alias: str, tag: str, creator: int, created: int): new_alias = Alias.new(ctx, alias, creator, created, tag, []) @@ -266,7 +214,7 @@ async def add_alias_use(self, ctx: commands.Context, alias: Alias, user: int, ti use = Use.new(ctx, user, time) async with self.config.guild(ctx.guild).aliases() as aliases: if alias.alias in aliases: - aliases[alias.alias]['uses'].append(use.to_dict()) + aliases[alias.alias]["uses"].append(use.to_dict()) tag = await self.get_tag_by_alias(ctx, alias) if tag is not None: await self.add_tag_use(ctx, tag, user, time) diff --git a/tests/test_phishingdetection.py b/tests/test_phishingdetection.py index 1d64c373..52df206b 100644 --- a/tests/test_phishingdetection.py +++ b/tests/test_phishingdetection.py @@ -1,4 +1,4 @@ -from typing import List, Set +from typing import Any, AsyncGenerator, List, Set import aiohttp import pytest @@ -7,18 +7,11 @@ def mutate_url(url: str) -> List[str]: - return [ - url, - f"http://{url}", - f"https://{url}", - f"https://www.{url}", - f"https://www.{url}/foobar", - f"https://{url}/foobar" - ] + return [url, f"http://{url}", f"https://{url}", f"https://www.{url}", f"https://www.{url}/foobar", f"https://{url}/foobar"] @pytest.fixture -async def session() -> aiohttp.ClientSession: +async def session() -> AsyncGenerator[aiohttp.ClientSession, Any]: client_session: aiohttp.ClientSession = aiohttp.ClientSession(headers={"X-Identity": "Test client"}) yield client_session await client_session.close() @@ -31,14 +24,7 @@ async def urls(session: aiohttp.ClientSession) -> Set[str]: @pytest.fixture def legitimate_urls() -> Set[str]: - return { - "discord.com", - "discordapp.com", - "twitch.tv", - "twitter.com", - "tenor.com", - "giphy.com" - } + return {"discord.com", "discordapp.com", "twitch.tv", "twitter.com", "tenor.com", "giphy.com"} async def test_fetch_urls(session: aiohttp.ClientSession): diff --git a/timeout/timeout.py b/timeout/timeout.py index fff5fed6..08a047fa 100644 --- a/timeout/timeout.py +++ b/timeout/timeout.py @@ -1,5 +1,6 @@ import datetime import logging +from typing import Optional import discord from redbot.core import Config, checks, commands @@ -12,22 +13,21 @@ class Timeout(commands.Cog): def __init__(self): self.config = Config.get_conf(self, identifier=539343858187161140) - default_guild = { - "logchannel": "", - "report": "", - "timeoutrole": "" - } + default_guild = {"logchannel": "", "report": "", "timeoutrole": ""} self.config.register_guild(**default_guild) - self.config.register_member( - roles=[] - ) + self.config.register_member(roles=[]) - self.actor: str = None - self.target: str = None + self.actor: Optional[str] = None + self.target: Optional[str] = None # Helper functions - async def report_handler(self, ctx: commands.Context, user: discord.Member, action_info: dict): + def _is_valid_channel(self, channel: discord.guild.GuildChannel | None): + if channel is not None and not isinstance(channel, (discord.ForumChannel, discord.CategoryChannel)): + return channel + return False + + async def report_handler(self, ctx: commands.GuildContext, user: discord.Member, action_info: dict): """Build and send embed reports""" # Retrieve log channel @@ -36,39 +36,30 @@ async def report_handler(self, ctx: commands.Context, user: discord.Member, acti # Build embed embed = discord.Embed( - description=f"{user.mention} ({user.id})", - color=(await ctx.embed_colour()), - timestamp=datetime.datetime.utcnow() - ) - embed.add_field( - name="Moderator", - value=ctx.author.mention, - inline=False - ) - embed.add_field( - name="Reason", - value=action_info["reason"], - inline=False + description=f"{user.mention} ({user.id})", color=(await ctx.embed_colour()), timestamp=datetime.datetime.utcnow() ) + embed.add_field(name="Moderator", value=ctx.author.mention, inline=False) + embed.add_field(name="Reason", value=action_info["reason"], inline=False) if user.display_avatar: - embed.set_author( - name=f"{user} {action_info['action']} timeout", - icon_url=user.display_avatar.url) + embed.set_author(name=f"{user} {action_info['action']} timeout", icon_url=user.display_avatar.url) else: - embed.set_author( - name=f"{user} {action_info['action']} timeout" - ) + embed.set_author(name=f"{user} {action_info['action']} timeout") # Send embed - await log_channel.send(embed=embed) + if channel := self._is_valid_channel(log_channel): + await channel.send(embed=embed) + else: + log.warning(f"Failed to get log channel {log_channel_config}, in guild {ctx.guild}") async def timeout_add( - self, ctx: commands.Context, - user: discord.Member, - reason: str, - timeout_role: discord.Role, - timeout_roleset: list[discord.Role]): + self, + ctx: commands.GuildContext, + user: discord.Member, + reason: str, + timeout_role: discord.Role, + timeout_roleset: list[discord.Role], + ): """Retrieve and save user's roles, then add user to timeout""" # Catch users already holding timeout role. # This could be caused by an error in this cog's logic or, @@ -78,9 +69,9 @@ async def timeout_add( "Something went wrong! Is the user already in timeout? Please check the console for more information." ) log.warning( - f"Something went wrong while trying to add user {self.target} to timeout.\n" + - f"Current roles: {user.roles}\n" + - f"Attempted new roles: {timeout_roleset}" + f"Something went wrong while trying to add user {self.target} to timeout.\n" + + f"Current roles: {user.roles}\n" + + f"Attempted new roles: {timeout_roleset}" ) return @@ -99,29 +90,28 @@ async def timeout_add( except discord.Forbidden as error: await ctx.send("Whoops, looks like I don't have permission to do that.") log.exception( - f"Something went wrong while trying to add user {self.target} to timeout.\n" + - f"Current roles: {user.roles}\n" + - f"Attempted new roles: {timeout_roleset}", exc_info=error + f"Something went wrong while trying to add user {self.target} to timeout.\n" + + f"Current roles: {user.roles}\n" + + f"Attempted new roles: {timeout_roleset}", + exc_info=error, ) except discord.HTTPException as error: await ctx.send("Something went wrong! Please check the console for more information.") log.exception( - f"Something went wrong while trying to add user {self.target} to timeout.\n" + - f"Current roles: {user.roles}\n" + - f"Attempted new roles: {timeout_roleset}", exc_info=error + f"Something went wrong while trying to add user {self.target} to timeout.\n" + + f"Current roles: {user.roles}\n" + + f"Attempted new roles: {timeout_roleset}", + exc_info=error, ) else: await ctx.message.add_reaction("✅") # Send report to channel if await self.config.guild(ctx.guild).report(): - action_info = { - "reason": reason, - "action": "added to" - } + action_info = {"reason": reason, "action": "added to"} await self.report_handler(ctx, user, action_info) - async def timeout_remove(self, ctx: commands.Context, user: discord.Member, reason: str): + async def timeout_remove(self, ctx: commands.GuildContext, user: discord.Member, reason: str): """Remove user from timeout""" # Fetch and define user's previous roles. user_roles = [] @@ -135,16 +125,18 @@ async def timeout_remove(self, ctx: commands.Context, user: discord.Member, reas except discord.Forbidden as error: await ctx.send("Whoops, looks like I don't have permission to do that.") log.exception( - f"Something went wrong while trying to remove user {self.target} from timeout.\n" + - f"Current roles: {user.roles}\n" + - f"Attempted new roles: {user_roles}", exc_info=error + f"Something went wrong while trying to remove user {self.target} from timeout.\n" + + f"Current roles: {user.roles}\n" + + f"Attempted new roles: {user_roles}", + exc_info=error, ) except discord.HTTPException as error: await ctx.send("Something went wrong! Please check the console for more information.") log.exception( - f"Something went wrong while trying to remove user {self.target} from timeout.\n" + - f"Current roles: {user.roles}\n" + - f"Attempted new roles: {user_roles}", exc_info=error + f"Something went wrong while trying to remove user {self.target} from timeout.\n" + + f"Current roles: {user.roles}\n" + + f"Attempted new roles: {user_roles}", + exc_info=error, ) else: await ctx.message.add_reaction("✅") @@ -154,24 +146,21 @@ async def timeout_remove(self, ctx: commands.Context, user: discord.Member, reas # Send report to channel if await self.config.guild(ctx.guild).report(): - action_info = { - "reason": reason, - "action": "removed from" - } + action_info = {"reason": reason, "action": "removed from"} await self.report_handler(ctx, user, action_info) # Commands @commands.guild_only() @commands.group() - async def timeoutset(self, ctx: commands.Context): + async def timeoutset(self, ctx: commands.GuildContext): """Change the configurations for `[p]timeout`.""" if not ctx.invoked_subcommand: pass @timeoutset.command(name="logchannel") @checks.mod() - async def timeoutset_logchannel(self, ctx: commands.Context, channel: discord.TextChannel): + async def timeoutset_logchannel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Set the log channel for any reports etc. Example: @@ -182,7 +171,7 @@ async def timeoutset_logchannel(self, ctx: commands.Context, channel: discord.Te @timeoutset.command(name="report") @checks.mod() - async def timeoutset_report(self, ctx: commands.Context, choice: str): + async def timeoutset_report(self, ctx: commands.GuildContext, choice: str): """Whether to send a report when a user is added or removed from timeout. These reports will be sent in the form of an embed with timeout reason to the configured log channel. @@ -207,7 +196,7 @@ async def timeoutset_report(self, ctx: commands.Context, choice: str): @timeoutset.command(name="role") @checks.mod() - async def timeoutset_role(self, ctx: commands.Context, role: discord.Role): + async def timeoutset_role(self, ctx: commands.GuildContext, role: discord.Role): """Set the timeout role. Example: @@ -218,7 +207,7 @@ async def timeoutset_role(self, ctx: commands.Context, role: discord.Role): @timeoutset.command(name="list", aliases=["show", "view", "settings"]) @checks.mod() - async def timeoutset_list(self, ctx: commands.Context): + async def timeoutset_list(self, ctx: commands.GuildContext): """Show current settings.""" log_channel = await self.config.guild(ctx.guild).logchannel() @@ -239,35 +228,18 @@ async def timeoutset_list(self, ctx: commands.Context): report = "Unconfigured" # Build embed - embed = discord.Embed( - color=(await ctx.embed_colour()) - ) - embed.set_author( - name="Timeout Cog Settings", - icon_url=ctx.guild.me.display_avatar.url - ) - embed.add_field( - name="Log Channel", - value=log_channel, - inline=True - ) - embed.add_field( - name="Send Reports", - value=report, - inline=True - ) - embed.add_field( - name="Timeout Role", - value=timeout_role, - inline=True - ) + embed = discord.Embed(color=(await ctx.embed_colour())) + embed.set_author(name="Timeout Cog Settings", icon_url=ctx.guild.me.display_avatar.url) + embed.add_field(name="Log Channel", value=log_channel, inline=True) + embed.add_field(name="Send Reports", value=report, inline=True) + embed.add_field(name="Timeout Role", value=timeout_role, inline=True) # Send embed await ctx.send(embed=embed) - @commands.command() + @commands.command() # type: ignore @checks.mod() - async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason="Unspecified"): + async def timeout(self, ctx: commands.GuildContext, user: discord.Member, *, reason="Unspecified"): """Timeouts a user or returns them from timeout if they are currently in timeout. See and edit current configuration with `[p]timeoutset`. @@ -285,6 +257,9 @@ async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason=" # Find the timeout role in server timeout_role_data = await self.config.guild(ctx.guild).timeoutrole() timeout_role = ctx.guild.get_role(timeout_role_data) + if not timeout_role: + await ctx.send("Please set the timeout role using `[p]timeoutset role`.") + return if await self.config.guild(ctx.guild).report() and not await self.config.guild(ctx.guild).logchannel(): await ctx.send("Please set the log channel using `[p]timeoutset logchannel`, or disable reporting.") @@ -306,7 +281,7 @@ async def timeout(self, ctx: commands.Context, user: discord.Member, *, reason=" # role, so we must ensure we avoid attempting to do so. booster_role = ctx.guild.premium_subscriber_role timeout_roleset = {timeout_role} - if booster_role in user.roles: + if booster_role is not None and booster_role in user.roles: timeout_roleset.add(booster_role) # Check if user already in timeout. diff --git a/topic/topic.py b/topic/topic.py index 5c4ffa26..7065b241 100644 --- a/topic/topic.py +++ b/topic/topic.py @@ -8,24 +8,40 @@ class Topic(commands.Cog): def __init__(self): pass + def _is_valid_channel(self, channel: discord.abc.MessageableChannel | discord.interactions.InteractionChannel | None): + if channel is not None and not isinstance( + channel, + ( + discord.VoiceChannel, + discord.Thread, + discord.DMChannel, + discord.PartialMessageable, + discord.GroupChannel, + discord.VoiceChannel, + discord.CategoryChannel, + ), + ): + return channel + return False + @commands.command() @commands.guild_only() - async def topic(self, ctx: commands.Context): + async def topic(self, ctx: commands.GuildContext): """Repeats the current channel's topic as a message in the channel.""" - - topic = ctx.channel.topic - if topic: - await ctx.send(f"{ctx.channel.mention}: {topic}") - else: - await ctx.send("This channel does not have a topic.") + if channel := self._is_valid_channel(ctx.channel): + topic = channel.topic + if topic: + await ctx.send(f"{ctx.channel.mention}: {topic}") + return + await ctx.send("This channel does not have a topic.") @app_commands.command(name="topic") @app_commands.guild_only() async def app_topic(self, interaction: discord.Interaction): """Repeats the current channel's topic as a message in the channel.""" - - topic = interaction.channel.topic - if topic: - await interaction.response.send_message(f"{interaction.channel.mention}: {topic}") - else: - await interaction.response.send_message("This channel does not have a topic.") + if channel := self._is_valid_channel(interaction.channel): + topic = channel.topic + if topic: + await interaction.response.send_message(f"{channel.mention}: {topic}") + return + await interaction.response.send_message("This channel does not have a topic.") diff --git a/verify/verify.py b/verify/verify.py index e09faf6d..73e38d1b 100644 --- a/verify/verify.py +++ b/verify/verify.py @@ -1,11 +1,16 @@ """discord red-bot verify""" + +import logging from datetime import timedelta +from typing import Optional import discord import Levenshtein as lev from redbot.core import Config, checks, commands from redbot.core.utils.mod import is_mod_or_superior +logger = logging.getLogger("red.rhomelab.verify") + class VerifyCog(commands.Cog): """Verify Cog""" @@ -27,13 +32,13 @@ def __init__(self, bot): "welcomechannel": None, "welcomemsg": "", "wrongmsg": "", - "welcome_ignore_roles": [] + "welcome_ignore_roles": [], } self.config.register_guild(**default_guild_settings, force_registration=True) @commands.Cog.listener() - async def on_message(self, message: discord.Message): + async def on_message(self, message: discord.Message): # noqa: PLR0911 if not isinstance(message.guild, discord.Guild): # The user has DM'd us. Ignore. return @@ -45,8 +50,7 @@ async def on_message(self, message: discord.Message): return author = message.author - valid_user = isinstance(author, discord.Member) and not author.bot - if not valid_user: + if not isinstance(author, discord.Member) or author.bot or author.joined_at is None: # User is a bot. Ignore. return @@ -91,10 +95,6 @@ async def on_message(self, message: discord.Message): if await self._verify_user(guild, author): await self._log_verify_message(guild, author, None) - role_id = await self.config.guild(guild).role() - role = guild.get_role(role_id) - await self._cleanup(message, role) - @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): """Verification event""" @@ -131,11 +131,14 @@ async def on_member_update(self, before: discord.Member, after: discord.Member): welcomechannel = await self.config.guild(guild).welcomechannel() if welcomechannel: welcomemsg = welcomemsg.format(user=after.mention) - await guild.get_channel(welcomechannel).send(welcomemsg) + if channel := self._is_valid_channel(guild.get_channel(welcomechannel)): + await channel.send(welcomemsg) + else: + logger.warning(f"Failed to get welcome channel {welcomechannel}, in guild {guild}") # Command groups - @commands.group(name="verify") + @commands.group(name="verify") # type: ignore @commands.guild_only() @checks.mod() async def _verify(self, ctx: commands.Context): @@ -144,7 +147,7 @@ async def _verify(self, ctx: commands.Context): # Commands @_verify.command("message") - async def verify_message(self, ctx: commands.Context, *, message: str): + async def verify_message(self, ctx: commands.GuildContext, *, message: str): """Sets the new verification message Example: @@ -157,10 +160,10 @@ async def verify_message(self, ctx: commands.Context, *, message: str): @_verify.command("welcome") async def verify_welcome( self, - ctx: commands.Context, - channel: discord.TextChannel = None, + ctx: commands.GuildContext, + channel: Optional[discord.TextChannel] = None, *, - message: str = None, + message: Optional[str] = None, ): """Sets the welcome message @@ -178,7 +181,7 @@ async def verify_welcome( await ctx.send("Welcome message set.") @_verify.command("tooquick") - async def verify_tooquick(self, ctx: commands.Context, message: str): + async def verify_tooquick(self, ctx: commands.GuildContext, message: str): """The message to reply if they're too quick at verifying Example: @@ -189,7 +192,7 @@ async def verify_tooquick(self, ctx: commands.Context, message: str): await ctx.send("Too quick reply message set.") @_verify.command("wrongmsg") - async def verify_wrongmsg(self, ctx: commands.Context, message: str): + async def verify_wrongmsg(self, ctx: commands.GuildContext, message: str): """The message to reply if they input the wrong verify message. Using `{user}` in the message will mention the user and allow the message to be deleted automatically once the user is verified. @@ -204,7 +207,7 @@ async def verify_wrongmsg(self, ctx: commands.Context, message: str): await ctx.send("Wrong verify message reply message set.") @_verify.command("role") - async def verify_role(self, ctx: commands.Context, role: discord.Role): + async def verify_role(self, ctx: commands.GuildContext, role: discord.Role): """Sets the verified role Example: @@ -214,7 +217,7 @@ async def verify_role(self, ctx: commands.Context, role: discord.Role): await ctx.send(f"Verify role set to `{role.name}`") @_verify.command("mintime") - async def verify_mintime(self, ctx: commands.Context, mintime: int): + async def verify_mintime(self, ctx: commands.GuildContext, mintime: int): """ Sets the minimum time a user must be in the discord server to be verified, using seconds as a unit. @@ -232,7 +235,7 @@ async def verify_mintime(self, ctx: commands.Context, mintime: int): await ctx.send(f"Verify minimum time set to {mintime} seconds") @_verify.command("channel") - async def verify_channel(self, ctx: commands.Context, channel: discord.TextChannel): + async def verify_channel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Sets the channel to post the message in to get the role Example: @@ -243,7 +246,7 @@ async def verify_channel(self, ctx: commands.Context, channel: discord.TextChann await ctx.send(f"Verify message channel set to `{channel.name}`") @_verify.command("logchannel") - async def verify_logchannel(self, ctx: commands.Context, channel: discord.TextChannel): + async def verify_logchannel(self, ctx: commands.GuildContext, channel: discord.TextChannel): """Sets the channel to post the verification logs Example: @@ -254,7 +257,7 @@ async def verify_logchannel(self, ctx: commands.Context, channel: discord.TextCh await ctx.send(f"Verify log message channel set to `{channel.name}`") @_verify.command("block") - async def verify_block(self, ctx: commands.Context, user: discord.Member): + async def verify_block(self, ctx: commands.GuildContext, user: discord.Member): """Blocks the user from verification Example: @@ -269,7 +272,7 @@ async def verify_block(self, ctx: commands.Context, user: discord.Member): await ctx.send(f"{user.mention} has already been blocked from verifying") @_verify.command("unblock") - async def verify_unlock(self, ctx: commands.Context, user: discord.Member): + async def verify_unlock(self, ctx: commands.GuildContext, user: discord.Member): """Unblocks the user from verification Example: @@ -284,7 +287,7 @@ async def verify_unlock(self, ctx: commands.Context, user: discord.Member): await ctx.send(f"{user.mention} wasn't blocked from verifying") @_verify.command("fuzziness") - async def _set_fuzziness(self, ctx, fuzziness: int): + async def _set_fuzziness(self, ctx: commands.GuildContext, fuzziness: int): """Sets the threshold for fuzzy matching of the verify message This command takes the `fuzziness` arg as a number from 0 - 100, with 0 requiring an exact match Verify checks are case insensitive regardless of fuzziness level @@ -301,7 +304,7 @@ async def _set_fuzziness(self, ctx, fuzziness: int): await ctx.send(f"Fuzzy matching threshold for verification set to `{fuzziness}%`") @_verify.command("status") - async def verify_status(self, ctx: commands.Context): + async def verify_status(self, ctx: commands.GuildContext): # noqa: PLR0912, PLR0915 """Status of the cog. The bot will display how many users it has verified since it's inception. @@ -338,17 +341,28 @@ async def verify_status(self, ctx: commands.Context): embed.add_field(name="Verified", value=f"{count} users") if role_id: - role = ctx.guild.get_role(role_id) - embed.add_field(name="Role", value=role.mention) + if role := ctx.guild.get_role(role_id): + embed.add_field(name="Role", value=role.mention) + else: + embed.add_field(name="ERROR: role with ID not found", value=role_id) + else: + embed.add_field(name="ERROR: role ID missing", value="") if channel_id: - channel = ctx.guild.get_channel(channel_id) - embed.add_field(name="Channel", value=channel.mention) + if channel := self._is_valid_channel(ctx.guild.get_channel(channel_id)): + embed.add_field(name="Channel", value=channel.mention) + else: + embed.add_field(name="ERROR: Channel with ID not found", value=channel_id) + else: + embed.add_field(name="ERROR: Channel ID missing", value="") if log_id: - log = ctx.guild.get_channel(log_id) - embed.add_field(name="Log", value=log.mention) - + if log_channel := self._is_valid_channel(ctx.guild.get_channel(log_id)): + embed.add_field(name="Log", value=log_channel.mention) + else: + embed.add_field(name="ERROR: Log channel with ID not found", value=log_id) + else: + embed.add_field(name="ERROR: Log channel ID missing", value="") embed.add_field(name="Min Time", value=f"{mintime} secs") embed.add_field(name="Message", value=f"`{message}`") embed.add_field(name="Too Quick Msg", value=f"`{tooquick}`") @@ -356,9 +370,8 @@ async def verify_status(self, ctx: commands.Context): if wrongmsg: embed.add_field(name="Wrong Msg", value=f"`{wrongmsg}`") - if welcomechannel: - welcome = ctx.guild.get_channel(welcomechannel) - embed.add_field(name="Welcome Channel", value=welcome.mention) + if welcomechannel and (welcome_channel := self._is_valid_channel(ctx.guild.get_channel(welcomechannel))): + embed.add_field(name="Welcome Channel", value=welcome_channel.mention) if welcomemsg: embed.add_field(name="Welcome Msg", value=f"`{welcomemsg}`") @@ -366,8 +379,10 @@ async def verify_status(self, ctx: commands.Context): if welcome_ignore_roles: welcome_ignore = "" for role in welcome_ignore_roles: - discord_role = ctx.guild.get_role(role) - welcome_ignore += f"{discord_role.name}, " + if role and (discord_role := ctx.guild.get_role(role)): + welcome_ignore += f"{discord_role.name}, " + else: + await ctx.send(f"ERROR: Welcome ignore role not found: {role}") embed.add_field(name="Welcome Ignore Roles", value=welcome_ignore.rstrip(", ")) embed.add_field(name="# Users Blocked", value=f"`{len(blocked_users)}`") @@ -378,10 +393,10 @@ async def verify_status(self, ctx: commands.Context): except discord.Forbidden: await ctx.send("I need the `Embed links` permission to send a verify status.") - @commands.command(name="v") + @commands.command(name="v") # type: ignore @commands.guild_only() @checks.mod() - async def verify_manual(self, ctx: commands.Context, user: discord.Member, *, reason: str = None): + async def verify_manual(self, ctx: commands.GuildContext, user: discord.Member, *, reason: Optional[str] = None): """Manually verifies a user Example: @@ -404,11 +419,16 @@ async def verify_manual(self, ctx: commands.Context, user: discord.Member, *, re # Helper functions + def _is_valid_channel(self, channel: discord.guild.GuildChannel | None): + if channel is not None and not isinstance(channel, (discord.ForumChannel, discord.CategoryChannel)): + return channel + return False + async def _log_verify_message( self, guild: discord.Guild, user: discord.Member, - verifier: discord.Member, + verifier: Optional[discord.Member] = None, **kwargs, ): """Private method for logging a message to the logchannel""" @@ -431,10 +451,11 @@ async def _log_verify_message( if reason: data.add_field(name="Reason", value=reason) if log: - try: - await log.send(embed=data) - except discord.Forbidden: - await log.send(f"**{message}** - {user.id} - {user}") + if channel := self._is_valid_channel(log): + try: + await channel.send(embed=data) + except discord.Forbidden: + await channel.send(f"**{message}** - {user.id} - {user}") async def _verify_user(self, guild: discord.Guild, member: discord.Member): """Private method for verifying a user""" @@ -442,10 +463,16 @@ async def _verify_user(self, guild: discord.Guild, member: discord.Member): if member.id in blocked_users: return False + log_id = await self.config.guild(guild).logchannel() role_id = await self.config.guild(guild).role() - role = guild.get_role(role_id) - await member.add_roles(role) - return True + if role := guild.get_role(role_id): + await member.add_roles(role) + return True + elif log_id and (log_channel := self._is_valid_channel(guild.get_channel(log_id))): + await log_channel.send(f"**User Not Verified Due To Error** - missing verified role. role_id: {role_id}") + else: + logger.warning(f"Failed to get log channel {log_id}, in guild {guild}") + return False @_verify.group(name="welcomeignore") async def welcome_ignore(self, ctx: commands.Context): @@ -466,7 +493,7 @@ async def welcome_ignore(self, ctx: commands.Context): pass @welcome_ignore.command(name="add") - async def welcome_ignore_add(self, ctx: commands.Context, role: discord.Role): + async def welcome_ignore_add(self, ctx: commands.GuildContext, role: discord.Role): """Add a role to the welcomeignore roles list Example: @@ -478,7 +505,7 @@ async def welcome_ignore_add(self, ctx: commands.Context, role: discord.Role): await ctx.tick() @welcome_ignore.command(name="remove") - async def welcome_ignore_remove(self, ctx: commands.Context, role: discord.Role): + async def welcome_ignore_remove(self, ctx: commands.GuildContext, role: discord.Role): """Remove a role to the welcomeignore roles list Example: @@ -490,7 +517,7 @@ async def welcome_ignore_remove(self, ctx: commands.Context, role: discord.Role) await ctx.tick() @welcome_ignore.command(name="list") - async def welcome_ignore_list(self, ctx: commands.Context): + async def welcome_ignore_list(self, ctx: commands.GuildContext): """List roles in the welcomeignore roles list Example: @@ -503,10 +530,13 @@ async def welcome_ignore_list(self, ctx: commands.Context): embed = discord.Embed(color=(await ctx.embed_colour())) for role in roles_config: - discord_role = ctx.guild.get_role(role) - embed.add_field(name="Role Name", value=discord_role.name, inline=True) - embed.add_field(name="Role ID", value=discord_role.id, inline=True) - embed.add_field(name="​", value="​", inline=True) # ZWJ field + if discord_role := ctx.guild.get_role(role): + embed.add_field(name="Role Name", value=discord_role.name, inline=True) + embed.add_field(name="Role ID", value=discord_role.id, inline=True) + embed.add_field(name="\u200b", value="\u200b", inline=True) # ZWJ field + else: + embed.add_field(name="ERROR: Role ID missing", value=role, inline=True) + embed.add_field(name="\u200b", value="\u200b", inline=True) # ZWJ field await ctx.send(embed=embed) else: diff --git a/xkcd/xkcd.py b/xkcd/xkcd.py index 6fb0903f..3c84800a 100644 --- a/xkcd/xkcd.py +++ b/xkcd/xkcd.py @@ -6,7 +6,7 @@ async def fetch_get(url_in: str) -> dict: """Make web requests""" async with aiohttp.request("GET", url_in) as response: - if response.status != 200: + if response.status != 200: # noqa: PLR2004 return {} return await response.json() @@ -42,9 +42,7 @@ async def xkcd(self, ctx: commands.Context, comic_number: int = 0): async def make_comic_embed(self, ctx: commands.Context, data: dict) -> discord.Embed: """Generate embed for xkcd comic""" xkcd_embed = discord.Embed( - title=f"xkcd Comic: #{data['num']}", - url=f"https://xkcd.com/{data['num']}", - colour=await ctx.embed_colour() + title=f"xkcd Comic: #{data['num']}", url=f"https://xkcd.com/{data['num']}", colour=await ctx.embed_colour() ) xkcd_embed.add_field(name="Comic Title", value=data["safe_title"]) xkcd_embed.add_field(name="Publish Date", value=f"{data['year']}-{data['month']}-{data['day']}")