aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ks129 <[email protected]>2020-12-17 16:33:56 +0200
committerGravatar GitHub <[email protected]>2020-12-17 16:33:56 +0200
commit0f294d9d3d4dca937ff7d852f9f7b47143f998f2 (patch)
tree9fae71d2e5e1def64dea6646cf35b89752b58549
parentMerge branch 'master' into bug-fixes (diff)
parentUpdate verification.py (diff)
Merge branch 'master' into bug-fixes
-rw-r--r--.github/CODEOWNERS15
-rw-r--r--.github/review-policy.yml3
-rw-r--r--.github/workflows/lint-test.yml22
-rw-r--r--.github/workflows/status_embed.yaml78
-rw-r--r--bot/bot.py29
-rw-r--r--bot/exts/help_channels/_cog.py44
-rw-r--r--bot/exts/info/information.py49
-rw-r--r--bot/exts/moderation/infraction/management.py2
-rw-r--r--bot/exts/moderation/infraction/superstarify.py41
-rw-r--r--bot/exts/moderation/verification.py22
-rw-r--r--bot/exts/utils/utils.py18
-rw-r--r--bot/resources/elements.json1
-rw-r--r--bot/resources/tags/codeblock.md4
-rw-r--r--tests/bot/exts/info/test_information.py1
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