Example #1
0
    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()
Example #2
0
 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"
             )
Example #3
0
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)
Example #4
0
 def start(self, args, main_bot=True):
     # Check whether bot is already running
     bot_cache = BotCache(main_bot).parse()
     if bot_cache is not None:
         pid = bot_cache["pid"]
         if pid is not None and psutil.pid_exists(pid):
             return log.error("Bot is already running!")
     # Some variable initializations
     config = None
     secret_config = None
     bc.restart_flag = False
     bc.args = args
     # Handle --nohup flag
     if sys.platform in ("linux", "darwin") and args.nohup:
         fd = os.open(const.NOHUP_FILE_PATH,
                      os.O_WRONLY | os.O_CREAT | os.O_APPEND)
         log.info(f"Output is redirected to {const.NOHUP_FILE_PATH}")
         os.dup2(fd, sys.stdout.fileno())
         os.dup2(sys.stdout.fileno(), sys.stderr.fileno())
         os.close(fd)
         signal.signal(signal.SIGHUP, signal.SIG_IGN)
     # Selecting YAML parser
     bc.yaml_loader, bc.yaml_dumper = Util.get_yaml(verbose=True)
     # Saving application pd in order to safely stop it later
     BotCache(main_bot).dump_to_file()
     # Executing patch tool if it is necessary
     if args.patch:
         cmd = f"'{sys.executable}' '{os.path.dirname(__file__) + '/../tools/patch.py'}' all"
         log.info("Executing patch tool: " + cmd)
         subprocess.call(cmd)
     # Read configuration files
     config = Util.read_config_file(const.CONFIG_PATH)
     if config is None:
         config = Config()
     secret_config = Util.read_config_file(const.SECRET_CONFIG_PATH)
     if secret_config is None:
         secret_config = SecretConfig()
     bc.markov = Util.read_config_file(const.MARKOV_PATH)
     if bc.markov is None and os.path.isdir("backup"):
         # Check available backups
         markov_backups = sorted([
             x for x in os.listdir("backup")
             if x.startswith("markov_") and x.endswith(".zip")
         ])
         if markov_backups:
             # Restore Markov model from backup
             with zipfile.ZipFile("backup/" + markov_backups[-1],
                                  'r') as zip_ref:
                 zip_ref.extractall(".")
             log.info(
                 f"Restoring Markov model from backup/{markov_backups[-1]}")
             shutil.move(markov_backups[-1][:-4], "markov.yaml")
             bc.markov = Util.read_config_file(const.MARKOV_PATH)
             if bc.markov is None:
                 bc.markov = Markov()
                 log.warning(
                     "Failed to restore Markov model from backup. Creating new Markov model..."
                 )
     if bc.markov is None:
         bc.markov = Markov()
         log.info("Created empty Markov model")
     # Check config versions
     ok = True
     ok &= Util.check_version(
         "discord.py",
         discord.__version__,
         const.DISCORD_LIB_VERSION,
         solutions=[
             "execute: python -m pip install -r requirements.txt",
         ])
     ok &= Util.check_version(
         "Config",
         config.version,
         const.CONFIG_VERSION,
         solutions=[
             "run patch tool",
             "remove config.yaml (settings will be lost!)",
         ])
     ok &= Util.check_version(
         "Markov config",
         bc.markov.version,
         const.MARKOV_CONFIG_VERSION,
         solutions=[
             "run patch tool",
             "remove markov.yaml (Markov model will be lost!)",
         ])
     ok &= Util.check_version(
         "Secret config",
         secret_config.version,
         const.SECRET_CONFIG_VERSION,
         solutions=[
             "run patch tool",
             "remove secret.yaml (your Discord authentication token will be lost!)",
         ])
     if main_bot and not ok:
         sys.exit(const.ExitStatus.CONFIG_FILE_ERROR)
     config.commands.update()
     # Checking authentication token
     if secret_config.token is None:
         secret_config = SecretConfig()
         if not FF.is_enabled("WALBOT_FEATURE_NEW_CONFIG"):
             secret_config.token = input("Enter your token: ")
     # Constructing bot instance
     if main_bot:
         intents = discord.Intents.all()
         walbot = WalBot(args.name, config, secret_config, intents=intents)
     else:
         walbot = importlib.import_module("src.minibot").MiniWalBot(
             args.name, config, secret_config, args.message)
     # Starting the bot
     try:
         walbot.run(secret_config.token)
     except discord.errors.PrivilegedIntentsRequired:
         log.error(
             "Privileged Gateway Intents are not enabled! Shutting down the bot..."
         )
     # After stopping the bot
     log.info("Bot is disconnected!")
     if main_bot:
         config.save(const.CONFIG_PATH,
                     const.MARKOV_PATH,
                     const.SECRET_CONFIG_PATH,
                     wait=True)
     BotCache(main_bot).remove()
     if bc.restart_flag:
         cmd = f"'{sys.executable}' '{os.path.dirname(os.path.dirname(__file__)) + '/walbot.py'}' start"
         log.info("Calling: " + cmd)
         if sys.platform in ("linux", "darwin"):
             fork = os.fork()
             if fork == 0:
                 subprocess.call(cmd)
             elif fork > 0:
                 log.info("Stopping current instance of the bot")
                 sys.exit(const.ExitStatus.NO_ERROR)
         else:
             subprocess.call(cmd)
