diff options
| author | 2020-12-17 16:33:56 +0200 | |
|---|---|---|
| committer | 2020-12-17 16:33:56 +0200 | |
| commit | 0f294d9d3d4dca937ff7d852f9f7b47143f998f2 (patch) | |
| tree | 9fae71d2e5e1def64dea6646cf35b89752b58549 | |
| parent | Merge branch 'master' into bug-fixes (diff) | |
| parent | Update verification.py (diff) | |
Merge branch 'master' into bug-fixes
| -rw-r--r-- | .github/CODEOWNERS | 15 | ||||
| -rw-r--r-- | .github/review-policy.yml | 3 | ||||
| -rw-r--r-- | .github/workflows/lint-test.yml | 22 | ||||
| -rw-r--r-- | .github/workflows/status_embed.yaml | 78 | ||||
| -rw-r--r-- | bot/bot.py | 29 | ||||
| -rw-r--r-- | bot/exts/help_channels/_cog.py | 44 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 49 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/management.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/superstarify.py | 41 | ||||
| -rw-r--r-- | bot/exts/moderation/verification.py | 22 | ||||
| -rw-r--r-- | bot/exts/utils/utils.py | 18 | ||||
| -rw-r--r-- | bot/resources/elements.json | 1 | ||||
| -rw-r--r-- | bot/resources/tags/codeblock.md | 4 | ||||
| -rw-r--r-- | tests/bot/exts/info/test_information.py | 1 | 
14 files changed, 274 insertions, 55 deletions
| diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 642676078..ad813d893 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,3 @@ -# Request Dennis for any PR -* @Den4200 -  # Extensions  **/bot/exts/backend/sync/**             @MarkKoz  **/bot/exts/filters/*token_remover.py   @MarkKoz @@ -9,9 +6,11 @@ bot/exts/info/codeblock/**              @MarkKoz  bot/exts/utils/extensions.py            @MarkKoz  bot/exts/utils/snekbox.py               @MarkKoz @Akarys42  bot/exts/help_channels/**               @MarkKoz @Akarys42 -bot/exts/moderation/**                  @Akarys42 @mbaruh -bot/exts/info/**                        @Akarys42 @mbaruh +bot/exts/moderation/**                  @Akarys42 @mbaruh @Den4200 @ks129 +bot/exts/info/**                        @Akarys42 @mbaruh @Den4200  bot/exts/filters/**                     @mbaruh +bot/exts/fun/**                         @ks129 +bot/exts/utils/**                       @ks129  # Utils  bot/utils/extensions.py                 @MarkKoz @@ -26,9 +25,9 @@ tests/bot/exts/test_cogs.py             @MarkKoz  tests/**                                @Akarys42  # CI & Docker -.github/workflows/**                    @MarkKoz @Akarys42 @SebastiaanZ -Dockerfile                              @MarkKoz @Akarys42 -docker-compose.yml                      @MarkKoz @Akarys42 +.github/workflows/**                    @MarkKoz @Akarys42 @SebastiaanZ @Den4200 +Dockerfile                              @MarkKoz @Akarys42 @Den4200 +docker-compose.yml                      @MarkKoz @Akarys42 @Den4200  # Tools  Pipfile*                                @Akarys42 diff --git a/.github/review-policy.yml b/.github/review-policy.yml new file mode 100644 index 000000000..421b30f8a --- /dev/null +++ b/.github/review-policy.yml @@ -0,0 +1,3 @@ +remote: python-discord/.github +path: review-policies/core-developers.yml +ref: main diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 5444fc3de..6fa8e8333 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -113,3 +113,25 @@ jobs:          env:              GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}          run: coveralls + +      # Prepare the Pull Request Payload artifact. If this fails, we +      # we fail silently using the `continue-on-error` option. It's +      # nice if this succeeds, but if it fails for any reason, it +      # does not mean that our lint-test checks failed. +      - name: Prepare Pull Request Payload artifact +        id: prepare-artifact +        if: always() && github.event_name == 'pull_request' +        continue-on-error: true +        run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json + +      # This only makes sense if the previous step succeeded. To +      # get the original outcome of the previous step before the +      # `continue-on-error` conclusion is applied, we use the +      # `.outcome` value. This step also fails silently. +      - name: Upload a Build Artifact +        if: always() && steps.prepare-artifact.outcome == 'success' +        continue-on-error: true +        uses: actions/upload-artifact@v2 +        with: +          name: pull-request-payload +          path: pull_request_payload.json diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml new file mode 100644 index 000000000..b6a71b887 --- /dev/null +++ b/.github/workflows/status_embed.yaml @@ -0,0 +1,78 @@ +name: Status Embed + +on: +  workflow_run: +    workflows: +      - Lint & Test +      - Build +      - Deploy +    types: +      - completed + +jobs: +  status_embed: +    # We need to send a status embed whenever the workflow +    # sequence we're running terminates. There are a number +    # of situations in which that happens: +    # +    # 1. We reach the end of the Deploy workflow, without +    #    it being skipped. +    # +    # 2. A `pull_request` triggered a Lint & Test workflow, +    #    as the sequence always terminates with one run. +    # +    # 3. If any workflow ends in failure or was cancelled. +    if: >- +      (github.event.workflow_run.name == 'Deploy' && github.event.workflow_run.conclusion != 'skipped') || +      github.event.workflow_run.event == 'pull_request' || +      github.event.workflow_run.conclusion == 'failure' || +      github.event.workflow_run.conclusion == 'cancelled' +    name:  Send Status Embed to Discord +    runs-on: ubuntu-latest + +    steps: +      # A workflow_run event does not contain all the information +      # we need for a PR embed. That's why we upload an artifact +      # with that information in the Lint workflow. +      - name: Get Pull Request Information +        id: pr_info +        if: github.event.workflow_run.event == 'pull_request' +        run: | +          curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json +          DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') +          [ -z "$DOWNLOAD_URL" ] && exit 1 +          wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2 +          unzip -p pull_request_payload.zip > pull_request_payload.json +          [ -s pull_request_payload.json ] || exit 3 +          echo "::set-output name=pr_author_login::$(jq -r '.user.login // empty' pull_request_payload.json)" +          echo "::set-output name=pr_number::$(jq -r '.number // empty' pull_request_payload.json)" +          echo "::set-output name=pr_title::$(jq -r '.title // empty' pull_request_payload.json)" +          echo "::set-output name=pr_source::$(jq -r '.head.label // empty' pull_request_payload.json)" +        env: +          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +      # Send an informational status embed to Discord instead of the +      # standard embeds that Discord sends. This embed will contain +      # more information and we can fine tune when we actually want +      # to send an embed. +      - name: GitHub Actions Status Embed for Discord +        uses: SebastiaanZ/[email protected] +        with: +          # Our GitHub Actions webhook +          webhook_id: '784184528997842985' +          webhook_token: ${{ secrets.GHA_WEBHOOK_TOKEN }} + +          # Workflow information +          workflow_name: ${{ github.event.workflow_run.name }} +          run_id: ${{ github.event.workflow_run.id }} +          run_number: ${{ github.event.workflow_run.run_number }} +          status: ${{ github.event.workflow_run.conclusion }} +          actor: ${{ github.actor }} +          repository:  ${{ github.repository }} +          ref: ${{ github.ref }} +          sha: ${{ github.event.workflow_run.head_sha }} + +          pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} +          pr_number: ${{ steps.pr_info.outputs.pr_number }} +          pr_title: ${{ steps.pr_info.outputs.pr_title }} +          pr_source: ${{ steps.pr_info.outputs.pr_source }} diff --git a/bot/bot.py b/bot/bot.py index 06b1bd6e0..e00d355b6 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -16,6 +16,7 @@ from bot import api, constants  from bot.async_stats import AsyncStatsClient  log = logging.getLogger('bot') +LOCALHOST = "127.0.0.1"  class Bot(commands.Bot): @@ -37,6 +38,7 @@ class Bot(commands.Bot):          self._connector = None          self._resolver = None +        self._statsd_timerhandle: asyncio.TimerHandle = None          self._guild_available = asyncio.Event()          statsd_url = constants.Stats.statsd_host @@ -45,9 +47,29 @@ class Bot(commands.Bot):              # Since statsd is UDP, there are no errors for sending to a down port.              # For this reason, setting the statsd host to 127.0.0.1 for development              # will effectively disable stats. -            statsd_url = "127.0.0.1" +            statsd_url = LOCALHOST -        self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") +        self.stats = AsyncStatsClient(self.loop, LOCALHOST) +        self._connect_statsd(statsd_url) + +    def _connect_statsd(self, statsd_url: str, retry_after: int = 2, attempt: int = 1) -> None: +        """Callback used to retry a connection to statsd if it should fail.""" +        if attempt >= 8: +            log.error("Reached 8 attempts trying to reconnect AsyncStatsClient. Aborting") +            return + +        try: +            self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") +        except socket.gaierror: +            log.warning(f"Statsd client failed to connect (Attempt(s): {attempt})") +            # Use a fallback strategy for retrying, up to 8 times. +            self._statsd_timerhandle = self.loop.call_later( +                retry_after, +                self._connect_statsd, +                statsd_url, +                retry_after * 2, +                attempt + 1 +            )          # All tasks that need to block closing until finished          self.closing_tasks: List[asyncio.Task] = [] @@ -207,6 +229,9 @@ class Bot(commands.Bot):          if self.redis_session:              await self.redis_session.close() +        if self._statsd_timerhandle: +            self._statsd_timerhandle.cancel() +      def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None:          """Add an item to the bots filter_list_cache."""          type_ = item["type"] diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e22d4663e..983c5d183 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -145,22 +145,17 @@ class HelpChannels(commands.Cog):          Make the current in-use help channel dormant.          Make the channel dormant if the user passes the `dormant_check`, -        delete the message that invoked this, -        and reset the send permissions cooldown for the user who started the session. +        delete the message that invoked this.          """          log.trace("close command invoked; checking if the channel is in-use.") -        if ctx.channel.category == self.in_use_category: -            if await self.dormant_check(ctx): -                await _cooldown.remove_cooldown_role(ctx.author) -                # Ignore missing task when cooldown has passed but the channel still isn't dormant. -                if ctx.author.id in self.scheduler: -                    self.scheduler.cancel(ctx.author.id) - -                await self.move_to_dormant(ctx.channel, "command") -                self.scheduler.cancel(ctx.channel.id) -        else: +        if ctx.channel.category != self.in_use_category:              log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") +            return + +        if await self.dormant_check(ctx): +            await self.move_to_dormant(ctx.channel, "command") +            self.scheduler.cancel(ctx.channel.id)      async def get_available_candidate(self) -> discord.TextChannel:          """ @@ -368,12 +363,13 @@ class HelpChannels(commands.Cog):          """          log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") -        await _caches.claimants.delete(channel.id)          await self.move_to_bottom_position(              channel=channel,              category_id=constants.Categories.help_dormant,          ) +        await self.unclaim_channel(channel) +          self.bot.stats.incr(f"help.dormant_calls.{caller}")          in_use_time = await _channel.get_in_use_time(channel.id) @@ -397,6 +393,28 @@ class HelpChannels(commands.Cog):          self.channel_queue.put_nowait(channel)          self.report_stats() +    async def unclaim_channel(self, channel: discord.TextChannel) -> None: +        """ +        Mark the channel as unclaimed and remove the cooldown role from the claimant if needed. + +        The role is only removed if they have no claimed channels left once the current one is unclaimed. +        This method also handles canceling the automatic removal of the cooldown role. +        """ +        claimant_id = await _caches.claimants.pop(channel.id) + +        # Ignore missing task when cooldown has passed but the channel still isn't dormant. +        if claimant_id in self.scheduler: +            self.scheduler.cancel(claimant_id) + +        claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) +        if claimant is None: +            log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") +            return + +        # Remove the cooldown role if the claimant has no other channels left +        if not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): +            await _cooldown.remove_cooldown_role(claimant) +      async def move_to_in_use(self, channel: discord.TextChannel) -> None:          """Make a channel in-use and schedule it to be made dormant."""          log.info(f"Moving #{channel} ({channel.id}) to the In Use category.") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 5aaf85e5a..187950689 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,11 +6,13 @@ from collections import Counter, defaultdict  from string import Template  from typing import Any, Mapping, Optional, Tuple, Union +from dateutil import parser  from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils  from discord.abc import GuildChannel  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role  from bot import constants +from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.converters import FetchedMember  from bot.decorators import in_whitelist @@ -21,7 +23,6 @@ from bot.utils.time import time_since  log = logging.getLogger(__name__) -  STATUS_EMOTES = {      Status.offline: constants.Emojis.status_offline,      Status.dnd: constants.Emojis.status_dnd, @@ -224,13 +225,16 @@ class Information(Cog):              if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):                  badges.append(emoji) +        verified_at, activity = await self.user_verification_and_messages(user) +          if on_server:              joined = time_since(user.joined_at, max_units=3)              roles = ", ".join(role.mention for role in user.roles[1:]) -            membership = textwrap.dedent(f""" -                             Joined: {joined} -                             Roles: {roles or None} -                         """).strip() +            membership = {"Joined": joined, "Verified": verified_at or "False", "Roles": roles or None} +            if not is_mod_channel(ctx.channel): +                membership.pop("Verified") + +            membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()]))          else:              roles = None              membership = "The user is not a member of the server" @@ -252,6 +256,8 @@ class Information(Cog):          # Show more verbose output in moderation channels for infractions and nominations          if is_mod_channel(ctx.channel): +            fields.append(activity) +              fields.append(await self.expanded_user_infraction_counts(user))              fields.append(await self.user_nomination_counts(user))          else: @@ -354,6 +360,39 @@ class Information(Cog):          return "Nominations", "\n".join(output) +    async def user_verification_and_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: +        """ +        Gets the time of verification and amount of messages for `member`. + +        Fetches information from the metricity database that's hosted by the site. +        If the database returns a code besides a 404, then many parts of the bot are broken including this one. +        """ +        activity_output = [] +        verified_at = False + +        try: +            user_activity = await self.bot.api_client.get(f"bot/users/{user.id}/metricity_data") +        except ResponseCodeError as e: +            if e.status == 404: +                activity_output = "No activity" + +        else: +            try: +                if (verified_at := user_activity["verified_at"]) is not None: +                    verified_at = time_since(parser.isoparse(verified_at), max_units=3) +            except ValueError: +                log.warning(f"Could not parse ISO string correctly for user {user.id} verification date.") +                verified_at = None + +            activity_output.append(user_activity["total_messages"] or "No messages") +            activity_output.append(user_activity["activity_blocks"] or "No activity") + +            activity_output = "\n".join( +                f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) +            ) + +        return verified_at, ("Activity", activity_output) +      def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:          """Format a mapping to be readable to a human."""          # sorting is technically superfluous but nice if you want to look for a specific field diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index c58410f8c..b3783cd60 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -197,7 +197,7 @@ class ModManagement(commands.Cog):      # endregion      # region: Search infractions -    @infraction_group.group(name="search", invoke_without_command=True) +    @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True)      async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None:          """Searches for infractions in the database."""          if isinstance(query, int): diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 96dfb562f..ffc470c54 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -104,14 +104,14 @@ class Superstarify(InfractionScheduler, Cog):              await self.reapply_infraction(infraction, action) -    @command(name="superstarify", aliases=("force_nick", "star")) +    @command(name="superstarify", aliases=("force_nick", "star", "starify"))      async def superstarify(          self,          ctx: Context,          member: Member,          duration: Expiry,          *, -        reason: str = None, +        reason: str = '',      ) -> None:          """          Temporarily force a random superstar name (like Taylor Swift) to be the user's nickname. @@ -128,16 +128,16 @@ class Superstarify(InfractionScheduler, Cog):          Alternatively, an ISO 8601 timestamp can be provided for the duration. -        An optional reason can be provided. If no reason is given, the original name will be shown -        in a generated reason. +        An optional reason can be provided, which would be added to a message stating their old nickname +        and linking to the nickname policy.          """          if await _utils.get_active_infraction(ctx, member, "superstar"):              return          # Post the infraction to the API          old_nick = member.display_name -        reason = reason or f"old nick: {old_nick}" -        infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) +        infraction_reason = f'Old nickname: {old_nick}. {reason}' +        infraction = await _utils.post_infraction(ctx, member, "superstar", infraction_reason, duration, active=True)          id_ = infraction["id"]          forced_nick = self.get_nick(id_, member.id) @@ -152,37 +152,38 @@ class Superstarify(InfractionScheduler, Cog):          old_nick = escape_markdown(old_nick)          forced_nick = escape_markdown(forced_nick) -        superstar_reason = f"Your nickname didn't comply with our [nickname policy]({NICKNAME_POLICY_URL})."          nickname_info = textwrap.dedent(f"""              Old nickname: `{old_nick}`              New nickname: `{forced_nick}`          """).strip() +        user_message = ( +            f"Your previous nickname, **{old_nick}**, " +            f"was so bad that we have decided to change it. " +            f"Your new nickname will be **{forced_nick}**.\n\n" +            "{reason}" +            f"You will be unable to change your nickname until **{expiry_str}**. " +            "If you're confused by this, please read our " +            f"[official nickname policy]({NICKNAME_POLICY_URL})." +        ).format +          successful = await self.apply_infraction(              ctx, infraction, member, action(), -            user_reason=superstar_reason, +            user_reason=user_message(reason=f'**Additional details:** {reason}\n\n' if reason else ''),              additional_info=nickname_info          ) -        # Send an embed with the infraction information to the invoking context if -        # superstar was successful. +        # Send an embed with to the invoking context if superstar was successful.          if successful:              log.trace(f"Sending superstar #{id_} embed.")              embed = Embed( -                title="Congratulations!", +                title="Superstarified!",                  colour=constants.Colours.soft_orange, -                description=( -                    f"Your previous nickname, **{old_nick}**, " -                    f"was so bad that we have decided to change it. " -                    f"Your new nickname will be **{forced_nick}**.\n\n" -                    f"You will be unable to change your nickname until **{expiry_str}**.\n\n" -                    "If you're confused by this, please read our " -                    f"[official nickname policy]({NICKNAME_POLICY_URL})." -                ) +                description=user_message(reason='')              )              await ctx.send(embed=embed) -    @command(name="unsuperstarify", aliases=("release_nick", "unstar")) +    @command(name="unsuperstarify", aliases=("release_nick", "unstar", "unstarify"))      async def unsuperstarify(self, ctx: Context, member: Member) -> None:          """Remove the superstarify infraction and allow the user to change their nickname."""          await self.pardon_infraction(ctx, "superstar", member) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c599156d0..7aa559617 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -565,11 +565,11 @@ class Verification(Cog):          raw_member = await self.bot.http.get_member(member.guild.id, member.id) -        # If the user has the is_pending flag set, they will be using the alternate +        # If the user has the pending flag set, they will be using the alternate          # gate and will not need a welcome DM with verification instructions.          # We will send them an alternate DM once they verify with the welcome          # video. -        if raw_member.get("is_pending"): +        if raw_member.get("pending"):              await self.member_gating_cache.set(member.id, True)              # TODO: Temporary, remove soon after asking joe. @@ -756,7 +756,7 @@ class Verification(Cog):          log.trace(f"Bumping verification stats in category: {category}")          self.bot.stats.incr(f"verification.{category}") -    @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True) +    @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 @@ -848,6 +848,22 @@ class Verification(Cog):          else:              return True +    @command(name='verify') +    @has_any_role(*constants.MODERATION_ROLES) +    async def apply_developer_role(self, ctx: Context, user: discord.Member) -> None: +        """Command for moderators to apply the Developer role to any user.""" +        log.trace(f'verify command called by {ctx.author} for {user.id}.') +        developer_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.verified) + +        if developer_role in user.roles: +            log.trace(f'{user.id} is already a developer, aborting.') +            await ctx.send(f'{constants.Emojis.cross_mark} {user} is already a developer.') +            return + +        await user.add_roles(developer_role) +        log.trace(f'Developer role successfully applied to {user.id}') +        await ctx.send(f'{constants.Emojis.check_mark} Developer role applied to {user}.') +      # endregion diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 6d8d98695..8e7e6ba36 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -9,13 +9,16 @@ from typing import Dict, Optional, Tuple, Union  from discord import Colour, Embed, utils  from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role +from discord.utils import snowflake_time  from bot.bot import Bot  from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES +from bot.converters import Snowflake  from bot.decorators import in_whitelist  from bot.pagination import LinePaginator  from bot.utils import messages  from bot.utils.cache import AsyncCache +from bot.utils.time import time_since  log = logging.getLogger(__name__) @@ -166,6 +169,21 @@ class Utils(Cog):          embed.description = best_match          await ctx.send(embed=embed) +    @command(aliases=("snf", "snfl", "sf")) +    @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) +    async def snowflake(self, ctx: Context, snowflake: Snowflake) -> None: +        """Get Discord snowflake creation time.""" +        created_at = snowflake_time(snowflake) +        embed = Embed( +            description=f"**Created at {created_at}** ({time_since(created_at, max_units=3)}).", +            colour=Colour.blue() +        ) +        embed.set_author( +            name=f"Snowflake: {snowflake}", +            icon_url="https://github.com/twitter/twemoji/blob/master/assets/72x72/2744.png?raw=true" +        ) +        await ctx.send(embed=embed) +      @command(aliases=("poll",))      @has_any_role(*MODERATION_ROLES)      async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: diff --git a/bot/resources/elements.json b/bot/resources/elements.json index 2dc9b6fd6..a3ac5b99f 100644 --- a/bot/resources/elements.json +++ b/bot/resources/elements.json @@ -32,7 +32,6 @@      "gallium",      "germanium",      "arsenic", -    "selenium",      "bromine",      "krypton",      "rubidium", diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 8d48bdf06..ac64656e5 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -1,7 +1,7 @@  Here's how to format Python code on Discord: -\```py +\`\`\`py  print('Hello world!') -\``` +\`\`\`  **These are backticks, not quotes.** Check [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) out if you can't find the backtick key. diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index daede54c5..254b0a867 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -355,6 +355,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase):          self.assertEqual(              textwrap.dedent(f"""                  Joined: {"1 year ago"} +                Verified: {"False"}                  Roles: &Moderators              """).strip(),              embed.fields[1].value | 
