aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Daniel Brown <[email protected]>2024-01-03 12:58:18 -0600
committerGravatar GitHub <[email protected]>2024-01-03 12:58:18 -0600
commitda6997aab25b57152eec5d8c706977c472563853 (patch)
tree37787fcbd4c4cd76cb512a05156afb3235a72b17
parentBump ruff from 0.1.9 to 0.1.11 (#2876) (diff)
Voice Verify Button Instead of Voice Verify Command (#2856)
* Added additional ignore to `.gitignore` * Setting structure for view class * Transferring voice_verification functionality to button view * Completed VerifyView and added cog_load * Moved `voice_verify()` functionality to button. Added `prepare_voice_button()` to create the persistent button and message. * Tweaked message deletion times to be more consistent * Voice Gate rework - Removed unused imports caused by changes - Changed voice pings to reflect the use of button instead of command - Changed from deleting pings by message id in favor of using built in `delete_after` arguments on messages - Changed `delete_after` to use values from config - Redis cache no longer tracks message ids along with user id for the purpose of tracking people who have been pinged - * Changes to Structure and Logic - Removed superfluous new line in constants file - Removed ModLog from file as it is no longer needed - New to voice chat users will no longer be sent a DM, and will instead only be sent a ping in the verification channel - Changed class name from `VerifyView` to `VoiceVerificationView` for added clarity - Adjusted voice button role check logic to reduce number of api calls * Corrected logging formatting - Formatting now correctly using %-style instead of f-strings * Swapped channel getting & corrected redundant logic - Swapped get_channel to get_or_fetch_channel to make sure channel is retrieved - Adjusted list comprehension and for loop to be a single for loop * Change ping phrasing for clarity Co-authored-by: wookie184 <[email protected]> --------- Co-authored-by: wookie184 <[email protected]>
-rw-r--r--.gitignore1
-rw-r--r--bot/constants.py3
-rw-r--r--bot/exts/moderation/voice_gate.py288
3 files changed, 117 insertions, 175 deletions
diff --git a/.gitignore b/.gitignore
index 65a9af431..630f83870 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,6 +117,7 @@ log.*
*config.yml
docker-compose.override.yml
metricity-config.toml
+pyrightconfig.json
# xmlrunner unittest XML reports
TEST-**.xml
diff --git a/bot/constants.py b/bot/constants.py
index 1ddb1748e..0250d0e31 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -352,11 +352,10 @@ PythonNews = _PythonNews()
class _VoiceGate(EnvConfig, env_prefix="voice_gate_"):
- bot_message_delete_delay: int = 10
+ delete_after_delay: int = 60
minimum_activity_blocks: int = 3
minimum_days_member: int = 3
minimum_messages: int = 50
- voice_ping_delete_delay: int = 60
VoiceGate = _VoiceGate()
diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py
index 4e0334078..4244cce03 100644
--- a/bot/exts/moderation/voice_gate.py
+++ b/bot/exts/moderation/voice_gate.py
@@ -1,18 +1,15 @@
-import asyncio
-from contextlib import suppress
from datetime import timedelta
import arrow
import discord
from async_rediscache import RedisCache
-from discord import Colour, Member, VoiceState
-from discord.ext.commands import Cog, Context, command
+from discord import Colour, Member, TextChannel, VoiceState
+from discord.ext.commands import Cog, Context, command, has_any_role
from pydis_core.site_api import ResponseCodeError
+from pydis_core.utils.channel import get_or_fetch_channel
from bot.bot import Bot
-from bot.constants import Bot as BotConfig, Channels, MODERATION_ROLES, Roles, VoiceGate as GateConf
-from bot.decorators import has_no_roles, in_whitelist
-from bot.exts.moderation.modlog import ModLog
+from bot.constants import Channels, MODERATION_ROLES, Roles, VoiceGate as GateConf
from bot.log import get_logger
from bot.utils.checks import InWhitelistCheckFailure
@@ -37,137 +34,63 @@ MESSAGE_FIELD_MAP = {
VOICE_PING = (
"Wondering why you can't talk in the voice channels? "
- f"Use the `{BotConfig.prefix}voiceverify` command in here to verify. "
+ "Click the Voice Verify button above to verify. "
"If you don't yet qualify, you'll be told why!"
)
-VOICE_PING_DM = (
- "Wondering why you can't talk in the voice channels? "
- f"Use the `{BotConfig.prefix}voiceverify` command in "
- "{channel_mention} to verify. If you don't yet qualify, you'll be told why!"
-)
-
-class VoiceGate(Cog):
- """Voice channels verification management."""
-
- # RedisCache[discord.User.id | discord.Member.id, 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()
+class VoiceVerificationView(discord.ui.View):
+ """Persistent view to add a Voice Verify button."""
def __init__(self, bot: Bot) -> None:
+ super().__init__(timeout=None)
self.bot = bot
- @property
- 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) -> tuple:
- """
- See if `member` should be sent a voice verification notification, and send it if so.
-
- Returns (False, None) 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 return (True, channel).
- channel is either [discord.TextChannel, discord.DMChannel].
- """
- if await self.redis_cache.contains(member.id):
- log.trace("User already in cache. Ignore.")
- return False, None
-
- 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, None
-
- log.trace("User is unverified. Send ping.")
-
- await self.bot.wait_until_guild_available()
- voice_verification_channel = self.bot.get_channel(Channels.voice_gate)
-
- try:
- message = await member.send(VOICE_PING_DM.format(channel_mention=voice_verification_channel.mention))
- except discord.Forbidden:
- log.trace("DM failed for Voice ping message. Sending in channel.")
- message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}")
-
- await self.redis_cache.set(member.id, message.id)
- return True, message.channel
-
- @command(aliases=("voiceverify", "voice-verify",))
- @has_no_roles(Roles.voice_verified)
- @in_whitelist(channels=(Channels.voice_gate,), redirect=None)
- async def voice_verify(self, ctx: Context, *_) -> None:
- """
- Apply to be able to use voice within the Discord server.
-
- In order to use voice you must meet all three of the following criteria:
- - You must have over a certain number of messages within the Discord server
- - You must have accepted our rules over a certain number of days ago
- - 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
+ @discord.ui.button(label="Voice Verify", style=discord.ButtonStyle.primary, custom_id="voice_verify_button",)
+ async def voice_button(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
+ """A button that checks to see if the user qualifies for voice verification and verifies them if they do."""
+ if interaction.user.get_role(Roles.voice_verified):
+ await interaction.response.send_message((
+ "You have already verified! "
+ "If you have received this message in error, "
+ "please send a message to the ModMail bot."),
+ ephemeral=True,
+ delete_after=GateConf.delete_after_delay,
+ )
+ return
try:
- data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data")
- except ResponseCodeError as e:
- if e.status == 404:
- embed = discord.Embed(
- title="Not found",
- description=(
- "We were unable to find user data for you. "
- "Please try again shortly, "
- "if this problem persists please contact the server staff through Modmail."
- ),
- color=Colour.red()
+ data = await self.bot.api_client.get(
+ f"bot/users/{interaction.user.id}/metricity_data",
+ raise_for_status=True
+ )
+ except ResponseCodeError as err:
+ if err.response.status == 404:
+ await interaction.response.send_message((
+ "We were unable to find user data for you. "
+ "Please try again shortly. "
+ "If this problem persists, please contact the server staff through ModMail."),
+ ephemeral=True,
+ delete_after=GateConf.delete_after_delay,
)
- log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})")
+ log.info("Unable to find Metricity data about %s (%s)", interaction.user, interaction.user.id)
else:
- embed = discord.Embed(
- title="Unexpected response",
- description=(
- "We encountered an error while attempting to find data for your user. "
- "Please try again and let us know if the problem persists."
- ),
- color=Colour.red()
+ await interaction.response.send_message((
+ "We encountered an error while attempting to find data for your user. "
+ "Please try again and let us know if the problem persists."),
+ ephemeral=True,
+ delete_after=GateConf.delete_after_delay,
+ )
+ log.warning(
+ "Got response code %s while trying to get %s Metricity data.",
+ err.status,
+ interaction.user.id
)
- log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.")
- try:
- await ctx.author.send(embed=embed)
- except discord.Forbidden:
- log.info("Could not send user DM. Sending in voice-verify channel and scheduling delete.")
- await ctx.send(embed=embed)
-
return
checks = {
"joined_at": (
- ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member)
+ interaction.user.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member)
),
"total_messages": data["total_messages"] < GateConf.minimum_messages,
"voice_gate_blocked": data["voice_gate_blocked"],
@@ -175,102 +98,121 @@ class VoiceGate(Cog):
}
failed = any(checks.values())
- failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True]
- [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True]
if failed:
+ failed_reasons = []
+ for key, value in checks.items():
+ if value is True:
+ failed_reasons.append(MESSAGE_FIELD_MAP[key])
+ self.bot.stats.incr(f"voice_gate.failed.{key}")
+
embed = discord.Embed(
title="Voice Gate failed",
description=FAILED_MESSAGE.format(reasons="\n".join(f"- You {reason}." for reason in failed_reasons)),
color=Colour.red()
)
- try:
- await ctx.author.send(embed=embed)
- await ctx.send(f"{ctx.author}, please check your DMs.")
- except discord.Forbidden:
- await ctx.channel.send(ctx.author.mention, embed=embed)
+
+ await interaction.response.send_message(
+ embed=embed,
+ ephemeral=True,
+ delete_after=GateConf.delete_after_delay,
+ )
return
embed = discord.Embed(
title="Voice gate passed",
description="You have been granted permission to use voice channels in Python Discord.",
- color=Colour.green()
+ color=Colour.green(),
)
- if ctx.author.voice:
+ # interaction.user.voice will return None if the user is not in a voice channel
+ if interaction.user.voice:
embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions."
- try:
- await ctx.author.send(embed=embed)
- await ctx.send(f"{ctx.author}, please check your DMs.")
- except discord.Forbidden:
- await ctx.channel.send(ctx.author.mention, embed=embed)
+ await interaction.response.send_message(
+ embed=embed,
+ ephemeral=True,
+ delete_after=GateConf.delete_after_delay,
+ )
+ await interaction.user.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed")
+ self.bot.stats.incr("voice_gate.passed")
- # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it.
- await asyncio.sleep(3)
- await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed")
- self.bot.stats.incr("voice_gate.passed")
+class VoiceGate(Cog):
+ """Voice channels verification management."""
- @Cog.listener()
- async def on_message(self, message: discord.Message) -> None:
- """Delete all non-staff messages from voice gate channel that don't invoke voice verify command."""
- # Check is channel voice gate
- if message.channel.id != Channels.voice_gate:
- return
+ # RedisCache[discord.User.id | discord.Member.id, 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 set to 0, as we only need to track which users have connected before
+ redis_cache = RedisCache()
- ctx = await self.bot.get_context(message)
- is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify"
-
- # 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
-
- # Then check is member moderator+, because we don't want to delete their messages.
- if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False:
- log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.")
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ async def cog_load(self) -> None:
+ """Adds verify button to be monitored by the bot."""
+ self.bot.add_view(VoiceVerificationView(self.bot))
+
+ @redis_cache.atomic_transaction
+ async def _ping_newcomer(self, member: discord.Member) -> None:
+ """See if `member` should be sent a voice verification notification, and send it if so."""
+ log.trace("User is not verified. Checking cache.")
+ if await self.redis_cache.contains(member.id):
+ log.trace("User %s already in cache. Ignore.", member.id)
return
- with suppress(discord.NotFound):
- await message.delete()
+ log.trace("User %s is unverified and has not been pinged before. Sending ping.", member.id)
+ await self.bot.wait_until_guild_available()
+ voice_verification_channel = await get_or_fetch_channel(self.bot, Channels.voice_gate)
+
+ await voice_verification_channel.send(
+ f"Hello, {member.mention}! {VOICE_PING}",
+ delete_after=GateConf.delete_after_delay,
+ )
+
+ await self.redis_cache.set(member.id, NO_MSG)
+ log.trace("User %s added to cache to not be pinged again.", member.id)
@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.")
+ log.trace("User %s is a bot. Ignore.", member.id)
+ return
+
+ if member.get_role(Roles.voice_verified):
+ log.trace("User %s already verified. Ignore", member.id)
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.")
+ log.trace("User %s not in a voice channel. Ignore.", member.id)
return
if isinstance(after.channel, discord.StageChannel):
- log.trace("User joined a stage channel. Ignore.")
+ log.trace("User %s joined a stage channel. Ignore.", member.id)
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, message_channel = await self._ping_newcomer(member)
-
- # Schedule the channel ping notification to be deleted after the configured delay, which is
- # again delegated to an atomic helper
- if notification_sent and isinstance(message_channel, discord.TextChannel):
- await asyncio.sleep(GateConf.voice_ping_delete_delay)
- await self._delete_ping(member.id)
+ await self._ping_newcomer(member)
async def cog_command_error(self, ctx: Context, error: Exception) -> None:
"""Check for & ignore any InWhitelistCheckFailure."""
if isinstance(error, InWhitelistCheckFailure):
error.handled = True
+ @command(name="prepare_voice")
+ @has_any_role(*MODERATION_ROLES)
+ async def prepare_voice_button(self, ctx: Context, channel: TextChannel | None, *, text: str) -> None:
+ """Sends a message that includes the Voice Verify button. Should only need to be run once."""
+ if channel is None:
+ await ctx.send(text, view=VoiceVerificationView(self.bot))
+ elif not channel.permissions_for(ctx.author).send_messages:
+ await ctx.send("You don't have permission to send messages to that channel.")
+ else:
+ await channel.send(text, view=VoiceVerificationView(self.bot))
+
async def setup(bot: Bot) -> None:
"""Loads the VoiceGate cog."""