aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Daniel Brown <[email protected]>2020-11-12 12:40:43 -0600
committerGravatar GitHub <[email protected]>2020-11-12 12:40:43 -0600
commit25d55097d2872bd654687553e1aedb09828e2c3b (patch)
tree5f9094c4d7f60af79d67d21be73345e9a04bbbb3
parentCI: invalidate dependency cache (diff)
parentConfig: ensure 2 blank lines between classes (diff)
Merge pull request #1272 from python-discord/Hemlock/voice-gate-ping
Voice Gate: notify new unverified users in the verification channel
-rw-r--r--bot/constants.py2
-rw-r--r--bot/exts/moderation/voice_gate.py100
-rw-r--r--config-default.yml1
3 files changed, 100 insertions, 3 deletions
diff --git a/bot/constants.py b/bot/constants.py
index 4d41f4eb2..731f06fed 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -361,6 +361,7 @@ class CleanMessages(metaclass=YAMLGetter):
message_limit: int
+
class Stats(metaclass=YAMLGetter):
section = "bot"
subsection = "stats"
@@ -601,6 +602,7 @@ class VoiceGate(metaclass=YAMLGetter):
minimum_messages: int
bot_message_delete_delay: int
minimum_activity_blocks: int
+ voice_ping_delete_delay: int
class Event(Enum):
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 93d96693c..4d48d2c1b 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -4,8 +4,9 @@ from contextlib import suppress
from datetime import datetime, timedelta
import discord
+from async_rediscache import RedisCache
from dateutil import parser
-from discord import Colour
+from discord import Colour, Member, VoiceState
from discord.ext.commands import Cog, Context, command
from bot.api import ResponseCodeError
@@ -17,6 +18,12 @@ from bot.utils.checks import InWhitelistCheckFailure
log = logging.getLogger(__name__)
+# Flag written to the cog's RedisCache as a value when the Member's (key) notification
+# was already removed ~ this signals both that no further notifications should be sent,
+# and that the notification does not need to be removed. The implementation relies on
+# this being falsey!
+NO_MSG = 0
+
FAILED_MESSAGE = (
"""You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}"""
)
@@ -28,11 +35,22 @@ MESSAGE_FIELD_MAP = {
"activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks",
}
+VOICE_PING = (
+ "Wondering why you can't talk in the voice channels? "
+ "Use the `!voiceverify` command in here to verify. "
+ "If you don't yet qualify, you'll be told why!"
+)
+
class VoiceGate(Cog):
"""Voice channels verification management."""
- def __init__(self, bot: Bot):
+ # RedisCache[t.Union[discord.User.id, discord.Member.id], t.Union[discord.Message.id, int]]
+ # The cache's keys are the IDs of members who are verified or have joined a voice channel
+ # The cache's values are either the message ID of the ping message or 0 (NO_MSG) if no message is present
+ redis_cache = RedisCache()
+
+ def __init__(self, bot: Bot) -> None:
self.bot = bot
@property
@@ -40,6 +58,54 @@ class VoiceGate(Cog):
"""Get the currently loaded ModLog cog instance."""
return self.bot.get_cog("ModLog")
+ @redis_cache.atomic_transaction # Fully process each call until starting the next
+ async def _delete_ping(self, member_id: int) -> None:
+ """
+ If `redis_cache` holds a message ID for `member_id`, delete the message.
+
+ If the message was deleted, the value under the `member_id` key is then set to `NO_MSG`.
+ When `member_id` is not in the cache, or has a value of `NO_MSG` already, this function
+ does nothing.
+ """
+ if message_id := await self.redis_cache.get(member_id):
+ log.trace(f"Removing voice gate reminder message for user: {member_id}")
+ with suppress(discord.NotFound):
+ await self.bot.http.delete_message(Channels.voice_gate, message_id)
+ await self.redis_cache.set(member_id, NO_MSG)
+ else:
+ log.trace(f"Voice gate reminder message for user {member_id} was already removed")
+
+ @redis_cache.atomic_transaction
+ async def _ping_newcomer(self, member: discord.Member) -> bool:
+ """
+ See if `member` should be sent a voice verification notification, and send it if so.
+
+ Returns False if the notification was not sent. This happens when:
+ * The `member` has already received the notification
+ * The `member` is already voice-verified
+
+ Otherwise, the notification message ID is stored in `redis_cache` and True is returned.
+ """
+ if await self.redis_cache.contains(member.id):
+ log.trace("User already in cache. Ignore.")
+ return False
+
+ log.trace("User not in cache and is in a voice channel.")
+ verified = any(Roles.voice_verified == role.id for role in member.roles)
+ if verified:
+ log.trace("User is verified, add to the cache and ignore.")
+ await self.redis_cache.set(member.id, NO_MSG)
+ return False
+
+ log.trace("User is unverified. Send ping.")
+ await self.bot.wait_until_guild_available()
+ voice_verification_channel = self.bot.get_channel(Channels.voice_gate)
+
+ message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
+ await self.redis_cache.set(member.id, message.id)
+
+ return True
+
@command(aliases=('voiceverify',))
@has_no_roles(Roles.voice_verified)
@in_whitelist(channels=(Channels.voice_gate,), redirect=None)
@@ -53,6 +119,8 @@ class VoiceGate(Cog):
- You must not be actively banned from using our voice channels
- You must have been active for over a certain number of 10-minute blocks
"""
+ await self._delete_ping(ctx.author.id) # If user has received a ping in voice_verification, delete the message
+
try:
data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data")
except ResponseCodeError as e:
@@ -142,8 +210,12 @@ class VoiceGate(Cog):
ctx = await self.bot.get_context(message)
is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify"
- # When it's bot sent message, delete it after some time
+ # When it's a bot sent message, delete it after some time
if message.author.bot:
+ # Comparing the message with the voice ping constant
+ if message.content.endswith(VOICE_PING):
+ log.trace("Message is the voice verification ping. Ignore.")
+ return
with suppress(discord.NotFound):
await message.delete(delay=GateConf.bot_message_delete_delay)
return
@@ -160,6 +232,28 @@ class VoiceGate(Cog):
with suppress(discord.NotFound):
await message.delete()
+ @Cog.listener()
+ async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState) -> None:
+ """Pings a user if they've never joined the voice chat before and aren't voice verified."""
+ if member.bot:
+ log.trace("User is a bot. Ignore.")
+ return
+
+ # member.voice will return None if the user is not in a voice channel
+ if member.voice is None:
+ log.trace("User not in a voice channel. Ignore.")
+ return
+
+ # To avoid race conditions, checking if the user should receive a notification
+ # and sending it if appropriate is delegated to an atomic helper
+ notification_sent = await self._ping_newcomer(member)
+
+ # Schedule the notification to be deleted after the configured delay, which is
+ # again delegated to an atomic helper
+ if notification_sent:
+ await asyncio.sleep(GateConf.voice_ping_delete_delay)
+ await self._delete_ping(member.id)
+
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Check for & ignore any InWhitelistCheckFailure."""
if isinstance(error, InWhitelistCheckFailure):
diff --git a/config-default.yml b/config-default.yml
index 2afdcd594..8912841ff 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -522,6 +522,7 @@ voice_gate:
minimum_messages: 50 # How many messages a user must have to be eligible for voice
bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate
minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active
+ voice_ping_delete_delay: 60 # Seconds before deleting the bot's ping to user in Voice Gate
config: