diff options
| author | 2021-01-16 16:32:42 -0800 | |
|---|---|---|
| committer | 2021-01-16 16:32:42 -0800 | |
| commit | 6912639e5c35a5ce7500073851ea428ee9f6c379 (patch) | |
| tree | 3f3b47a50c9c454951962c5b24989b3b483cf519 | |
| parent | Only helpers and below now get command suggestions (diff) | |
| parent | Merge pull request #1354 from python-discord/remove-unnomiation-reason (diff) | |
Merge branch 'master' into feat/F4zi/CommandSuggestion
Diffstat (limited to '')
| -rw-r--r-- | Pipfile | 2 | ||||
| -rw-r--r-- | Pipfile.lock | 42 | ||||
| -rw-r--r-- | bot/constants.py | 13 | ||||
| -rw-r--r-- | bot/exts/backend/error_handler.py | 8 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 8 | ||||
| -rw-r--r-- | bot/exts/info/tags.py | 4 | ||||
| -rw-r--r-- | bot/exts/moderation/silence.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/verification.py | 675 | ||||
| -rw-r--r-- | bot/exts/moderation/watchchannels/talentpool.py | 19 | ||||
| -rw-r--r-- | bot/exts/utils/jams.py | 4 | ||||
| -rw-r--r-- | bot/rules/burst_shared.py | 11 | ||||
| -rw-r--r-- | config-default.yml | 17 | ||||
| -rw-r--r-- | tests/bot/exts/utils/test_jams.py | 4 | 
13 files changed, 58 insertions, 751 deletions
| @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9"  colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}  coloredlogs = "~=14.0"  deepdiff = "~=4.0" -"discord.py" = {git = "https://github.com/Rapptz/discord.py.git", ref = "93f102ca907af6722ee03638766afd53dfe93a7f"} +"discord.py" = "~=1.6.0"  feedparser = "~=5.2"  fuzzywuzzy = "~=0.17"  lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 6606a3791..3f9c32d62 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "1ba637e521c654a23bcc82950e155f5366219eae00bbf809170a371122961a4f" +            "sha256": "f9f28d3d98e12f92c179e6d88444d1a9ad57557683b7116a91f0b1650d399848"          },          "pipfile-spec": 6,          "requires": { @@ -211,6 +211,7 @@                  "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",                  "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"              ], +            "index": "pypi",              "markers": "sys_platform == 'win32'",              "version": "==0.4.4"          }, @@ -230,13 +231,13 @@              "index": "pypi",              "version": "==4.3.2"          }, -        "discord-py": { -            "git": "https://github.com/Rapptz/discord.py.git", -            "ref": "93f102ca907af6722ee03638766afd53dfe93a7f" -        },          "discord.py": { -            "git": "https://github.com/Rapptz/discord.py.git", -            "ref": "93f102ca907af6722ee03638766afd53dfe93a7f" +            "hashes": [ +                "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12", +                "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f" +            ], +            "index": "pypi", +            "version": "==1.6.0"          },          "docutils": {              "hashes": [ @@ -582,6 +583,15 @@              "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'",              "version": "==2.4.7"          }, +        "pyreadline": { +            "hashes": [ +                "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1", +                "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e", +                "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b" +            ], +            "markers": "sys_platform == 'win32'", +            "version": "==2.1" +        },          "python-dateutil": {              "hashes": [                  "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", @@ -925,11 +935,11 @@          },          "flake8-annotations": {              "hashes": [ -                "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1", -                "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df" +                "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055", +                "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e"              ],              "index": "pypi", -            "version": "==2.4.1" +            "version": "==2.5.0"          },          "flake8-bugbear": {              "hashes": [ @@ -987,11 +997,11 @@          },          "identify": {              "hashes": [ -                "sha256:7aef7a5104d6254c162990e54a203cdc0fd202046b6c415bd5d636472f6565c4", -                "sha256:b2c71bf9f5c482c389cef816f3a15f1c9d7429ad70f497d4a2e522442d80c6de" +                "sha256:18994e850ba50c37bcaed4832be8b354d6a06c8fb31f54e0e7ece76d32f69bc8", +                "sha256:892473bf12e655884132a3a32aca737a3cbefaa34a850ff52d501773a45837bc"              ],              "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", -            "version": "==1.5.11" +            "version": "==1.5.12"          },          "idna": {              "hashes": [ @@ -1115,11 +1125,11 @@          },          "virtualenv": {              "hashes": [ -                "sha256:54b05fc737ea9c9ee9f8340f579e5da5b09fb64fd010ab5757eb90268616907c", -                "sha256:b7a8ec323ee02fb2312f098b6b4c9de99559b462775bc8fe3627a73706603c1b" +                "sha256:205a7577275dd0d9223c730dd498e21a8910600085c3dee97412b041fc4b853b", +                "sha256:7992b8de87e544a4ab55afc2240bf8388c4e3b5765d03784dad384bfdf9097ee"              ],              "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", -            "version": "==20.2.2" +            "version": "==20.3.0"          }      }  } diff --git a/bot/constants.py b/bot/constants.py index 6bfda160b..d813046ab 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -434,7 +434,6 @@ class Channels(metaclass=YAMLGetter):      talent_pool: int      user_event_announcements: int      user_log: int -    verification: int      voice_chat: int      voice_gate: int      voice_log: int @@ -471,8 +470,6 @@ class Roles(metaclass=YAMLGetter):      python_community: int      sprinters: int      team_leaders: int -    unverified: int -    verified: int  # This is the Developers role on PyDis, here named verified for readability reasons.      voice_verified: int @@ -594,16 +591,6 @@ class PythonNews(metaclass=YAMLGetter):      webhook: int -class Verification(metaclass=YAMLGetter): -    section = "verification" - -    unverified_after: int -    kicked_after: int -    reminder_frequency: int -    bot_message_delete_delay: int -    kick_confirmation_threshold: float - -  class VoiceGate(metaclass=YAMLGetter):      section = "voice_gate" diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 51fbac99b..2402fa175 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -48,7 +48,6 @@ class ErrorHandler(Cog):              * If CommandNotFound is raised when invoking the tag (determined by the presence of the                `invoked_from_error_handler` attribute), this error is treated as being unexpected                and therefore sends an error message -            * Commands in the verification channel are ignored          2. UserInputError: see `handle_user_input_error`          3. CheckFailure: see `handle_check_failure`          4. CommandOnCooldown: send an error message in the invoking context @@ -64,10 +63,9 @@ class ErrorHandler(Cog):          if isinstance(e, errors.CommandNotFound) and not hasattr(ctx, "invoked_from_error_handler"):              if await self.try_silence(ctx):                  return -            if ctx.channel.id != Channels.verification: -                # Try to look for a tag with the command's name -                await self.try_get_tag(ctx) -                return  # Exit early to avoid logging. +            # Try to look for a tag with the command's name +            await self.try_get_tag(ctx) +            return  # Exit early to avoid logging.          elif isinstance(e, errors.UserInputError):              await self.handle_user_input_error(ctx, e)          elif isinstance(e, errors.CheckFailure): diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index b2138b03f..38e760ee3 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -419,10 +419,14 @@ class Information(Cog):          return out.rstrip()      @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) -    @group(invoke_without_command=True, enabled=False) +    @group(invoke_without_command=True)      @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)      async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:          """Shows information about the raw API response.""" +        if ctx.author not in message.channel.members: +            await ctx.send(":x: You do not have permissions to see the channel this message is in.") +            return +          # I *guess* it could be deleted right as the command is invoked but I felt like it wasn't worth handling          # doing this extra request is also much easier than trying to convert everything back into a dictionary again          raw_data = await ctx.bot.http.get_message(message.channel.id, message.id) @@ -454,7 +458,7 @@ class Information(Cog):          for page in paginator.pages:              await ctx.send(page) -    @raw.command(enabled=False) +    @raw.command()      async def json(self, ctx: Context, message: Message) -> None:          """Shows information about the raw API response in a copy-pasteable Python format."""          await ctx.invoke(self.raw, message=message, json=True) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index e3b9d6d5b..639286d90 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -46,7 +46,7 @@ class Tags(Cog):                      "embed": {                          "description": file.read_text(encoding="utf8"),                      }, -                    "restricted_to": "developers", +                    "restricted_to": None,                      "location": f"/bot/{file}"                  } @@ -63,7 +63,7 @@ class Tags(Cog):      @staticmethod      def check_accessibility(user: Member, tag: dict) -> bool:          """Check if user can access a tag.""" -        return tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] +        return not tag["restricted_to"] or tag["restricted_to"].lower() in [role.name.lower() for role in user.roles]      @staticmethod      def _fuzzy_search(search: str, target: str) -> float: diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index a942d5294..2a7ca932e 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -72,7 +72,7 @@ class SilenceNotifier(tasks.Loop):  class Silence(commands.Cog): -    """Commands for stopping channel messages for `verified` role in a channel.""" +    """Commands for stopping channel messages for `everyone` role in a channel."""      # Maps muted channel IDs to their previous overwrites for send_message and add_reactions.      # Overwrites are stored as JSON. diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index ce91dcb15..2a24c8ec6 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -1,27 +1,18 @@ -import asyncio  import logging  import typing as t -from contextlib import suppress -from datetime import datetime, timedelta  import discord -from async_rediscache import RedisCache -from discord.ext import tasks -from discord.ext.commands import Cog, Context, command, group, has_any_role -from discord.utils import snowflake_time +from discord.ext.commands import Cog, Context, command, has_any_role  from bot import constants -from bot.api import ResponseCodeError  from bot.bot import Bot -from bot.decorators import has_no_roles, in_whitelist -from bot.exts.moderation.modlog import ModLog -from bot.utils.checks import InWhitelistCheckFailure, has_no_roles_check -from bot.utils.messages import format_user +from bot.decorators import in_whitelist +from bot.utils.checks import InWhitelistCheckFailure  log = logging.getLogger(__name__)  # Sent via DMs once user joins the guild -ON_JOIN_MESSAGE = f""" +ON_JOIN_MESSAGE = """  Welcome to Python Discord!  To show you what kind of community we are, we've created this video: @@ -29,32 +20,9 @@ https://youtu.be/ZH26PuX3re0  As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. \  In order to see the rest of the channels and to send messages, you first have to accept our rules. - -Please visit <#{constants.Channels.verification}> to get started. Thank you!  """ -# Sent via DMs once user verifies  VERIFIED_MESSAGE = f""" -Thanks for verifying yourself! - -For your records, these are the documents you accepted: - -`1)` Our rules, here: <https://pythondiscord.com/pages/rules> -`2)` Our privacy policy, here: <https://pythondiscord.com/pages/privacy> - you can find information on how to have \ -your information removed here as well. - -Feel free to review them at any point! - -Additionally, if you'd like to receive notifications for the announcements \ -we post in <#{constants.Channels.announcements}> -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ -to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. - -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. -""" - -ALTERNATE_VERIFIED_MESSAGE = f"""  You are now verified!  You can find a copy of our rules for reference at <https://pythondiscord.com/pages/rules>. @@ -71,61 +39,6 @@ To introduce you to our community, we've made the following video:  https://youtu.be/ZH26PuX3re0  """ -# Sent via DMs to users kicked for failing to verify -KICKED_MESSAGE = f""" -Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ -within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again! - -{constants.Guild.invite} -""" - -# Sent periodically in the verification channel -REMINDER_MESSAGE = f""" -<@&{constants.Roles.unverified}> - -Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \ -to send messages in the community! - -You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days. -""".strip() - -# An async function taking a Member param -Request = t.Callable[[discord.Member], t.Awaitable] - - -class StopExecution(Exception): -    """Signals that a task should halt immediately & alert admins.""" - -    def __init__(self, reason: discord.HTTPException) -> None: -        super().__init__() -        self.reason = reason - - -class Limit(t.NamedTuple): -    """Composition over config for throttling requests.""" - -    batch_size: int  # Amount of requests after which to pause -    sleep_secs: int  # Sleep this many seconds after each batch - - -def mention_role(role_id: int) -> discord.AllowedMentions: -    """Construct an allowed mentions instance that allows pinging `role_id`.""" -    return discord.AllowedMentions(roles=[discord.Object(role_id)]) - - -def is_verified(member: discord.Member) -> bool: -    """ -    Check whether `member` is considered verified. - -    Members are considered verified if they have at least 1 role other than -    the default role (@everyone) and the @Unverified role. -    """ -    unverified_roles = { -        member.guild.get_role(constants.Roles.unverified), -        member.guild.default_role, -    } -    return len(set(member.roles) - unverified_roles) > 0 -  async def safe_dm(coro: t.Coroutine) -> None:      """ @@ -150,410 +63,16 @@ class Verification(Cog):      """      User verification and role management. -    There are two internal tasks in this cog: - -    * `update_unverified_members` -        * Unverified members are given the @Unverified role after configured `unverified_after` days -        * Unverified members are kicked after configured `kicked_after` days -    * `ping_unverified` -        * Periodically ping the @Unverified role in the verification channel -      Statistics are collected in the 'verification.' namespace. -    Moderators+ can use the `verification` command group to start or stop both internal -    tasks, if necessary. Settings are persisted in Redis across sessions. - -    Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands, -    and keeps the verification channel clean by deleting messages. +    Additionally, this cog offers the !subscribe and !unsubscribe commands,      """ -    # Persist task settings & last sent `REMINDER_MESSAGE` id -    # RedisCache[ -    #   "tasks_running": int (0 or 1), -    #   "last_reminder": int (discord.Message.id), -    # ] -    task_cache = RedisCache() -      def __init__(self, bot: Bot) -> None:          """Start internal tasks."""          self.bot = bot -        self.bot.loop.create_task(self._maybe_start_tasks()) -          self.pending_members = set() -    def cog_unload(self) -> None: -        """ -        Cancel internal tasks. - -        This is necessary, as tasks are not automatically cancelled on cog unload. -        """ -        self._stop_tasks(gracefully=False) - -    @property -    def mod_log(self) -> ModLog: -        """Get currently loaded ModLog cog instance.""" -        return self.bot.get_cog("ModLog") - -    async def _maybe_start_tasks(self) -> None: -        """ -        Poll Redis to check whether internal tasks should start. - -        Redis must be interfaced with from an async function. -        """ -        log.trace("Checking whether background tasks should begin") -        setting: t.Optional[int] = await self.task_cache.get("tasks_running")  # This can be None if never set - -        if setting: -            log.trace("Background tasks will be started") -            self.update_unverified_members.start() -            self.ping_unverified.start() - -    def _stop_tasks(self, *, gracefully: bool) -> None: -        """ -        Stop the update users & ping @Unverified tasks. - -        If `gracefully` is True, the tasks will be able to finish their current iteration. -        Otherwise, they are cancelled immediately. -        """ -        log.info(f"Stopping internal tasks ({gracefully=})") -        if gracefully: -            self.update_unverified_members.stop() -            self.ping_unverified.stop() -        else: -            self.update_unverified_members.cancel() -            self.ping_unverified.cancel() - -    # region: automatically update unverified users - -    async def _verify_kick(self, n_members: int) -> bool: -        """ -        Determine whether `n_members` is a reasonable amount of members to kick. - -        First, `n_members` is checked against the size of the PyDis guild. If `n_members` are -        more than the configured `kick_confirmation_threshold` of the guild, the operation -        must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe. -        """ -        log.debug(f"Checking whether {n_members} members are safe to kick") - -        await self.bot.wait_until_guild_available()  # Ensure cache is populated before we grab the guild -        pydis = self.bot.get_guild(constants.Guild.id) - -        percentage = n_members / len(pydis.members) -        if percentage < constants.Verification.kick_confirmation_threshold: -            log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe") -            return True - -        # Since `n_members` is a suspiciously large number, we will ask for confirmation -        log.debug("Amount of users is too large, requesting staff confirmation") - -        core_dev_channel = pydis.get_channel(constants.Channels.dev_core) -        core_dev_ping = f"<@&{constants.Roles.core_developers}>" - -        confirmation_msg = await core_dev_channel.send( -            f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " -            f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's " -            f"population. Proceed?", -            allowed_mentions=mention_role(constants.Roles.core_developers), -        ) - -        options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) -        for option in options: -            await confirmation_msg.add_reaction(option) - -        core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members] - -        def check(reaction: discord.Reaction, user: discord.User) -> bool: -            """Check whether `reaction` is a valid reaction to `confirmation_msg`.""" -            return ( -                reaction.message.id == confirmation_msg.id  # Reacted to `confirmation_msg` -                and str(reaction.emoji) in options  # With one of `options` -                and user.id in core_dev_ids  # By a core developer -            ) - -        timeout = 60 * 5  # Seconds, i.e. 5 minutes -        try: -            choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout) -        except asyncio.TimeoutError: -            log.debug("Staff prompt not answered, aborting operation") -            return False -        finally: -            with suppress(discord.HTTPException): -                await confirmation_msg.clear_reactions() - -        result = str(choice) == constants.Emojis.incident_actioned -        log.debug(f"Received answer: {choice}, result: {result}") - -        # Edit the prompt message to reflect the final choice -        if result is True: -            result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!" -        else: -            result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!" - -        with suppress(discord.HTTPException): -            await confirmation_msg.edit(content=result_msg) - -        return result - -    async def _alert_admins(self, exception: discord.HTTPException) -> None: -        """ -        Ping @Admins with information about `exception`. - -        This is used when a critical `exception` caused a verification task to abort. -        """ -        await self.bot.wait_until_guild_available() -        log.info(f"Sending admin alert regarding exception: {exception}") - -        admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins) -        ping = f"<@&{constants.Roles.admins}>" - -        await admins_channel.send( -            f"{ping} Aborted updating unverified users due to the following exception:\n" -            f"```{exception}```\n" -            f"Internal tasks will be stopped.", -            allowed_mentions=mention_role(constants.Roles.admins), -        ) - -    async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: -        """ -        Pass `members` one by one to `request` handling Discord exceptions. - -        This coroutine serves as a generic `request` executor for kicking members and adding -        roles, as it allows us to define the error handling logic in one place only. - -        Any `request` has the ability to completely abort the execution by raising `StopExecution`. -        In such a case, the @Admins will be alerted of the reason attribute. - -        To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds -        to sleep between batches. - -        Returns the amount of successful requests. Failed requests are logged at info level. -        """ -        log.trace(f"Sending {len(members)} requests") -        n_success, bad_statuses = 0, set() - -        for progress, member in enumerate(members, start=1): -            if is_verified(member):  # Member could have verified in the meantime -                continue -            try: -                await request(member) -            except StopExecution as stop_execution: -                await self._alert_admins(stop_execution.reason) -                await self.task_cache.set("tasks_running", 0) -                self._stop_tasks(gracefully=True)  # Gracefully finish current iteration, then stop -                break -            except discord.HTTPException as http_exc: -                bad_statuses.add(http_exc.status) -            else: -                n_success += 1 - -            if progress % limit.batch_size == 0: -                log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds") -                await asyncio.sleep(limit.sleep_secs) - -        if bad_statuses: -            log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}") - -        return n_success - -    async def _add_kick_note(self, member: discord.Member) -> None: -        """ -        Post a note regarding `member` being kicked to site. - -        Allows keeping track of kicked members for auditing purposes. -        """ -        payload = { -            "active": False, -            "actor": self.bot.user.id,  # Bot actions this autonomously -            "expires_at": None, -            "hidden": True, -            "reason": "Verification kick", -            "type": "note", -            "user": member.id, -        } - -        log.trace(f"Posting kick note for member {member} ({member.id})") -        try: -            await self.bot.api_client.post("bot/infractions", json=payload) -        except ResponseCodeError as api_exc: -            log.warning("Failed to post kick note", exc_info=api_exc) - -    async def _kick_members(self, members: t.Collection[discord.Member]) -> int: -        """ -        Kick `members` from the PyDis guild. - -        Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second -        after each 2 requests to allow breathing room for other features. - -        Note that this is a potentially destructive operation. Returns the amount of successful requests. -        """ -        log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)") - -        async def kick_request(member: discord.Member) -> None: -            """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" -            try: -                await safe_dm(member.send(KICKED_MESSAGE))  # Suppress disabled DMs -            except discord.HTTPException as suspicious_exception: -                raise StopExecution(reason=suspicious_exception) -            await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") -            await self._add_kick_note(member) - -        n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) -        self.bot.stats.incr("verification.kicked", count=n_kicked) - -        return n_kicked - -    async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int: -        """ -        Give `role` to all `members`. - -        We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded. - -        Returns the amount of successful requests. -        """ -        log.info( -            f"Assigning {role} role to {len(members)} members (not verified " -            f"after {constants.Verification.unverified_after} days)" -        ) - -        async def role_request(member: discord.Member) -> None: -            """Add `role` to `member`.""" -            await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days") - -        return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1)) - -    async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: -        """ -        Check in on the verification status of PyDis members. - -        This coroutine finds two sets of users: -        * Not verified after configured `unverified_after` days, should be given the @Unverified role -        * Not verified after configured `kicked_after` days, should be kicked from the guild - -        These sets are always disjoint, i.e. share no common members. -        """ -        await self.bot.wait_until_guild_available()  # Ensure cache is ready -        pydis = self.bot.get_guild(constants.Guild.id) - -        unverified = pydis.get_role(constants.Roles.unverified) -        current_dt = datetime.utcnow()  # Discord timestamps are UTC - -        # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint -        for_role, for_kick = set(), set() - -        log.debug("Checking verification status of guild members") -        for member in pydis.members: - -            # Skip verified members, bots, and members for which we do not know their join date, -            # this should be extremely rare but docs mention that it can happen -            if is_verified(member) or member.bot or member.joined_at is None: -                continue - -            # At this point, we know that `member` is an unverified user, and we will decide what -            # to do with them based on time passed since their join date -            since_join = current_dt - member.joined_at - -            if since_join > timedelta(days=constants.Verification.kicked_after): -                for_kick.add(member)  # User should be removed from the guild - -            elif ( -                since_join > timedelta(days=constants.Verification.unverified_after) -                and unverified not in member.roles -            ): -                for_role.add(member)  # User should be given the @Unverified role - -        log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") -        return for_role, for_kick - -    @tasks.loop(minutes=30) -    async def update_unverified_members(self) -> None: -        """ -        Periodically call `_check_members` and update unverified members accordingly. - -        After each run, a summary will be sent to the modlog channel. If a suspiciously high -        amount of members to be kicked is found, the operation is guarded by `_verify_kick`. -        """ -        log.info("Updating unverified guild members") - -        await self.bot.wait_until_guild_available() -        unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified) - -        for_role, for_kick = await self._check_members() - -        if not for_role: -            role_report = f"Found no users to be assigned the {unverified.mention} role." -        else: -            n_roles = await self._give_role(for_role, unverified) -            role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members." - -        if not for_kick: -            kick_report = "Found no users to be kicked." -        elif not await self._verify_kick(len(for_kick)): -            kick_report = f"Not authorized to kick `{len(for_kick)}` members." -        else: -            n_kicks = await self._kick_members(for_kick) -            kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild." - -        await self.mod_log.send_log_message( -            icon_url=self.bot.user.avatar_url, -            colour=discord.Colour.blurple(), -            title="Verification system", -            text=f"{kick_report}\n{role_report}", -        ) - -    # endregion -    # region: periodically ping @Unverified - -    @tasks.loop(hours=constants.Verification.reminder_frequency) -    async def ping_unverified(self) -> None: -        """ -        Delete latest `REMINDER_MESSAGE` and send it again. - -        This utilizes RedisCache to persist the latest reminder message id. -        """ -        await self.bot.wait_until_guild_available() -        verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification) - -        last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") - -        if last_reminder is not None: -            log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}") - -            with suppress(discord.HTTPException):  # If something goes wrong, just ignore it -                await self.bot.http.delete_message(verification.id, last_reminder) - -        log.trace("Sending verification reminder") -        new_reminder = await verification.send( -            REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified), -        ) - -        await self.task_cache.set("last_reminder", new_reminder.id) - -    @ping_unverified.before_loop -    async def _before_first_ping(self) -> None: -        """ -        Sleep until `REMINDER_MESSAGE` should be sent again. - -        If latest reminder is not cached, exit instantly. Otherwise, wait wait until the -        configured `reminder_frequency` has passed. -        """ -        last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") - -        if last_reminder is None: -            log.trace("Latest verification reminder message not cached, task will not wait") -            return - -        # Convert cached message id into a timestamp -        time_since = datetime.utcnow() - snowflake_time(last_reminder) -        log.trace(f"Time since latest verification reminder: {time_since}") - -        to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since -        log.trace(f"Time to sleep until next ping: {to_sleep}") - -        # Delta can be negative if `reminder_frequency` has already passed -        secs = max(to_sleep.total_seconds(), 0) -        await asyncio.sleep(secs) - -    # endregion      # region: listeners      @Cog.listener() @@ -586,183 +105,12 @@ class Verification(Cog):                  # and has gone through the alternate gating system we should send                  # our alternate welcome DM which includes info such as our welcome                  # video. -                await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) +                await safe_dm(after.send(VERIFIED_MESSAGE))              except discord.HTTPException:                  log.exception("DM dispatch failed on unexpected error code") -    @Cog.listener() -    async def on_message(self, message: discord.Message) -> None: -        """Check new message event for messages to the checkpoint channel & process.""" -        if message.channel.id != constants.Channels.verification: -            return  # Only listen for #checkpoint messages - -        if message.content == REMINDER_MESSAGE: -            return  # Ignore bots own verification reminder - -        if message.author.bot: -            # They're a bot, delete their message after the delay. -            await message.delete(delay=constants.Verification.bot_message_delete_delay) -            return - -        # if a user mentions a role or guild member -        # alert the mods in mod-alerts channel -        if message.mentions or message.role_mentions: -            log.debug( -                f"{message.author} mentioned one or more users " -                f"and/or roles in {message.channel.name}" -            ) - -            embed_text = ( -                f"{format_user(message.author)} sent a message in " -                f"{message.channel.mention} that contained user and/or role mentions." -                f"\n\n**Original message:**\n>>> {message.content}" -            ) - -            # Send pretty mod log embed to mod-alerts -            await self.mod_log.send_log_message( -                icon_url=constants.Icons.filtering, -                colour=discord.Colour(constants.Colours.soft_red), -                title=f"User/Role mentioned in {message.channel.name}", -                text=embed_text, -                thumbnail=message.author.avatar_url_as(static_format="png"), -                channel_id=constants.Channels.mod_alerts, -            ) - -        ctx: Context = await self.bot.get_context(message) -        if ctx.command is not None and ctx.command.name == "accept": -            return - -        if any(r.id == constants.Roles.verified for r in ctx.author.roles): -            log.info( -                f"{ctx.author} posted '{ctx.message.content}' " -                "in the verification channel, but is already verified." -            ) -            return - -        log.debug( -            f"{ctx.author} posted '{ctx.message.content}' in the verification " -            "channel. We are providing instructions how to verify." -        ) -        await ctx.send( -            f"{ctx.author.mention} Please type `!accept` to verify that you accept our rules, " -            f"and gain access to the rest of the server.", -            delete_after=20 -        ) - -        log.trace(f"Deleting the message posted by {ctx.author}") -        with suppress(discord.NotFound): -            await ctx.message.delete() -      # endregion -    # region: task management commands - -    @has_any_role(*constants.MODERATION_ROLES) -    @group(name="verification") -    async def verification_group(self, ctx: Context) -> None: -        """Manage internal verification tasks.""" -        if ctx.invoked_subcommand is None: -            await ctx.send_help(ctx.command) - -    @verification_group.command(name="status") -    async def status_cmd(self, ctx: Context) -> None: -        """Check whether verification tasks are running.""" -        log.trace("Checking status of verification tasks") - -        if self.update_unverified_members.is_running(): -            update_status = f"{constants.Emojis.incident_actioned} Member update task is running." -        else: -            update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running." - -        mention = f"<@&{constants.Roles.unverified}>" -        if self.ping_unverified.is_running(): -            ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running." -        else: -            ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running." - -        embed = discord.Embed( -            title="Verification system", -            description=f"{update_status}\n{ping_status}", -            colour=discord.Colour.blurple(), -        ) -        await ctx.send(embed=embed) - -    @verification_group.command(name="start") -    async def start_cmd(self, ctx: Context) -> None: -        """Start verification tasks if they are not already running.""" -        log.info("Starting verification tasks") - -        if not self.update_unverified_members.is_running(): -            self.update_unverified_members.start() - -        if not self.ping_unverified.is_running(): -            self.ping_unverified.start() - -        await self.task_cache.set("tasks_running", 1) - -        colour = discord.Colour.blurple() -        await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) - -    @verification_group.command(name="stop", aliases=["kill"]) -    async def stop_cmd(self, ctx: Context) -> None: -        """Stop verification tasks.""" -        log.info("Stopping verification tasks") - -        self._stop_tasks(gracefully=False) -        await self.task_cache.set("tasks_running", 0) - -        colour = discord.Colour.blurple() -        await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) - -    # endregion -    # region: accept and subscribe commands - -    def _bump_verified_stats(self, verified_member: discord.Member) -> None: -        """ -        Increment verification stats for `verified_member`. - -        Each member falls into one of the three categories: -            * Verified within 24 hours after joining -            * Does not have @Unverified role yet -            * Does have @Unverified role - -        Stats for member kicking are handled separately. -        """ -        if verified_member.joined_at is None:  # Docs mention this can happen -            return - -        if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24): -            category = "accepted_on_day_one" -        elif constants.Roles.unverified not in [role.id for role in verified_member.roles]: -            category = "accepted_before_unverified" -        else: -            category = "accepted_after_unverified" - -        log.trace(f"Bumping verification stats in category: {category}") -        self.bot.stats.incr(f"verification.{category}") - -    @command(name='accept', aliases=('verified', 'accepted'), hidden=True) -    @has_no_roles(constants.Roles.verified) -    @in_whitelist(channels=(constants.Channels.verification,)) -    async def accept_command(self, ctx: Context, *_) -> None:  # We don't actually care about the args -        """Accept our rules and gain access to the rest of the server.""" -        log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") -        await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules") - -        self._bump_verified_stats(ctx.author)  # This checks for @Unverified so make sure it's not yet removed - -        if constants.Roles.unverified in [role.id for role in ctx.author.roles]: -            log.debug(f"Removing Unverified role from: {ctx.author}") -            await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) - -        try: -            await safe_dm(ctx.author.send(VERIFIED_MESSAGE)) -        except discord.HTTPException: -            log.exception(f"Sending welcome message failed for {ctx.author}.") -        finally: -            log.trace(f"Deleting accept message by {ctx.author}.") -            with suppress(discord.NotFound): -                self.mod_log.ignore(constants.Event.message_delete, ctx.message.id) -                await ctx.message.delete() +    # region: subscribe commands      @command(name='subscribe')      @in_whitelist(channels=(constants.Channels.bot_commands,)) @@ -823,15 +171,6 @@ class Verification(Cog):          if isinstance(error, InWhitelistCheckFailure):              error.handled = True -    @staticmethod -    async def bot_check(ctx: Context) -> bool: -        """Block any command within the verification channel that is not !accept.""" -        is_verification = ctx.channel.id == constants.Channels.verification -        if is_verification and await has_no_roles_check(ctx, *constants.MODERATION_ROLES): -            return ctx.command.name == "accept" -        else: -            return True -      @command(name='verify')      @has_any_role(*constants.MODERATION_ROLES)      async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None: diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index a77dbe156..dd3349c3a 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -64,12 +64,12 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))      @has_any_role(*STAFF_ROLES) -    async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: +    async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None:          """          Relay messages sent by the given `user` to the `#talent-pool` channel. -        A `reason` for adding the user to the talent pool is required and will be displayed -        in the header when relaying messages of this user to the channel. +        A `reason` for adding the user to the talent pool is optional. +        If given, it will be displayed in the header when relaying messages of this user to the channel.          """          if user.bot:              await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") @@ -122,8 +122,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          if history:              total = f"({len(history)} previous nominations in total)"              start_reason = f"Watched: {textwrap.shorten(history[0]['reason'], width=500, placeholder='...')}" -            end_reason = f"Unwatched: {textwrap.shorten(history[0]['end_reason'], width=500, placeholder='...')}" -            msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}\n\n{end_reason}```" +            msg += f"\n\nUser's previous watch reasons {total}:```{start_reason}```"          await ctx.send(msg) @@ -202,7 +201,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              f"{self.api_endpoint}/{nomination_id}",              json={field: reason}          ) - +        await self.fetch_user_cache()  # Update cache.          await ctx.send(f":white_check_mark: Updated the {field} of the nomination!")      @Cog.listener() @@ -243,8 +242,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          actor = guild.get_member(actor_id)          active = nomination_object["active"] -        log.debug(active) -        log.debug(type(nomination_object["inserted_at"])) + +        reason = nomination_object["reason"] or "*None*"          start_date = time.format_infraction(nomination_object["inserted_at"])          if active: @@ -254,7 +253,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):                  Status: **Active**                  Date: {start_date}                  Actor: {actor.mention if actor else actor_id} -                Reason: {nomination_object["reason"]} +                Reason: {reason}                  Nomination ID: `{nomination_object["id"]}`                  ===============                  """ @@ -267,7 +266,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):                  Status: Inactive                  Date: {start_date}                  Actor: {actor.mention if actor else actor_id} -                Reason: {nomination_object["reason"]} +                Reason: {reason}                  End date: {end_date}                  Unwatch reason: {nomination_object["end_reason"]} diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 1c0988343..98fbcb303 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -93,10 +93,6 @@ class CodeJams(commands.Cog):                  connect=True              ),              guild.default_role: PermissionOverwrite(read_messages=False, connect=False), -            guild.get_role(Roles.verified): PermissionOverwrite( -                read_messages=False, -                connect=False -            )          }          # Rest of members should just have read_messages diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index 0e66df69c..bbe9271b3 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -2,20 +2,11 @@ from typing import Dict, Iterable, List, Optional, Tuple  from discord import Member, Message -from bot.constants import Channels -  async def apply(      last_message: Message, recent_messages: List[Message], config: Dict[str, int]  ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: -    """ -    Detects repeated messages sent by multiple users. - -    This filter never triggers in the verification channel. -    """ -    if last_message.channel.id == Channels.verification: -        return - +    """Detects repeated messages sent by multiple users."""      total_recent = len(recent_messages)      if total_recent > config['max']: diff --git a/config-default.yml b/config-default.yml index ca89bb639..175460a31 100644 --- a/config-default.yml +++ b/config-default.yml @@ -173,7 +173,6 @@ guild:          # Special          bot_commands:       &BOT_CMD        267659945086812160          esoteric:                           470884583684964352 -        verification:                       352442727016693763          voice_gate:                         764802555427029012          # Staff @@ -244,8 +243,6 @@ guild:          python_community:   &PY_COMMUNITY_ROLE  458226413825294336          sprinters:          &SPRINTERS          758422482289426471 -        unverified:                             739794855945044069 -        verified:                               352427296948486144  # @Developers on PyDis          voice_verified:                         764802720779337729          # Staff @@ -489,7 +486,7 @@ redirect_output:  duck_pond: -    threshold: 4 +    threshold: 5      channel_blacklist:          - *ANNOUNCEMENTS          - *PYNEWS_CHANNEL @@ -514,18 +511,6 @@ python_news:      webhook: *PYNEWS_WEBHOOK -verification: -    unverified_after: 3  # Days after which non-Developers receive the @Unverified role -    kicked_after: 30  # Days after which non-Developers get kicked from the guild -    reminder_frequency: 28  # Hours between @Unverified pings -    bot_message_delete_delay: 10  # Seconds before deleting bots response in #verification - -    # Number in range [0, 1] determining the percentage of unverified users that are safe -    # to be kicked from the guild in one batch, any larger amount will require staff confirmation, -    # set this to 0 to require explicit approval for batches of any size -    kick_confirmation_threshold: 0.01  # 1% - -  voice_gate:      minimum_days_member: 3  # How many days the user must have been a member for      minimum_messages: 50  # How many messages a user must have to be eligible for voice diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py index 45e7b5b51..85d6a1173 100644 --- a/tests/bot/exts/utils/test_jams.py +++ b/tests/bot/exts/utils/test_jams.py @@ -118,11 +118,9 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):              self.assertTrue(overwrites[member].read_messages)              self.assertTrue(overwrites[member].connect) -        # Everyone and verified role overwrite +        # Everyone role overwrite          self.assertFalse(overwrites[self.guild.default_role].read_messages)          self.assertFalse(overwrites[self.guild.default_role].connect) -        self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].read_messages) -        self.assertFalse(overwrites[self.guild.get_role(Roles.verified)].connect)      async def test_team_channels_creation(self):          """Should create new voice and text channel for team.""" | 
