aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Kieran Siek <[email protected]>2020-09-19 14:11:43 +0800
committerGravatar GitHub <[email protected]>2020-09-19 14:11:43 +0800
commitae1363769455ad93e93f0d1ba3efcc39751ee9fa (patch)
tree9d9e6ccbad74a6d6379495f72d27d94c46f26d5a
parentAdd everyone_ping filter. (diff)
parentMerge pull request #1158 from python-discord/config-update (diff)
Merge branch 'master' into bug/1142/fix-everyone-ping
-rw-r--r--bot/cogs/eval.py25
-rw-r--r--bot/cogs/help_channels.py4
-rw-r--r--bot/cogs/snekbox.py13
-rw-r--r--bot/cogs/verification.py596
-rw-r--r--bot/constants.py12
-rw-r--r--bot/rules/burst_shared.py11
-rw-r--r--bot/utils/__init__.py19
-rw-r--r--bot/utils/helpers.py23
-rw-r--r--bot/utils/services.py54
-rw-r--r--config-default.yml25
-rw-r--r--tests/bot/cogs/test_snekbox.py40
-rw-r--r--tests/bot/utils/test_services.py74
12 files changed, 808 insertions, 88 deletions
diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py
index eb8bfb1cf..23e5998d8 100644
--- a/bot/cogs/eval.py
+++ b/bot/cogs/eval.py
@@ -15,6 +15,7 @@ from bot.bot import Bot
from bot.constants import Roles
from bot.decorators import with_role
from bot.interpreter import Interpreter
+from bot.utils import find_nth_occurrence, send_to_paste_service
log = logging.getLogger(__name__)
@@ -171,6 +172,30 @@ async def func(): # (None,) -> Any
res = traceback.format_exc()
out, embed = self._format(code, res)
+ out = out.rstrip("\n") # Strip empty lines from output
+
+ # Truncate output to max 15 lines or 1500 characters
+ newline_truncate_index = find_nth_occurrence(out, "\n", 15)
+
+ if newline_truncate_index is None or newline_truncate_index > 1500:
+ truncate_index = 1500
+ else:
+ truncate_index = newline_truncate_index
+
+ if len(out) > truncate_index:
+ paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py")
+ if paste_link is not None:
+ paste_text = f"full contents at {paste_link}"
+ else:
+ paste_text = "failed to upload contents to paste service."
+
+ await ctx.send(
+ f"```py\n{out[:truncate_index]}\n```"
+ f"... response truncated; {paste_text}",
+ embed=embed
+ )
+ return
+
await ctx.send(f"```py\n{out}```", embed=embed)
@group(name='internal', aliases=('int',))
diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py
index 541c6f336..0f9cac89e 100644
--- a/bot/cogs/help_channels.py
+++ b/bot/cogs/help_channels.py
@@ -36,7 +36,7 @@ the **Help: Dormant** category.
Try to write the best question you can by providing a detailed description and telling us what \
you've tried already. For more information on asking a good question, \
-check out our guide on [**asking good questions**]({ASKING_GUIDE_URL}).
+check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.
"""
DORMANT_MSG = f"""
@@ -47,7 +47,7 @@ channel until it becomes available again.
If your question wasn't answered yet, you can claim a new help channel from the \
**Help: Available** category by simply asking your question again. Consider rephrasing the \
question to maximize your chance of getting a good answer. If you're not sure how, have a look \
-through our guide for [**asking a good question**]({ASKING_GUIDE_URL}).
+through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.
"""
CoroutineFunc = t.Callable[..., t.Coroutine]
diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py
index 63e6d7f31..03bf454ac 100644
--- a/bot/cogs/snekbox.py
+++ b/bot/cogs/snekbox.py
@@ -14,6 +14,7 @@ from discord.ext.commands import Cog, Context, command, guild_only
from bot.bot import Bot
from bot.constants import Categories, Channels, Roles, URLs
from bot.decorators import in_whitelist
+from bot.utils import send_to_paste_service
from bot.utils.messages import wait_for_deletion
log = logging.getLogger(__name__)
@@ -71,17 +72,7 @@ class Snekbox(Cog):
if len(output) > MAX_PASTE_LEN:
log.info("Full output is too long to upload")
return "too long to upload"
-
- url = URLs.paste_service.format(key="documents")
- try:
- async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp:
- data = await resp.json()
-
- if "key" in data:
- return URLs.paste_service.format(key=data["key"])
- except Exception:
- # 400 (Bad Request) means there are too many characters
- log.exception("Failed to upload full output to paste service!")
+ return await send_to_paste_service(self.bot.http_session, output, extension="txt")
@staticmethod
def prepare_input(code: str) -> str:
diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py
index ae156cf70..9ae92a228 100644
--- a/bot/cogs/verification.py
+++ b/bot/cogs/verification.py
@@ -1,19 +1,36 @@
+import asyncio
import logging
+import typing as t
from contextlib import suppress
+from datetime import datetime, timedelta
-from discord import Colour, Forbidden, Message, NotFound, Object
-from discord.ext.commands import Cog, Context, command
+import discord
+from discord.ext import tasks
+from discord.ext.commands import Cog, Context, command, group
+from discord.utils import snowflake_time
from bot import constants
from bot.bot import Bot
from bot.cogs.moderation import ModLog
-from bot.decorators import in_whitelist, without_role
+from bot.decorators import in_whitelist, with_role, without_role
from bot.utils.checks import InWhitelistCheckFailure, without_role_check
+from bot.utils.redis_cache import RedisCache
log = logging.getLogger(__name__)
-WELCOME_MESSAGE = f"""
-Hello! Welcome to the server, and thanks for verifying yourself!
+# Sent via DMs once user joins the guild
+ON_JOIN_MESSAGE = f"""
+Hello! Welcome to Python Discord!
+
+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. To do so, \
+please visit <#{constants.Channels.verification}>. Thank you!
+"""
+
+# Sent via DMs once user verifies
+VERIFIED_MESSAGE = f"""
+Thanks for verifying yourself!
For your records, these are the documents you accepted:
@@ -32,29 +49,471 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!
<#{constants.Channels.bot_commands}>.
"""
-BOT_MESSAGE_DELETE_DELAY = 10
+# 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
class Verification(Cog):
- """User verification and role self-management."""
+ """
+ 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.
- def __init__(self, bot: Bot):
+ 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.
+ """
+
+ # 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())
+
+ 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.info(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 _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 member.send(KICKED_MESSAGE)
+ except discord.Forbidden as exc_403:
+ log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}")
+ if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs
+ raise StopExecution(reason=exc_403)
+ await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days")
+
+ 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()
- async def on_message(self, message: Message) -> None:
+ async def on_member_join(self, member: discord.Member) -> None:
+ """Attempt to send initial direct message to each new member."""
+ if member.guild.id != constants.Guild.id:
+ return # Only listen for PyDis events
+
+ log.trace(f"Sending on join message to new member: {member.id}")
+ with suppress(discord.Forbidden):
+ await member.send(ON_JOIN_MESSAGE)
+
+ @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=BOT_MESSAGE_DELETE_DELAY)
+ await message.delete(delay=constants.Verification.bot_message_delete_delay)
return
# if a user mentions a role or guild member
@@ -74,7 +533,7 @@ class Verification(Cog):
# Send pretty mod log embed to mod-alerts
await self.mod_log.send_log_message(
icon_url=constants.Icons.filtering,
- colour=Colour(constants.Colours.soft_red),
+ 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"),
@@ -103,23 +562,117 @@ class Verification(Cog):
)
log.trace(f"Deleting the message posted by {ctx.author}")
- with suppress(NotFound):
+ with suppress(discord.NotFound):
await ctx.message.delete()
+ # endregion
+ # region: task management commands
+
+ @with_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=('verify', 'verified', 'accepted'), hidden=True)
@without_role(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(Object(constants.Roles.verified), reason="Accepted the rules")
+ 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 ctx.author.send(WELCOME_MESSAGE)
- except Forbidden:
+ await ctx.author.send(VERIFIED_MESSAGE)
+ except discord.Forbidden:
log.info(f"Sending welcome message failed for {ctx.author}.")
finally:
log.trace(f"Deleting accept message by {ctx.author}.")
- with suppress(NotFound):
+ with suppress(discord.NotFound):
self.mod_log.ignore(constants.Event.message_delete, ctx.message.id)
await ctx.message.delete()
@@ -139,7 +692,7 @@ class Verification(Cog):
return
log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.")
- await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements")
+ await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements")
log.trace(f"Deleting the message posted by {ctx.author}.")
@@ -163,7 +716,9 @@ class Verification(Cog):
return
log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.")
- await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements")
+ await ctx.author.remove_roles(
+ discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements"
+ )
log.trace(f"Deleting the message posted by {ctx.author}.")
@@ -171,6 +726,9 @@ class Verification(Cog):
f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."
)
+ # endregion
+ # region: miscellaneous
+
# This cannot be static (must have a __func__ attribute).
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Check for & ignore any InWhitelistCheckFailure."""
@@ -185,6 +743,8 @@ class Verification(Cog):
else:
return True
+ # endregion
+
def setup(bot: Bot) -> None:
"""Load the Verification cog."""
diff --git a/bot/constants.py b/bot/constants.py
index 70b36984f..e4e0b1732 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -463,6 +463,7 @@ class Roles(metaclass=YAMLGetter):
partners: int
python_community: int
team_leaders: int
+ unverified: int
verified: int # This is the Developers role on PyDis, here named verified for readability reasons.
@@ -470,6 +471,7 @@ class Guild(metaclass=YAMLGetter):
section = "guild"
id: int
+ invite: str # Discord invite, gets embedded in chat
moderation_channels: List[int]
moderation_roles: List[int]
modlog_blacklist: List[int]
@@ -580,6 +582,16 @@ 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 Event(Enum):
"""
Event names. This does not include every event (for example, raw
diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py
index bbe9271b3..0e66df69c 100644
--- a/bot/rules/burst_shared.py
+++ b/bot/rules/burst_shared.py
@@ -2,11 +2,20 @@ 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."""
+ """
+ Detects repeated messages sent by multiple users.
+
+ This filter never triggers in the verification channel.
+ """
+ if last_message.channel.id == Channels.verification:
+ return
+
total_recent = len(recent_messages)
if total_recent > config['max']:
diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py
index 5a6e1811b..3e93fcb06 100644
--- a/bot/utils/__init__.py
+++ b/bot/utils/__init__.py
@@ -1,18 +1,5 @@
-from abc import ABCMeta
-
-from discord.ext.commands import CogMeta
-
+from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64
from bot.utils.redis_cache import RedisCache
+from bot.utils.services import send_to_paste_service
-__all__ = ['RedisCache', 'CogABCMeta']
-
-
-class CogABCMeta(CogMeta, ABCMeta):
- """Metaclass for ABCs meant to be implemented as Cogs."""
-
- pass
-
-
-def pad_base64(data: str) -> str:
- """Return base64 `data` with padding characters to ensure its length is a multiple of 4."""
- return data + "=" * (-len(data) % 4)
+__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service']
diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py
new file mode 100644
index 000000000..d9b60af07
--- /dev/null
+++ b/bot/utils/helpers.py
@@ -0,0 +1,23 @@
+from abc import ABCMeta
+from typing import Optional
+
+from discord.ext.commands import CogMeta
+
+
+class CogABCMeta(CogMeta, ABCMeta):
+ """Metaclass for ABCs meant to be implemented as Cogs."""
+
+
+def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]:
+ """Return index of `n`th occurrence of `substring` in `string`, or None if not found."""
+ index = 0
+ for _ in range(n):
+ index = string.find(substring, index+1)
+ if index == -1:
+ return None
+ return index
+
+
+def pad_base64(data: str) -> str:
+ """Return base64 `data` with padding characters to ensure its length is a multiple of 4."""
+ return data + "=" * (-len(data) % 4)
diff --git a/bot/utils/services.py b/bot/utils/services.py
new file mode 100644
index 000000000..087b9f969
--- /dev/null
+++ b/bot/utils/services.py
@@ -0,0 +1,54 @@
+import logging
+from typing import Optional
+
+from aiohttp import ClientConnectorError, ClientSession
+
+from bot.constants import URLs
+
+log = logging.getLogger(__name__)
+
+FAILED_REQUEST_ATTEMPTS = 3
+
+
+async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]:
+ """
+ Upload `contents` to the paste service.
+
+ `http_session` should be the current running ClientSession from aiohttp
+ `extension` is added to the output URL
+
+ When an error occurs, `None` is returned, otherwise the generated URL with the suffix.
+ """
+ extension = extension and f".{extension}"
+ log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.")
+ paste_url = URLs.paste_service.format(key="documents")
+ for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1):
+ try:
+ async with http_session.post(paste_url, data=contents) as response:
+ response_json = await response.json()
+ except ClientConnectorError:
+ log.warning(
+ f"Failed to connect to paste service at url {paste_url}, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+ except Exception:
+ log.exception(
+ f"An unexpected error has occurred during handling of the request, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+
+ if "message" in response_json:
+ log.warning(
+ f"Paste service returned error {response_json['message']} with status code {response.status}, "
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
+ continue
+ elif "key" in response_json:
+ log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.")
+ return URLs.paste_service.format(key=response_json['key']) + extension
+ log.warning(
+ f"Got unexpected JSON response from paste service: {response_json}\n"
+ f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})."
+ )
diff --git a/config-default.yml b/config-default.yml
index cf9ce8798..465f61b5f 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -76,9 +76,10 @@ style:
ducky_maul: &DUCKY_MAUL 640137724958867467
ducky_santa: &DUCKY_SANTA 655360331002019870
- upvotes: "<:upvotes:638729835245731840>"
- comments: "<:comments:638729835073765387>"
- user: "<:user:638729835442602003>"
+ # emotes used for #reddit
+ upvotes: "<:reddit_upvotes:755845219890757644>"
+ comments: "<:reddit_comments:755845255001014384>"
+ user: "<:reddit_users:755845303822974997>"
icons:
crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png"
@@ -134,6 +135,7 @@ style:
guild:
id: 267624335836053506
+ invite: "https://discord.gg/python"
categories:
help_available: 691405807388196926
@@ -236,8 +238,8 @@ guild:
partners: 323426753857191936
python_community: &PY_COMMUNITY_ROLE 458226413825294336
- # This is the Developers role on PyDis, here named verified for readability reasons
- verified: 352427296948486144
+ unverified: 739794855945044069
+ verified: 352427296948486144 # @Developers on PyDis
# Staff
admins: &ADMINS_ROLE 267628507062992896
@@ -485,5 +487,18 @@ python_news:
channel: *PYNEWS_CHANNEL
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%
+
+
config:
required_keys: ['bot.token']
diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py
index 343e37db9..f22952931 100644
--- a/tests/bot/cogs/test_snekbox.py
+++ b/tests/bot/cogs/test_snekbox.py
@@ -1,5 +1,4 @@
import asyncio
-import logging
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch
@@ -39,43 +38,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):
result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1))
self.assertEqual(result, "too long to upload")
- async def test_upload_output(self):
+ @patch("bot.cogs.snekbox.send_to_paste_service")
+ async def test_upload_output(self, mock_paste_util):
"""Upload the eval output to the URLs.paste_service.format(key="documents") endpoint."""
- key = "MarkDiamond"
- resp = MagicMock()
- resp.json = AsyncMock(return_value={"key": key})
-
- context_manager = MagicMock()
- context_manager.__aenter__.return_value = resp
- self.bot.http_session.post.return_value = context_manager
-
- self.assertEqual(
- await self.cog.upload_output("My awesome output"),
- constants.URLs.paste_service.format(key=key)
- )
- self.bot.http_session.post.assert_called_with(
- constants.URLs.paste_service.format(key="documents"),
- data="My awesome output",
- raise_for_status=True
+ await self.cog.upload_output("Test output.")
+ mock_paste_util.assert_called_once_with(
+ self.bot.http_session, "Test output.", extension="txt"
)
- async def test_upload_output_gracefully_fallback_if_exception_during_request(self):
- """Output upload gracefully fallback if the upload fail."""
- resp = MagicMock()
- resp.json = AsyncMock(side_effect=Exception)
-
- context_manager = MagicMock()
- context_manager.__aenter__.return_value = resp
- self.bot.http_session.post.return_value = context_manager
-
- log = logging.getLogger("bot.cogs.snekbox")
- with self.assertLogs(logger=log, level='ERROR'):
- await self.cog.upload_output('My awesome output!')
-
- async def test_upload_output_gracefully_fallback_if_no_key_in_response(self):
- """Output upload gracefully fallback if there is no key entry in the response body."""
- self.assertEqual((await self.cog.upload_output('My awesome output!')), None)
-
def test_prepare_input(self):
cases = (
('print("Hello world!")', 'print("Hello world!")', 'non-formatted'),
diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py
new file mode 100644
index 000000000..5e0855704
--- /dev/null
+++ b/tests/bot/utils/test_services.py
@@ -0,0 +1,74 @@
+import logging
+import unittest
+from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+from aiohttp import ClientConnectorError
+
+from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service
+
+
+class PasteTests(unittest.IsolatedAsyncioTestCase):
+ def setUp(self) -> None:
+ self.http_session = MagicMock()
+
+ @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
+ async def test_url_and_sent_contents(self):
+ """Correct url was used and post was called with expected data."""
+ response = MagicMock(
+ json=AsyncMock(return_value={"key": ""})
+ )
+ self.http_session.post().__aenter__.return_value = response
+ self.http_session.post.reset_mock()
+ await send_to_paste_service(self.http_session, "Content")
+ self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content")
+
+ @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}")
+ async def test_paste_returns_correct_url_on_success(self):
+ """Url with specified extension is returned on successful requests."""
+ key = "paste_key"
+ test_cases = (
+ (f"https://paste_service.com/{key}.txt", "txt"),
+ (f"https://paste_service.com/{key}.py", "py"),
+ (f"https://paste_service.com/{key}", ""),
+ )
+ response = MagicMock(
+ json=AsyncMock(return_value={"key": key})
+ )
+ self.http_session.post().__aenter__.return_value = response
+
+ for expected_output, extension in test_cases:
+ with self.subTest(msg=f"Send contents with extension {repr(extension)}"):
+ self.assertEqual(
+ await send_to_paste_service(self.http_session, "", extension=extension),
+ expected_output
+ )
+
+ async def test_request_repeated_on_json_errors(self):
+ """Json with error message and invalid json are handled as errors and requests repeated."""
+ test_cases = ({"message": "error"}, {"unexpected_key": None}, {})
+ self.http_session.post().__aenter__.return_value = response = MagicMock()
+ self.http_session.post.reset_mock()
+
+ for error_json in test_cases:
+ with self.subTest(error_json=error_json):
+ response.json = AsyncMock(return_value=error_json)
+ result = await send_to_paste_service(self.http_session, "")
+ self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.assertIsNone(result)
+
+ self.http_session.post.reset_mock()
+
+ async def test_request_repeated_on_connection_errors(self):
+ """Requests are repeated in the case of connection errors."""
+ self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock()))
+ result = await send_to_paste_service(self.http_session, "")
+ self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.assertIsNone(result)
+
+ async def test_general_error_handled_and_request_repeated(self):
+ """All `Exception`s are handled, logged and request repeated."""
+ self.http_session.post = MagicMock(side_effect=Exception)
+ result = await send_to_paste_service(self.http_session, "")
+ self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS)
+ self.assertLogs("bot.utils", logging.ERROR)
+ self.assertIsNone(result)