Repository: python-discord/bot

Issue:
Ping New Voice Chat Users in #voice-verification
Currently as we have the voice-gate features, people are deeply confused as to why they're muted and can't talk in the chat.  I propose we fix that by tracking if a user has joined voice-chat before, and if they haven't, ping them in `#voice-verification`.

The ping message will go away after one of three criteria have been met:
- If 5 minutes have passed then the message will automatically delete
- If the user attempts to verify (successfully or otherwise)
- ~~The user clicks a provided trash can reaction to remove it immediately~~

~~An additional issue and PR would be required in the site repo in order to add another column in order to track if they have visited the voice chat before or not.~~

We'll track if a user has joined the voice channel before through ReDis.  This keeps it all in one place.  We'll likely also need a role check on bot boot to make sure it's up to date and doesn't ping people who have the voice verified role already.

Hopefully this would better inform users about what's going on when they try to talk.


Discussion:
After talking with lemon, the implementation will be using ReDis instead, and will not adding the trash icon.  I'll strike out the things I'm changing in the original post.

Resolving patch:
diff --git a/bot/constants.py b/bot/constants.py
--- 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
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -4,8 +4,9 @@
 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 @@
 
 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 @@
     "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 @@ def mod_log(self) -> ModLog:
         """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 @@ async def voice_verify(self, ctx: Context, *_) -> None:
         - 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 @@ async def on_message(self, message: discord.Message) -> None:
         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 @@ async def on_message(self, message: discord.Message) -> None:
         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):


Test patch: