Skip to content

Commit

Permalink
Voice Verify Button Instead of Voice Verify Command (#2856)
Browse files Browse the repository at this point in the history
* 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 <wookie1840@gmail.com>

---------

Co-authored-by: wookie184 <wookie1840@gmail.com>
  • Loading branch information
MrHemlock and wookie184 authored Jan 3, 2024
1 parent baf0fa1 commit da6997a
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 175 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ log.*
*config.yml
docker-compose.override.yml
metricity-config.toml
pyrightconfig.json

# xmlrunner unittest XML reports
TEST-**.xml
Expand Down
3 changes: 1 addition & 2 deletions bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
288 changes: 115 additions & 173 deletions bot/exts/moderation/voice_gate.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -37,240 +34,185 @@

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"],
"activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks,
}

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."""
Expand Down

0 comments on commit da6997a

Please sign in to comment.