Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9bc3e5b
Added method definition and needed imports.
MrHemlock Oct 30, 2020
4eccdef
Added RedisCache and event
MrHemlock Nov 2, 2020
2a31370
Added ping message, message id storage and message deletion
MrHemlock Nov 2, 2020
a6a2ba6
Bug fixes, including improper cache calls, typos and more
MrHemlock Nov 3, 2020
bf7916a
- Added ping deletion time to config file.
MrHemlock Nov 5, 2020
cbec20b
Merge branch 'master' into Hemlock/voice-gate-ping
MrHemlock Nov 5, 2020
30d3874
Corrected linting errors.
MrHemlock Nov 5, 2020
33d9927
Merge remote-tracking branch 'origin/Hemlock/voice-gate-ping' into He…
MrHemlock Nov 5, 2020
ec019d5
Requested fixes - Various restructures of code.
MrHemlock Nov 6, 2020
18fed79
Removed extra else's and added constant
MrHemlock Nov 6, 2020
8cc2622
Added dummy parameter, changed message delete logic
MrHemlock Nov 8, 2020
ec8018c
Voice Gate: one-line func signature
Nov 8, 2020
59b34c7
Voice Gate: fix cache membership check
Nov 8, 2020
d6820a2
Voice Gate: refer to config rather than hard-coded duration
Nov 8, 2020
dbff099
Voice Gate: correct HTTP delete method usage
Nov 8, 2020
92132af
Voice Gate: correct after-delay message delete methodology
Nov 8, 2020
b4220a3
Voice Gate: define atomic `_delete_ping` function
Nov 10, 2020
4b60c21
Voice Gate: ensure atomicity when notifying users
Nov 10, 2020
b32174b
Voice Gate: explain the purpose of `NO_MSG`
Nov 10, 2020
13cb238
Merge CI changes from 'master' branch
Nov 10, 2020
8bfd330
Merge CI dep cache bump from 'master' branch
Nov 11, 2020
8c8b65c
Config: ensure 2 blank lines between classes
Nov 11, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ class CleanMessages(metaclass=YAMLGetter):

message_limit: int


class Stats(metaclass=YAMLGetter):
section = "bot"
subsection = "stats"
Expand Down Expand Up @@ -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):
Expand Down
100 changes: 97 additions & 3 deletions bot/exts/moderation/voice_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"""
)
Expand All @@ -28,18 +35,77 @@
"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
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)
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions config-default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down