#!/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 import utils 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 utils.check_list_element_no_bounds(contents, 1, "help"): return await self.print_help(message.channel) elif utils.check_list_element_no_bounds(contents, 1, "ping"): return await message.channel.send(":ping_pong:") elif utils.check_list_element_no_bounds(contents, 1, "register"): return await self.register_channel(message) elif utils.check_list_element_no_bounds(contents, 1, "forget"): return await self.forget_channel(message) elif utils.check_list_element_no_bounds(contents, 1, "list"): return await self.list_watched_channels(message) elif utils.check_list_element_no_bounds(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: ({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) 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)