VocalMaisBot/VocalMaisBot.py

238 lines
9.9 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import logging
import sys
import textwrap
from pathlib import Path
from typing import Any, List, Dict, Union
import discord
import discord.utils
from ChannelsConfigFile import ChannelsConfigFile
logger = logging.getLogger("VocalMaisBot")
class VocalMaisBot(discord.Client):
def __init__(self, channels_config: ChannelsConfigFile):
super().__init__(
intents = discord.Intents(voice_states = True, guild_messages = True, guilds = True)
)
self.channels_config = channels_config
self.owner = None # Fetched by on_ready
async def _check_administrator_for_command(self, message: discord.Message) -> bool:
if message.author.guild_permissions.administrator or message.author == self.owner:
return True
else:
await message.channel.send(f":dragon_face: Only an administrator can control me!")
return False
async def sorry_do_not_understand(self, message: discord.Message):
await message.channel.send(f"Sorry I did not understand this message. Try `@{self.user.display_name} help` for help")
async def on_ready(self):
logger.info("Connected and ready!")
logger.debug(f"Logged as {self.user}")
self.owner = (await self.application_info()).owner
oauth_url = discord.utils.oauth_url(
self.user.id,
discord.Permissions(manage_channels = True, read_messages = True, send_messages = True, move_members = True)
)
logger.info(f"You can invite the bot to your server using the following link: {oauth_url}")
async def on_message(self, message: discord.Message):
if self.user not in message.mentions: # We only ever react to messages that mention us
return
if message.author.bot or message.is_system(): # We ignore messages from automated sources
return
contents = message.content.split()
if _check_list_element(contents, 1, "help"):
return await self.print_help(message.channel)
elif _check_list_element(contents, 1, "ping"):
return await message.channel.send(":ping_pong:")
elif _check_list_element(contents, 1, "register"):
return await self.register_channel(message)
elif _check_list_element(contents, 1, "forget"):
return await self.forget_channel(message)
elif _check_list_element(contents, 1, "list"):
return await self.list_watched_channels(message)
elif _check_list_element(contents, 1, "clear"):
return await self.clear_watched_channels(message)
else:
return await self.sorry_do_not_understand(message)
async def print_help(self, channel: discord.TextChannel):
me = self.user.display_name
message = f"""
**·** `@{me} register channel_id` : make me watch the voice channel with id `channel_id`
**·** `@{me} forget channel_id` : make me stop watching the voice channel with id `channel_id`
**·** `@{me} list` : list watched channels
**·** `@{me} clear` : stop watching all channels
**·** `@{me} help` : print this help
**·** `@{me} ping` : pong
"""
await channel.send(embed = discord.Embed(title = "Available commands", description = textwrap.dedent(message)))
async def register_channel(self, message: discord.Message):
channel_id = await self._get_channel_id_for_command(message)
if channel_id is None:
return
if not await self._check_administrator_for_command(message):
return
# Retrieving the channel
channel = self.get_channel(channel_id)
if channel is None or channel.guild != message.guild:
return await message.channel.send(f"I could not find a channel with id {channel_id} :cry:")
# Adding the channel to the list of watched channels (if needed)
has_been_added = self.channels_config.add_channel_to_watched(message.guild, channel)
if has_been_added:
await message.channel.send(f":white_check_mark: I am now watching {channel.name}")
else:
await message.channel.send(":thumbsup: I was already watching this channel")
async def forget_channel(self, message: discord.Message):
channel_id = await self._get_channel_id_for_command(message)
if channel_id is None:
return
if not await self._check_administrator_for_command(message):
return
channel = self.get_channel(channel_id)
if channel is None: # The channel does not exist (maybe it has been deleted)
channel = discord.Object(channel_id)
channel_name = str(channel_id)
else:
channel_name = f"{channel.name} ({channel_id})"
has_been_removed = self.channels_config.remove_channel_from_watched(message.guild, channel)
if has_been_removed:
await message.channel.send(f":white_check_mark: I am no longer watching {channel_name}")
else:
await message.channel.send(f":thumbsup: I already was not watching {channel_name}")
async def _get_channel_id_for_command(self, message: discord.Message) -> Union[int, None]:
"""
Get the channel id from a command message in the form "@Bot command channel_id", or answer with an error message and return None if not possible.
"""
message_elements = message.content.split()
if len(message_elements) < 3:
await self.sorry_do_not_understand(message)
return None
channel_id = message_elements[2]
try:
return int(channel_id)
except ValueError:
await message.channel.send(f":x: {channel_id} can not be converted to number")
return None
async def list_watched_channels(self, message: discord.Message):
watched_channels_ids = self.channels_config.get_watched_channels_ids(message.guild)
if len(watched_channels_ids) > 0:
answer_lines = []
for channel_id in watched_channels_ids:
channel = self.get_channel(channel_id)
if channel is not None: # Does the channel still exist?
answer_lines.append(f":eyes: {channel.name} ({channel_id})")
else:
answer_lines.append(f":ninja: <deleted channel> ({channel_id})")
await message.channel.send(embed = discord.Embed(title = "I am watching the following channels", description = "\n".join(answer_lines)))
else:
await message.channel.send(f":see_no_evil: I am not watching any voice channel! Do not hesitate to add some by running `@{self.user.display_name} register channel_id`")
async def clear_watched_channels(self, message: discord.Message):
if not await self._check_administrator_for_command(message):
return
self.channels_config.clear_watched_channels(message.guild)
await message.channel.send(":white_check_mark: I am no longer watching any channel")
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
if before.channel is not None: # They left a channel
await self.handle_user_left_voice(before.channel)
if after.channel is not None: # They joined a channel. Note that there is an if and not an elif, because they could leave a channel to join another
await self.handle_user_connected_to_voice(member, after.channel)
async def handle_user_connected_to_voice(self, user: discord.Member, channel: discord.VoiceChannel):
if user.bot:
return # We ignore bots connecting to channels, we do not want them asking us to create voice channels
if not self.channels_config.is_watched(channel.guild, channel):
return # It's not one of our special watched join-to-create channels
# The user is not a bot and connected to one of our special watched channels
# We create a channel for them and move them into it
category = channel.category
user_name = user.display_name
user_channel = await category.create_voice_channel(f"{user_name}'s channel", overwrites = {user: discord.PermissionOverwrite(manage_channels = True)})
await user.move_to(user_channel)
logger.info(f"Created channel {user_channel.name}({user_channel.id}) for {user.display_name}({user.id})")
# Saving the channel in the configuration
self.channels_config.add_channel_to_created(user_channel.guild, user_channel)
async def handle_user_left_voice(self, channel: discord.VoiceChannel):
# We do not verify if the user is a bot, because a bot could be the last one to leave a channel (e.g. music bot)
if not self.channels_config.is_created(channel.guild, channel):
return # It is not a channel that we manage, nothing to do
logger.debug(f"User left temporary channel {channel.name} ({channel.id})")
# It is a channel that we manage
if len(channel.members) > 0:
return # There are still people inside it, nothing to do
try:
await channel.delete()
except discord.NotFound:
pass # The channel already does not exist, that's not a problem, that's what we want
logger.info(f"Deleted channel {channel.name}({channel.id}) because it was empty")
# updating the configuration
self.channels_config.remove_channel_from_created(channel.guild, channel)
def _check_list_element(l: List, index: int, expected_value: Any) -> bool:
try:
return l[index] == expected_value
except IndexError:
return False
def _check_dict_element(d: Dict, key: Any, expected_value: Any) -> bool:
try:
return d[key] == expected_value
except KeyError:
return False
if __name__ == '__main__':
argparser = argparse.ArgumentParser(description = "Discord bot to automatically create temporary voice channels for users when they connect to a special channel", formatter_class = argparse.ArgumentDefaultsHelpFormatter)
argparser.add_argument("-t", "--token-file", default = ".token", help = "File where the discord bot token is stored")
argparser.add_argument("-w", "--watched-channels-file", default = "channels.json", help = "Used to store the list of channels that the bot watches")
argparser.add_argument("-v", "--verbose", action = "store_true", help = "Print debug messages")
ARGS = argparser.parse_args()
logging.basicConfig(
level = logging.DEBUG if ARGS.verbose else logging.INFO,
format = "%(asctime)s %(name)s [%(levelname)s] %(message)s'"
)
logger.info(f"Using discord.py version {discord.__version__}")
token_file_path = Path(ARGS.token_file).absolute()
if not token_file_path.is_file():
logger.error(f"Token file {token_file_path} does not exist")
sys.exit(1)
with token_file_path.open("r") as token_file:
token = token_file.readline().strip()
channels_config_file = ChannelsConfigFile(ARGS.watched_channels_file)
bot = VocalMaisBot(channels_config_file)
bot.run(token)