Пример #1
0
class ProtosBot(Client):

    """
    ProtOS Discord Bot
    """

    logger = logging.getLogger("Bot")

    CMD_PATH = Path("commands")
    CFG_PATH = Path("config")
    SOUND_DIR = Path("sounds")

    def __init__(self, token=None, cs=BrianCS, use_voice_receive_hooks=False):

        """
        Create a new bot instance.

        token is the bot token to authenticate with.
            If it is None, the token will be read from the configuration file instead.
        cs is the class of the conversation simulator to use. It must be a subclass of
            conversation.ConversationSimulator(). Defaults to conversation.BrianCS().
        """

        super().__init__()

        self._autosave_active = False
        self._connected_futures = []

        self.event(self.on_message)
        self.event(self.on_ready)

        self.command_parser = cmdsys.CommandParser()
        self.config = ConfigManager(path=self.CFG_PATH / "bot.xml", requireVersion=2)
        self.db = DatabaseManager(path=self.CFG_PATH / "db")
        self.db.register_model(BlockedUser)
        self.db.register_model(BlockedChannel)
        self.db.register_model(PinChannel)
        self.db.register_model(AuditLogChannel)
        self.db.register_model(TimeoutRole)
        self.db.register_model(TimeoutCount)
        self.db.register_model(PinReactionSettings)
        self.db.register_model(VoiceClientSettings)

        self.audio = AudioManager(self)
        self.cs = cs(self, self.config)
        self.voice_receive =  None
        if use_voice_receive_hooks:
            rhasspy_address = (self.config.getElementText("bot.network.rhasspy.host"), self.config.getElementInt("bot.network.rhasspy.port"))
            self.voice_receive = ConnectionListener(self, RhasspyRE(rhasspy_address))
            self.logger.warn("Voice receive hooks have been ENABLED. This is considered an experimental feature and should be used with great caution.")

        self.cidsl_parser = interaction.DSLParser()
        self.cidsl = interaction.DSLInterpreter(self)
        self.cidsl.registerAudioEngine(self.audio)
        for i in os.listdir("chat/scripts"):
            p = os.path.join("chat/scripts", i)
            if (not os.path.isfile(p)) or (not p.endswith(".ci")):
                continue
            f = open(p, "r")
            self.logger.debug("Loading CIDSL script at %s..." % p)
            try:
                self.cidsl.compile(self.cidsl_parser.parse("\n".join(f.readlines())))
            except:
                self.logger.exception("Exception occured while loading CIDSL script at %s: " % p)
            f.close()

        cmdsys.environment.update_environment({
            "client": self,
            "config": self.config,
            "database": self.db,
            "audio": self.audio,
            "conversation_simulator": self.cs,
            "voice_receive": self.voice_receive,
            "cidsl": self.cidsl
            })

        self.load_commands()

        self.token = token or self.config.getElementText("bot.token")

    def load_commands(self):

        """
        (Re)loads commands.
        """

        self.commands = cmdsys.load_commands(self.CMD_PATH)
        for command in self.commands:
            command.client = self

    def run(self):

        """
        Run the bot instance. This call will block.
        """

        self.logger.debug("Starting...")
        super().run(self.token)

    def wait_for_connection(self):

        if self.is_ready():
            f = asyncio.Future()
            f.set_result(True)
            return f
        f = asyncio.Future()
        self._connected_futures.append(f)
        return f

    def log_message(self, msg: Message):

        """
        Logs the message to the console window.
        Includes pretty printer options for server, channel, names/nicknames and role color
        """
        
        logger = self.logger.getChild("MessageLog")

        s = cmdutils.translateString(msg.content)
        a = cmdutils.translateString(msg.author.name)
        if hasattr(msg.author, "nick") and msg.author.nick:
            a = cmdutils.translateString(msg.author.nick) + "/" + a
            
        for i in msg.attachments: #display attachments
            s += "\n    => with attachment:"
            for j in i.items():
                s += "\n       " + j[0] + ": " + str(j[1])

        if isinstance(msg.channel, discord.abc.PrivateChannel): #We received a DM instead of a server message, this means most of our code is not required
            if isinstance(msg.channel, discord.DMChannel):    
                name = msg.channel.recipient.name
            else:
                name = getattr(msg.channel, "name", msg.channel.recipients[0].name)
            
            logger.info("[DM][%s](%s): %s" % (name, a, s))
            return

        color = msg.author.colour.to_rgb() if hasattr(msg.author, "colour") else (0, 0, 0)
        logger.info("[%s][%s](%s): %s" % (msg.guild.name, msg.channel.name, cmdutils.colorText(a, color), s))

    async def on_ready(self):

        """
        Event handler for ready events.

        Finishes initialization and schedules periodic tasks.
        """

        self.logger.info(S_TITLE_VERSION)
        self.logger.info("-"*50)
        self.logger.info("Ready.")

        await asyncio.sleep(3) #wait a short amount of time until we have received all data
        
        self.logger.debug("Starting autosave scheduler...")
        self.loop.create_task(self._autosave())

        game = Game(name= "%s | %s%s" % (S_VERSION, self.config.getElementText("bot.prefix"), "about"))
        await self.change_presence(activity=game)

        for f in self._connected_futures:
            f.set_result(True)

    async def on_message(self, msg: Message):

        """
        Event handler for messages.

        Handles command dispatch through text chat as well as interaction with CIDSL and conversation simulators.
        """

        self.log_message(msg)

        if msg.content.startswith(self.config.getElementText("bot.prefix")):
            responseHandle = ChatResponse(self, msg)
            await self.command_parser.parse_command(responseHandle, self.commands, self)

        else:

            if self.user.mentioned_in(msg) and not msg.author.id == self.user.id:

                await msg.channel.trigger_typing() #makes it seem more real (TODO: we need a way to terminate this if the following code throws an error)

                if self.cidsl.run(msg):
                    return

                #use our MegaHal implementation to get a response

                if self.config.getElementText("bot.chat.aistate") == "off": #AI turned off
                    await msg.channel.send(msg.author.mention + ", Sorry, this feature is unavailable right now. Please try again later.")
                    return

                if msg.guild:
                    #Check if this channel is blocked for AI
                    if len(self.db.get_db_by_message(msg).query(BlockedChannel).filter(channel_id=msg.channel.id)) > 0: #YOU'RE BANNED
                        await msg.channel.send(msg.author.mention + ", " + interaction.confused.getRandom())
                        return

                if not ("<@" + str(self.user.id) + ">" in msg.content or "<@!" + str(self.user.id) + ">" in msg.content): #cheking for group mentions
                    #must be either @here or @everyone... make this a rare occurance
                    if random.random() > 0.01: #only process every 100th message
                        return


                response = await self.cs.respond(msg)

                if self.config.getElementText("bot.chat.aistate") == "passive": #AI in passive mode
                    return

                if isinstance(response, bytes):
                    await msg.channel.send(msg.author.mention + ", " + response.decode()) #post our answer
                else:
                    await msg.channel.send(msg.author.mention + ", " + response)

            elif len(msg.content) > 0 and (not msg.author.id == self.user.id): #we don't want the bot to listen to its own messages

                if self.config.getElementText("bot.chat.aistate") == "off": #AI turned off
                    return

                if msg.guild:
                    #Check if this channel is blocked for AI
                    if len(self.db.get_db_by_message(msg).query(BlockedChannel).filter(channel_id=msg.channel.id)) > 0: #YOU'RE BANNED
                        return

                await self.cs.observe(msg)

                c = msg.content.lower()
                if " ram " in c or c.startswith("ram ") or c.endswith(" ram") or c == "ram": #Make the bot sometimes respond to its name when it comes up

                    for i in msg.guild.emojis: #Always try to add a "Ram" emoji if available
                        if i.name.lower() == "ram":
                            await msg.add_reaction(i)
                            break

                    if random.random() <= 0.001: #Responds with a probability of 1:1000
                        await msg.channel.send(interaction.mentioned.getRandom()) #IT HAS BECOME SELF AWARE!!!

    async def on_voice_state_update(self, what, before, after):

        before_channel = before.channel
        after_channel = after.channel

        #Voice line handling
        if (after_channel and after_channel != before_channel): #if the user is connected and has changed his voice channel (this may mean he just joined)
            if what.guild.voice_client and (after_channel == what.guild.voice_client.channel): #we are in the same channel as our target

                #Use new dynamic voiceline handling (voicelines are compared by User ID / Filename instead of a dictionary lookup
                #This isn't necessarily any faster but it is more convenient and doesn't require a restart to assign voicelines
                dir = os.listdir(self.SOUND_DIR / "voicelines")
                for i in dir:
                    if os.path.isdir(self.SOUND_DIR / "voicelines" / i) and  i == str(what.id): #we have a voiceline folder for this member
                        files = os.listdir(self.SOUND_DIR / "voicelines" / i)
                        if not files: #no voicelines found, return
                            return
                        filepath = self.SOUND_DIR / "voicelines" / i / random.choice(files)
                        sound = FFMPEGSound(filepath.as_posix())
                        self.audio.playSound(sound, after_channel, sync=False)
                        return

    async def _autosave(self):

        """
        Periodic autosave task.
        Runs every 5 minutes and writes changes made to the config and the CS to the filesystem.
        """

        logger = self.logger.getChild("Autosave")

        if self._autosave_active:
            logger.warn("Autosave task was triggered but is already running!")
            return

        self._autosave_active = True

        while not self.is_closed():
            await asyncio.sleep(300)

            logger.info("Periodic backup task triggered.")
            await self.save()

        self._autosave_active = False

    async def save(self):

        """
        Save the current configuration.
        """

        self.logger.debug("Saving data...")
        self.config.save()
        await self.cs.setOpt("SAVE", None)
        self.logger.debug("Backup complete!")

    async def shutdown(self, reason=""):

        """
        Shut down the bot.
        This coroutine will correctly deinitialize all submodules and run all cleanup functions
        before shutting down the event loop and exiting.

        reason specifies an optional string to log when shutting the bot down.
        """

        self.logger.info("Shutting down with reason: %s" % reason)

        await cmdsys.cleanUp()

        await self.save()
        await self.logout()
        await self.close()