Example #5
0
class WalBot(discord.Client):
    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"
                )

    async def _bot_runner_task(self, *args, **kwargs):
        try:
            await self.start(*args, **kwargs)
        finally:
            if not self.is_closed():
                await self.close()

    def run(self, *args, **kwargs):
        # Sightly patched implementation from discord.py discord.Client (parent) class
        # Reference: https://github.com/Rapptz/discord.py/blob/master/discord/client.py
        loop = self.loop
        try:
            loop.add_signal_handler(signal.SIGINT, lambda: loop.stop())
            loop.add_signal_handler(signal.SIGTERM, lambda: loop.stop())
        except NotImplementedError:
            pass
        asyncio.ensure_future(self._bot_runner_task(*args, *kwargs), loop=loop)
        try:
            loop.run_forever()
        except KeyboardInterrupt:
            loop.stop()
            log.info('Received signal to terminate bot and event loop')
        log.info("Shutting down the bot...")
        tasks = {t for t in asyncio.all_tasks(loop=loop) if not t.done()}
        for task in tasks:
            task.cancel()
        loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
        for task in tasks:
            if not task.cancelled():
                log.error("Asynchronous task cancel failed!")
        loop.run_until_complete(loop.shutdown_asyncgens())
        loop.run_until_complete(self._on_shutdown())
        loop.close()
        log.info("Bot is shut down!")

    async def _on_shutdown(self) -> None:
        if self.repl is not None:
            self.repl.stop()
        for event in bc.background_events:
            event.cancel()
        bc.background_loop = None
        await bc.plugin_manager.broadcast_command("close")

    @Mail.send_exception_info_to_admin_emails_async
    async def _precompile(self) -> None:
        log.debug("Started precompiling functions...")
        levenshtein_distance("", "")
        log.debug("Finished precompiling functions")

    async def _change_status(self, string: str,
                             type_: discord.ActivityType) -> None:
        await self.change_presence(
            activity=discord.Activity(name=string, type=type_))

    async def _config_autosave(self) -> None:
        await self.wait_until_ready()
        index = 1
        while not self.is_closed():
            await asyncio.sleep(self.config.saving["period"] * 60)
            if index % self.config.saving["backup"]["period"] == 0:
                self.config.backup(const.CONFIG_PATH, const.MARKOV_PATH)
            self.config.save(const.CONFIG_PATH, const.MARKOV_PATH,
                             const.SECRET_CONFIG_PATH)
            index += 1

    async def _process_reminders_iteration(self) -> None:
        log.debug3("Reminder processing iteration has started")
        now = datetime.datetime.now().replace(second=0).strftime(
            const.REMINDER_DATETIME_FORMAT)
        to_remove = []
        to_append = []
        reminder_do_not_update_flag = False
        for key, rem in self.config.reminders.items():
            for i in range(len(rem.prereminders_list)):
                prereminder = rem.prereminders_list[i]
                used_prereminder = rem.used_prereminders_list[i]
                if prereminder == 0 or used_prereminder:
                    continue
                prereminder_time = (datetime.datetime.now().replace(second=0) +
                                    datetime.timedelta(minutes=prereminder))
                if rem == prereminder_time.strftime(
                        const.REMINDER_DATETIME_FORMAT):
                    channel = self.get_channel(rem.channel_id)
                    e = DiscordEmbed()
                    clock_emoji = get_clock_emoji(
                        datetime.datetime.now().strftime("%H:%M"))
                    e.title(f"{prereminder} minutes left until reminder")
                    e.description(rem.message + "\n" + rem.notes)
                    e.color(random.randint(0x000000, 0xffffff))
                    e.timestamp(
                        datetime.datetime.now(datetime.timezone.utc) +
                        datetime.timedelta(minutes=prereminder))
                    e.footer(text=rem.author)
                    await channel.send("", embed=e.get())
                    rem.used_prereminders_list[i] = True
            if rem == now:
                channel = self.get_channel(rem.channel_id)
                clock_emoji = get_clock_emoji(
                    datetime.datetime.now().strftime("%H:%M"))
                e = DiscordEmbed()
                e.title(f"{clock_emoji} You asked to remind")
                e.description(rem.message + "\n" + rem.notes)
                e.color(random.randint(0x000000, 0xffffff))
                e.timestamp(datetime.datetime.now(datetime.timezone.utc))
                e.footer(text=rem.author)
                await channel.send(
                    ' '.join(rem.ping_users if rem.ping_users else ""),
                    embed=e.get())
                for user_id in rem.whisper_users:
                    await Msg.send_direct_message(
                        self.get_user(user_id),
                        f"You asked to remind at {now} -> {rem.message}",
                        False)
                if rem.email_users:
                    mail = Mail(self.secret_config)
                    mail.send(
                        rem.email_users, f"Reminder: {rem.message}",
                        f"You asked to remind at {now} -> {rem.message}")
                if rem.repeat_after > 0:
                    new_time = datetime.datetime.now().replace(
                        second=0, microsecond=0) + rem.get_next_event_delta()
                    new_time = new_time.strftime(
                        const.REMINDER_DATETIME_FORMAT)
                    to_append.append(
                        Reminder(str(new_time), rem.message, rem.channel_id,
                                 rem.author, rem.time_created))
                    to_append[-1].repeat_after = rem.repeat_after
                    to_append[
                        -1].repeat_interval_measure = rem.repeat_interval_measure
                    to_append[-1].prereminders_list = rem.prereminders_list
                    to_append[-1].used_prereminders_list = [False] * len(
                        rem.prereminders_list)
                    to_append[-1].notes = rem.notes
                    log.debug2(
                        f"Scheduled renew of recurring reminder - old id: {key}"
                    )
                to_remove.append(key)
            elif rem < now:
                log.debug2(f"Scheduled reminder with id {key} removal")
                to_remove.append(key)
            else:
                prereminders_delay = 0
                if rem.prereminders_list:
                    prereminders_delay = max(rem.prereminders_list)
                if ((datetime.datetime.strptime(
                        rem.time, const.REMINDER_DATETIME_FORMAT) -
                     datetime.datetime.now()) < datetime.timedelta(
                         minutes=(5 + prereminders_delay / 60))):
                    reminder_do_not_update_flag = True
        bc.do_not_update[
            DoNotUpdateFlag.REMINDER] = reminder_do_not_update_flag
        for key in to_remove:
            self.config.reminders.pop(key)
        for item in to_append:
            key = self.config.ids["reminder"]
            self.config.reminders[key] = item
            self.config.ids["reminder"] += 1
        log.debug3("Reminder processing iteration has finished")

    @Mail.send_exception_info_to_admin_emails_async
    async def _process_reminders(self) -> None:
        await self.wait_until_ready()
        while not self.is_closed():
            await self._process_reminders_iteration()
            await asyncio.sleep(const.REMINDER_POLLING_INTERVAL)

    async def _repl_routine(self) -> None:
        self.repl = Repl(self.config.repl["port"])
        await self.repl.start()

    @Mail.send_exception_info_to_admin_emails_async
    async def on_ready(self) -> None:
        self._load_plugins()
        log.info(
            f"Logged in as: {self.user.name} {self.user.id} ({self.__class__.__name__}), "
            f"instance: {self.instance_name}")
        self.bot_cache.update({
            "ready": True,
        })
        self.bot_cache.dump_to_file()
        bc.guilds = self.guilds
        for guild in self.guilds:
            if guild.id not in self.config.guilds.keys():
                self.config.guilds[guild.id] = GuildSettings(guild.id)
        bc.bot_user = self.user
        self.loop.create_task(self._config_autosave())
        self.loop.create_task(self._precompile())

    def _load_plugins(self) -> None:
        for plugin_name in bc.plugin_manager.get_plugins_list():
            if plugin_name not in self.config.plugins.keys():
                self.config.plugins[plugin_name] = {
                    "autostart": False,
                }
        for plugin_name, plugin_state in self.config.plugins.items():
            if plugin_state["autostart"]:
                asyncio.create_task(
                    bc.plugin_manager.send_command(plugin_name, "init"))

    @Mail.send_exception_info_to_admin_emails_async
    async def on_message(self, message: discord.Message) -> None:
        await bc.plugin_manager.broadcast_command("on_message", message)
        if self.config.guilds[message.channel.guild.id].ignored:
            return
        bc.message_buffer.push(message)
        log.info(f"<{message.id}> {message.author} -> {message.content}")
        if message.author.id == self.user.id:
            return
        if isinstance(message.channel, discord.DMChannel):
            return
        if message.channel.guild.id is None:
            return
        if self.config.guilds[message.channel.guild.id].is_whitelisted:
            if message.channel.id not in self.config.guilds[
                    message.channel.guild.id].whitelist:
                return
        if message.author.id not in self.config.users.keys():
            self.config.users[message.author.id] = User(message.author.id)
        if self.config.users[message.author.id].permission_level < 0:
            return
        if message.content.startswith(self.config.commands_prefix):
            await self._process_command(message)
        else:
            await self._process_regular_message(message)
            await self._process_repetitions(message)

    @Mail.send_exception_info_to_admin_emails_async
    async def on_message_edit(self, old_message: discord.Message,
                              message: discord.Message) -> None:
        if message.embeds != old_message.embeds:
            log.info(
                f"<{message.id}> (edit, embed update) {message.author} -> {message.content}"
            )
            return
        if self.config.guilds[message.channel.guild.id].ignored:
            return
        bc.message_buffer.push(message)
        log.info(
            f"<{message.id}> (edit) {message.author} -> {message.content}")
        if message.author.id == self.user.id:
            return
        if isinstance(message.channel, discord.DMChannel):
            return
        if message.channel.guild.id is None:
            return
        if self.config.guilds[message.channel.guild.id].is_whitelisted:
            if message.channel.id not in self.config.guilds[
                    message.channel.guild.id].whitelist:
                return
        if message.author.id not in self.config.users.keys():
            self.config.users[message.author.id] = User(message.author.id)
        if self.config.users[message.author.id].permission_level < 0:
            return
        if message.content.startswith(self.config.commands_prefix):
            await self._process_command(message)

    async def _process_repetitions(self, message: discord.Message) -> None:
        m = tuple(
            bc.message_buffer.get(message.channel.id, i) for i in range(3))
        if (all(m) and m[0].content
                and m[0].content == m[1].content == m[2].content and
            (m[0].author.id != self.user.id and m[1].author.id != self.user.id
             and m[2].author.id != self.user.id)):
            await message.channel.send(m[0].content)

    async def _process_regular_message(self, message: discord.Message) -> None:
        channel_id = message.channel.id
        if isinstance(
                message.channel,
                discord.Thread):  # Inherit parent channel settings for threads
            channel_id = message.channel.parent_id
        if (self.user.mentioned_in(message) or self.user.id in [
                member.id for member in list(
                    itertools.chain(
                        *[role.members for role in message.role_mentions]))
        ]):
            if channel_id in self.config.guilds[
                    message.channel.guild.id].markov_responses_whitelist:
                result = await self.config.disable_pings_in_response(
                    message, bc.markov.generate())
                await message.channel.send(message.author.mention + ' ' +
                                           result)
        elif channel_id in self.config.guilds[
                message.channel.guild.id].markov_logging_whitelist:
            needs_to_be_added = True
            for ignored_prefix in bc.markov.ignored_prefixes.values():
                if message.content.startswith(ignored_prefix):
                    needs_to_be_added = False
                    break
            if needs_to_be_added:
                bc.markov.add_string(message.content)
        if channel_id in self.config.guilds[
                message.channel.guild.id].responses_whitelist:
            responses_count = 0
            for response in self.config.responses.values():
                if responses_count >= const.MAX_BOT_RESPONSES_ON_ONE_MESSAGE:
                    break
                if re.search(response.regex, message.content):
                    text = await Command.process_subcommands(
                        response.text, message,
                        self.config.users[message.author.id])
                    await Msg.reply(message, text, False)
                    responses_count += 1
        if channel_id in self.config.guilds[
                message.channel.guild.id].reactions_whitelist:
            for reaction in self.config.reactions.values():
                if re.search(reaction.regex, message.content):
                    log.info("Added reaction " + reaction.emoji)
                    try:
                        await message.add_reaction(reaction.emoji)
                    except discord.HTTPException:
                        pass

    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])

    def _suggest_similar_command(self, unknown_command: str) -> str:
        min_dist = 100000
        suggestion = ""
        for command in self.config.commands.data.keys():
            dist = levenshtein_distance(unknown_command, command)
            if dist < min_dist:
                suggestion = command
                min_dist = dist
        for command in self.config.commands.aliases.keys():
            dist = levenshtein_distance(unknown_command, command)
            if dist < min_dist:
                suggestion = command
                min_dist = dist
        return suggestion

    async def on_raw_message_edit(
            self, payload: discord.RawMessageUpdateEvent) -> None:
        try:
            log.info(
                f"<{payload.message_id}> (raw_edit) {payload.data['author']['username']}#"
                f"{payload.data['author']['discriminator']} -> {payload.data['content']}"
            )
        except KeyError:
            pass

    async def on_raw_message_delete(
            self, payload: discord.RawMessageDeleteEvent) -> None:
        log.info(f"<{payload.message_id}> (delete)")