def __init__(self, name: str, config: Config, secret_config: SecretConfig, intents: discord.Intents) -> None: super().__init__(intents=intents) self.repl = None bc.instance_name = self.instance_name = name self.config = config self.secret_config = secret_config self.bot_cache = BotCache(True) self.loop.create_task(self._process_reminders()) self.loop.create_task(VoiceRoutine(self.bot_cache).start()) self.loop.create_task(self._repl_routine()) bc.config = self.config bc.commands = self.config.commands bc.background_loop = self.loop bc.latency = lambda: self.latency bc.change_status = self._change_status bc.change_presence = self.change_presence bc.close = self.close bc.secret_config = self.secret_config bc.info = BotInfo() bc.plugin_manager.register() bc.fetch_channel = self.fetch_channel if not bc.args.fast_start: log.debug("Started Markov model checks...") if bc.markov.check(): log.info("Markov model has passed all checks") else: log.info( "Markov model has not passed checks, but all errors were fixed" )
def stop(self, _, main_bot=True): if not BotCache(main_bot).exists(): return log.error( "Could not stop the bot (cache file does not exist)") bot_cache = BotCache(main_bot).parse() pid = bot_cache["pid"] if pid is None: return log.error( "Could not stop the bot (cache file does not contain pid)") if psutil.pid_exists(pid): if sys.platform == "win32": # Reference to the original solution: # https://stackoverflow.com/a/64357453 import ctypes kernel = ctypes.windll.kernel32 kernel.FreeConsole() kernel.AttachConsole(pid) kernel.SetConsoleCtrlHandler(None, 1) kernel.GenerateConsoleCtrlEvent(0, 0) else: os.kill(pid, signal.SIGINT) while psutil.pid_exists(pid): log.debug("Bot is still running. Please, wait...") time.sleep(0.5) log.info("Bot is stopped!") else: log.error("Could not stop the bot (bot is not running)") BotCache(main_bot).remove()
async def process_subcommands(content, message, user, safe=False): command_indicators = { ')': '(', ']': '[', '`': '`', '}': '{', } while True: updated = False for i in range(len(content)): if content[i] in command_indicators.keys(): for j in range(i - 1, 0, -1): if content[j] == command_indicators[ content[i]] and content[j - 1] == '$': updated = True message.content = content[j + 1:i] command = message.content.split() if not command: return if command[0] not in bc.config.commands.data.keys( ): if command[ 0] in bc.config.commands.aliases.keys( ): command[0] = bc.config.commands.aliases[ command[0]] else: await message.channel.send( f"Unknown command '{command[0]}'") result = "" if command and command[0] in bc.commands.data.keys( ): log.debug( f"Processing subcommand: {command[0]}: {message.content}" ) cmd = bc.commands.data[command[0]] if cmd.can_be_subcommand(): result = await cmd.run(message, command, user, silent=True) if result is None or ( safe and not const. ALNUM_STRING_REGEX.match(content)): result = "" else: await message.channel.send( f"Command '{command[0]}' can not be used as subcommand" ) content = content[:j - 1] + result + content[i + 1:] log.debug2( f"Command (during processing subcommands): {content}" ) break if updated: break if not updated: break return content
def register(self, reload: bool = False) -> None: """Find plugins in plugins directory and register them""" plugin_directory = os.path.join(os.path.dirname(__file__), "plugins") plugin_modules = ['src.plugins.' + os.path.splitext(path)[0] for path in os.listdir(plugin_directory) if os.path.isfile(os.path.join(plugin_directory, path)) and path.endswith(".py")] private_plugin_directory = os.path.join(os.path.dirname(__file__), "plugins", "private") plugin_modules += [Util.path_to_module( f"src.plugins.private.{os.path.relpath(path, private_plugin_directory)}." f"{os.path.splitext(file)[0]}") for path, _, files in os.walk(private_plugin_directory) for file in files if os.path.isfile(os.path.join(private_plugin_directory, path, file)) and file.endswith(".py")] importlib.invalidate_caches() for module in plugin_modules: log.debug2(f"Processing plugins from module: {module}") plugins_file = importlib.import_module(module) if reload: importlib.reload(plugins_file) plugins = [obj[1] for obj in inspect.getmembers(plugins_file, inspect.isclass) if (obj[1].__module__ == module) and issubclass(obj[1], BasePlugin)] if len(plugins) == 1: plugin = plugins[0] actual_functions_list = [ func[0] for func in inspect.getmembers(plugin, inspect.isfunction) if not func[0].startswith('_') ] if all(x in actual_functions_list for x in self._plugin_functions_interface): p = plugin() self._plugins[p.get_classname()] = p log.debug(f"Registered plugin '{p.get_classname()}'") else: log.error(f"Class '{p.get_classname()}' does comply with BasePlugin interface") elif len(plugins) > 1: log.error(f"Module '{module}' have more than 1 class in it") else: log.error(f"Module '{module}' have no classes in it")
async def _vqskip(message, command, silent=False): """Skip current track in voice queue Usage: !vqskip""" if not await Util.check_args_count( message, command, silent, min=1, max=1): return if bc.voice_client is not None: bc.voice_client.stop() await Msg.response(message, "Skipped current song", silent) else: log.debug("Nothing to skip")
def connect(self) -> None: self._db_client = pymongo.MongoClient(self.url, serverSelectionTimeoutMS=10, connectTimeoutMS=20000) try: info = self._db_client.server_info() log.debug(f"Mongo connection initialized: {info['version']}") except ServerSelectionTimeoutError as e: log.error(f"Mongo connection failed: {e}") self._db = self._db_client[self._db_name] self.markov = self._db["markov"]
async def run(self): command = self.message.content.split(' ') command = list(filter(None, command)) command[0] = command[0][1:] while True: await asyncio.sleep(self.period) log.debug(f"Triggered background event: {' '.join(command)}") if command[0] not in self.config.commands.data.keys(): await self.channel.send(f"Unknown command '{command[0]}'") else: cmd = self.config.commands.data[command[0]] saved_content = self.message.content await cmd.run(self.message, command, None) self.message.content = saved_content
async def _process_command(self, message: discord.Message) -> None: command = message.content.split(' ') command = list(filter(None, command)) command[0] = command[0][1:] if not command[0]: return log.debug("Ignoring empty command") if command[0] not in self.config.commands.data.keys(): if command[0] in self.config.commands.aliases.keys(): command[0] = self.config.commands.aliases[command[0]] else: await message.channel.send( f"Unknown command '{command[0]}', " f"probably you meant '{self._suggest_similar_command(command[0])}'" ) return if command[0] not in ( "poll", "timer", "stopwatch", "vqpush", ): timeout_error, _ = await Util.run_function_with_time_limit( self.config.commands.data[command[0]].run( message, command, self.config.users[message.author.id]), const.MAX_COMMAND_EXECUTION_TIME) if command[0] not in ("silent", ) and timeout_error: await message.channel.send( f"Command '{' '.join(command)}' took too long to execute") else: await self.config.commands.data[command[0]].run( message, command, self.config.users[message.author.id])
async def send_direct_message(author, content, silent, **kwargs): """Send direct message to message author""" if silent: return log.debug("[SILENT] -> " + content) if author.dm_channel is None: await author.create_dm() msg = None if content: for chunk in Msg.split_by_chunks(content, const.DISCORD_MAX_MESSAGE_LENGTH): msg = await author.dm_channel.send( chunk, tts=kwargs.get("tts", False), files=kwargs.get("files", None), embed=kwargs.get("embed", None)) if kwargs.get("suppress_embeds", False): await msg.edit(suppress=True) elif kwargs.get("files", None): msg = await author.dm_channel.send(None, files=kwargs.get("files", None)) elif kwargs.get("embed", None): msg = await author.dm_channel.send(embed=kwargs["embed"], tts=kwargs.get("tts", False)) if kwargs.get("suppress_embeds", False): await msg.edit(suppress=True) return msg
def check_versions(self) -> bool: """Compare versions from updated version.py with current ones""" import src.version as ver importlib.reload(ver) updated = False if self.config_version != ver.CONFIG_VERSION: self.config_version = ver.CONFIG_VERSION updated = True if self.markov_version != ver.MARKOV_CONFIG_VERSION: self.markov_version = ver.MARKOV_CONFIG_VERSION updated = True if self.secret_version != ver.SECRET_CONFIG_VERSION: self.secret_version = ver.SECRET_CONFIG_VERSION updated = True log.debug(f"Config versions were{'' if updated else ' not'} updated") return updated
def start(args) -> None: au = importlib.import_module("src.autoupdate_impl") signal.signal(signal.SIGHUP, au.at_exit) context = au.AutoUpdateContext() au.at_start() try: while True: time.sleep(const.AUTOUPDATE_CHECK_INTERVAL) is_updated = au.check_updates(context) if is_updated: au = importlib.reload(au) signal.signal(signal.SIGHUP, au.at_exit) log.debug("Reloaded autoupdate implementation module") except KeyboardInterrupt as e: au.at_failure(e) except Exception as e: au.at_exit() raise e au.at_exit()
async def run_external_command(message, cmd_line, silent=False): result = "" try: log.debug(f"Processing external command: '{cmd_line}'") process = subprocess.run(cmd_line, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=os.environ) log.debug( f"External command '{cmd_line}' finished execution with return code: {process.returncode}" ) result = process.stdout.decode("utf-8") await Msg.response(message, result, silent) except subprocess.CalledProcessError as e: await Msg.response( message, f"<Command failed with error code {e.returncode}>", silent) return result
def check_updates(context: AutoUpdateContext) -> bool: """Function that performs updates check. It is called periodically""" old_sha = context.repo.head.object.hexsha try: context.repo.remotes.origin.fetch() except Exception as e: return log.error( f"Fetch failed: {e}. Skipping this cycle, will try to update on the next one" ) new_sha = context.repo.remotes.origin.refs['master'].object.name_rev.split( )[0] log.debug(f"{old_sha} {new_sha}") if old_sha == new_sha: return log.debug("No new updates") bot_cache = importlib.import_module("src.bot_cache").BotCache(True).parse() if bot_cache is None: return log.warning( "Could not read bot cache. Skipping this cycle, will try to update on the next one" ) if "do_not_update" not in bot_cache.keys(): return log.warning( "Could not find 'do_not_update' field in bot cache. " "Skipping this cycle, will try to update on the next one") if bot_cache["do_not_update"]: return log.debug( "Automatic update is not permitted. Skipping this cycle, will try to update on the next one" ) context.repo.git.reset("--hard") try: g = git.cmd.Git(os.getcwd()) g.pull() except git.exc.GitCommandError as e: if "Connection timed out" in e.stderr or "Could not resolve host" in e.stderr: log.warning(f"{e.command}: {e.stderr}") else: raise e subprocess.call(f"{sys.executable} -m pip install -r requirements.txt", shell=True) minibot_response = "WalBot automatic update is in progress. Please, wait..." subprocess.call( f"{sys.executable} walbot.py startmini --message '{minibot_response}' --nohup &", shell=True) subprocess.call(f"{sys.executable} walbot.py stop", shell=True) if context.check_versions(): subprocess.call(f"{sys.executable} walbot.py patch", shell=True) subprocess.call(f"{sys.executable} walbot.py start --fast_start --nohup &", shell=True) while True: time.sleep(1) bot_cache = importlib.import_module("src.bot_cache").BotCache( True).parse() if bot_cache is not None and bot_cache["ready"]: subprocess.call(f"{sys.executable} walbot.py stopmini", shell=True) log.info("Bot is fully loaded. MiniWalBot is stopped.") break log.debug("Bot is not fully loaded yet. Waiting...") return True
def get_yaml(verbose: bool = False) -> Any: """Get YAML loader and dumper type. yaml.Loader and yaml.Dumper are slower implementations than yaml.CLoader and yaml.CDumper""" try: loader = yaml.CLoader if verbose: log.debug("Using fast YAML Loader") except AttributeError: loader = yaml.Loader if verbose: log.debug("Using slow YAML Loader") try: dumper = yaml.CDumper if verbose: log.debug("Using fast YAML Dumper") except AttributeError: dumper = yaml.Dumper if verbose: log.debug("Using slow YAML Dumper") return loader, dumper
async def _vleave(message, command, silent=False): """Leave (part) voice channel Usage: !vleave""" if not await Util.check_args_count( message, command, silent, min=1, max=1): return if bc.voice_client is not None: log.debug("Leaving previous voice channel...") await bc.voice_client.disconnect() log.debug("Left previous voice channel") bc.voice_client = None else: log.debug("No previous voice channel to leave")
async def reply(message, content, silent, **kwargs): """Reply on particular message""" if silent: return log.debug("[SILENT] -> " + content) msg = None if content: for chunk in Msg.split_by_chunks(content, const.DISCORD_MAX_MESSAGE_LENGTH): msg = await message.reply(chunk, tts=kwargs.get("tts", False), files=kwargs.get("files", None), embed=kwargs.get("embed", None)) if kwargs.get("suppress_embeds", False): await msg.edit(suppress=True) elif kwargs.get("files", None): msg = await message.reply(None, files=kwargs.get("files", None)) elif kwargs.get("embed", None): msg = await message.reply(embed=kwargs["embed"], tts=kwargs.get("tts", False)) if kwargs.get("suppress_embeds", False): await msg.edit(suppress=True) return msg
async def _vjoin(message, command, silent=False): """Join voice channel Usage: !vjoin <voice_channel_id>""" if not await Util.check_args_count( message, command, silent, min=2, max=2): return voice_channel_id = await Util.parse_int( message, command[1], f"Second parameter for '{command[0]}' should be an id of voice channel", silent) if voice_channel_id is None: return voice_channels = message.guild.voice_channels for v in voice_channels: if v.id == voice_channel_id: if bc.voice_client is not None: log.debug("Disconnecting from previous voice channel...") await bc.voice_client.disconnect() log.debug("Disconnected from previous voice channel") bc.voice_client = None log.debug( f"Connecting to the voice channel {voice_channel_id}...") try: bc.voice_client = await v.connect() except Exception as e: return null(await Msg.response(message, f"ERROR: Failed to connect: {e}", silent)) log.debug("Connected to the voice channel") break else: await Msg.response( message, f"🔊 Could not find voice channel with id {voice_channel_id}", silent)
async def push_video(message, yt_video_url, silent): """Push video by its URL to voice queue""" r = const.YT_VIDEO_REGEX.match(yt_video_url) if r is None: return yt_video_id = r.groups()[0] output_file_name = f'{Util.tmp_dir()}/yt_{yt_video_id}.mp3' ydl_opts = { 'format': 'bestaudio/best', 'outtmpl': output_file_name, 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', }], } try: with youtube_dl.YoutubeDL(ydl_opts) as ydl: video_info = ydl.extract_info(yt_video_url, download=False) if not os.path.exists(output_file_name): log.debug(f"Downloading YT video {yt_video_url} ...") loop = asyncio.get_event_loop() await loop.run_in_executor(None, ydl.download, [yt_video_url]) log.debug(f"Downloaded {yt_video_url}") else: log.debug(f"Found in cache: {yt_video_url}") except Exception as e: return null(await Msg.response(message, f"🔊 ERROR: Downloading failed: {e}", silent)) bc.voice_client_queue.append( VoiceQueueEntry(message.channel, video_info['title'], video_info['id'], output_file_name, message.author.name)) await Msg.response( message, f"🔊 Added {video_info['title']} (YT: {video_info['id']}) to the queue " f"at position #{len(bc.voice_client_queue)}", silent)
def at_start() -> None: """Autoupdate initialization""" if not os.path.isfile(const.BOT_CACHE_FILE_PATH): log.debug("Bot is not started! Starting...") subprocess.call( f"{sys.executable} walbot.py start --fast_start --nohup &", shell=True) else: bot_cache = BotCache(True).parse() pid = None if bot_cache is not None: pid = bot_cache["pid"] if pid is not None and psutil.pid_exists(pid): log.debug( "Bot is already started in different shell. Starting autoupdate routine." ) else: if os.path.isfile(const.BOT_CACHE_FILE_PATH): os.remove(const.BOT_CACHE_FILE_PATH) log.debug("Bot is not started! Starting...") subprocess.call( f"{sys.executable} walbot.py start --fast_start --nohup &", shell=True)
async def _precompile(self) -> None: log.debug("Started precompiling functions...") levenshtein_distance("", "") log.debug("Finished precompiling functions")
async def run(self, message, command, user, silent=False): if len(inspect.stack(0)) >= const.MAX_SUBCOMMAND_DEPTH: return null(await message.channel.send( "ERROR: Maximum subcommand depth is reached!")) log.debug(f"Processing command: {message.content}") channel_id = message.channel.id if isinstance( message.channel, discord.Thread): # Inherit command permissions for threads channel_id = message.channel.parent_id if not self.is_available(channel_id): return null(await message.channel.send( f"Command '{command[0]}' is not available in this channel")) if user is not None and self.permission > user.permission_level: return null(await message.channel.send( f"You don't have permission to call command '{command[0]}'")) self.times_called += 1 postpone_execution = [ "addcmd", "updcmd", "addextcmd", "updextcmd", "addbgevent", "addresponse", "updresponse", ] if message.content.split(' ')[0][1:] not in postpone_execution: log.debug2(f"Command (before processing): {message.content}") message.content = await self.process_subcommands( message.content, message, user) log.debug2( f"Command (after processing subcommands): {message.content}") else: log.debug2("Subcommands are not processed!") command = message.content[1:].split(' ') command = list(filter(None, command)) if self.perform is not None: return await self.get_actor()(message, command, silent) elif self.message is not None: response = self.message log.debug2(f"Command (before processing): {response}") response = await self.process_variables(response, message, command) log.debug2(f"Command (after processing variables): {response}") response = await self.process_subcommands(response, message, user) log.debug2(f"Command (after processing subcommands): {response}") if response: if not silent: if len(response) > const.DISCORD_MAX_MESSAGE_LENGTH * 5: await message.channel.send( "ERROR: Max message length exceeded " f"({len(response)} > {const.DISCORD_MAX_MESSAGE_LENGTH * 5})" ) elif len(const.UNICODE_EMOJI_REGEX.findall(response)) > 50: await message.channel.send( "ERROR: Max amount of Unicode emojis for one message exceeded " f"({len(const.UNICODE_EMOJI_REGEX.findall(response))} > {50})" ) else: for chunk in Msg.split_by_chunks( response, const.DISCORD_MAX_MESSAGE_LENGTH): await message.channel.send(chunk) return response elif self.cmd_line is not None: cmd_line = self.cmd_line[:] log.debug2(f"Command (before processing): {cmd_line}") cmd_line = await self.process_variables(cmd_line, message, command, safe=True) log.debug2(f"Command (after processing variables): {cmd_line}") cmd_line = await self.process_subcommands(cmd_line, message, user, safe=True) log.debug2(f"Command (after processing subcommands): {cmd_line}") return await Util.run_external_command(message, cmd_line, silent) else: await message.channel.send( f"Command '{command[0]}' is not callable")