diff --git a/.gitignore b/.gitignore index 65a9af4319..630f838700 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 1ddb1748eb..0250d0e319 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -352,11 +352,10 @@ class _PythonNews(EnvConfig, env_prefix="python_news_"): 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 4e03340785..4244cce037 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 @@ 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 @@ async def voice_verify(self, ctx: Context, *_) -> None: } 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."""