示例#1
0
class Overlord(discord.Client):
    __async_lock: asyncio.Lock
    __initialized: bool
    __awaiting_sync: bool
    __awaiting_sync_last_updated: datetime

    # Members loaded from ENV
    token: str
    guild_id: int
    control_channel_id: int
    error_channel_id: int

    # Members passed via constructor
    config: ConfigView
    db: DB.DBSession

    # Values initiated on_ready
    guild: discord.Guild
    control_channel: discord.TextChannel
    error_channel: discord.TextChannel
    me: discord.Member

    # Services
    s_users: UserService
    s_roles: RoleService
    s_events: EventService
    s_stats: StatService
    s_ranking: RankingService

    # Scheduled tasks
    tasks: List[asyncio.AbstractEventLoop]

    def __init__(self, config: ConfigView, db_session: DB.DBSession):
        self.__async_lock = asyncio.Lock()
        self.__initialized = False
        self.__awaiting_sync = True
        self.__awaiting_sync_last_updated = datetime.now()
        self.tasks = []

        self.config = config
        self.db = db_session

        # Init base class
        intents = discord.Intents.none()
        intents.guilds = True
        intents.members = True
        intents.messages = True
        intents.voice_states = True

        super().__init__(intents=intents)

        # Load env values
        self.token = os.getenv('DISCORD_TOKEN')
        self.guild_id = int(os.getenv('DISCORD_GUILD'))
        self.control_channel_id = int(os.getenv('DISCORD_CONTROL_CHANNEL'))
        self.error_channel_id = int(os.getenv('DISCORD_ERROR_CHANNEL'))

        # Preset values initiated on_ready
        self.guild = None
        self.control_channel = None
        self.error_channel = None
        self.me = None

        # Services
        self.s_roles = RoleService(self.db)
        self.s_users = UserService(self.db, self.s_roles)
        self.s_events = EventService(self.db)
        self.s_stats = StatService(self.db, self.s_events)
        self.s_ranking = RankingService(self.s_stats, self.s_roles,
                                        self.config.ranks)

    ###########
    # Getters #
    ###########

    def sync(self) -> asyncio.Lock:
        return self.__async_lock

    def is_guild_member(self, member: discord.Member) -> bool:
        return member.guild.id == self.guild.id

    def is_guild_member_message(self, msg: discord.Message) -> bool:
        return not is_dm_message(msg) and msg.guild.id == self.guild.id

    def check_afk_state(self, state: discord.VoiceState) -> bool:
        return not state.afk or not self.config["event.voice.afk.ignore"]

    def is_special_channel_id(self, channel_id: int) -> bool:
        return channel_id == self.control_channel.id or channel_id == self.error_channel.id

    def get_role(self, role_name: str) -> Optional[discord.Role]:
        return self.s_roles.get(role_name)

    def is_admin(self, user: discord.Member) -> bool:
        roles = self.config["control.roles"]
        return len(filter_roles(user, roles)) > 0

    def awaiting_sync(self):
        return self.__awaiting_sync

    def awaiting_sync_elapsed(self):
        if not self.__awaiting_sync:
            return 0
        return (datetime.now() -
                self.__awaiting_sync_last_updated).total_seconds()

    ################
    # Sync methods #
    ################

    def run(self):
        super().run(self.token)

    def check_config(self):
        admin_roles = self.config["control.roles"]
        for role_name in admin_roles:
            if self.get_role(role_name) is None:
                raise InvalidConfigException(f"No such role: '{role_name}'",
                                             "bot.control.roles")
        # Check ranks config
        self.s_ranking.check_config()

    def update_config(self, config: ConfigView):
        self.config = config
        self.s_ranking.config = config.ranks
        self.check_config()

    def set_awaiting_sync(self):
        self.__awaiting_sync_last_updated = datetime.now()
        self.__awaiting_sync = True

    def unset_awaiting_sync(self):
        self.__awaiting_sync_last_updated = datetime.now()
        self.__awaiting_sync = False

    #################
    # Async methods #
    #################

    async def init_lock(self):
        while not self.__initialized:
            await asyncio.sleep(0.1)
        return

    async def send_error(self, msg: str):
        if self.error_channel is not None:
            await self.error_channel.send(
                res.get("messages.error").format(msg))
        return

    async def send_warning(self, msg: str):
        if self.error_channel is not None:
            await self.error_channel.send(
                res.get("messages.warning").format(msg))
        return

    async def sync_users(self):
        log.info('Syncing roles')
        self.s_roles.load(self.guild.roles)

        log.info(f'Syncing users')
        # Mark everyone absent
        self.s_users.mark_everyone_absent()
        # Reload
        async for member in self.guild.fetch_members(limit=None):
            # Cache and skip bots
            if member.bot:
                self.s_users.cache_bot(member)
                continue
            # Update and repair
            user = self.s_users.update_member(member)
            self.s_events.repair_member_joined_event(member, user)
        # Remove effectively absent
        if not self.config["user.leave.keep"]:
            self.s_users.remove_absent()
        self.unset_awaiting_sync()
        log.info(f'Syncing users done')

    async def update_user_rank(self, member: discord.Member):
        if self.awaiting_sync():
            log.warn("Cannot update user rank: awaiting role sync")
            return False
        # Resolve user
        user = self.s_users.get(member)
        # Skip non-existing users
        if user is None:
            log.warn(
                f'{qualified_name(member)} does not exist in db! Skipping user rank update!'
            )
            return
        # Ignore inappropriate members
        if self.s_ranking.ignore_member(member):
            return
        # Resolve roles to move
        roles_add, roles_del = self.s_ranking.roles_to_add_and_remove(
            member, user)
        # Remove old roles
        if roles_del:
            log.info(
                f"Removing {qualified_name(member)}'s rank roles: {roles_del}")
            await member.remove_roles(*roles_del)
        # Add new roles
        if roles_add:
            log.info(
                f"Adding {qualified_name(member)}'s rank roles: {roles_add}")
            await member.add_roles(*roles_add)
        # Update user in db
        self.s_users.update_member(member)
        return True

    async def update_user_ranks(self):
        if self.awaiting_sync():
            log.error("Cannot update user ranks: awaiting role sync")
            await self.send_error(
                f'Cannot update user ranks: awaiting role sync')
            return
        log.info(f'Updating user ranks')
        async for member in self.guild.fetch_members(limit=None):
            # Cache and skip bots
            if member.bot:
                self.s_users.cache_bot(member)
                continue
            await self.update_user_rank(member)
        log.info(f'Done updating user ranks')

    async def resolve_user(self, user_mention: str) -> Optional[discord.User]:
        try:
            if '#' in user_mention:
                user = self.s_users.get_by_qualified_name(user_mention)
            else:
                user = self.s_users.get_by_display_name(user_mention)
            if user is None:
                return None
            return await self.fetch_user(user.did)
        except discord.NotFound:
            return None
        except ValueError:
            return None

    async def logout(self):
        for task in self.tasks:
            task.stop()
        await super().logout()

    #############
    # Own tasks #
    #############

    def get_user_sync_task(self, **kwargs) -> asyncio.AbstractEventLoop:
        @tasks.loop(**kwargs)
        async def user_sync_task():
            if self.awaiting_sync_elapsed() < 30:
                return
            log.info("Scheduled user sync update")
            async with self.sync():
                await self.sync_users()
            log.info("Done scheduled user sync update")

        return user_sync_task

    #########
    # Hooks #
    #########

    async def on_error(self, event, *args, **kwargs):
        """
            Async error event handler

            Sends stacktrace to error channel
        """
        ex_type = sys.exc_info()[0]
        ex = sys.exc_info()[1]

        logging.exception(f'Error on event: {event}')

        # exception_lines = traceback.format_exception(*sys.exc_info())
        # exception_msg = '`' + ''.join(exception_lines).replace('`', '\'')[:1800] + '`'
        exception_msg_short = f'`{str(ex)}`\nBlame <@281130377488236554>'

        if self.error_channel is not None:
            await self.send_error(exception_msg_short)

        if ex_type is InvalidConfigException:
            await self.logout()
        if ex_type is NotCoroutineException:
            await self.logout()

    async def on_ready(self):
        """
            Async ready event handler

            Completly initialize bot state
        """
        # Lock current async context
        async with self.sync():
            # Find guild
            self.guild = self.get_guild(self.guild_id)
            if self.guild is None:
                raise InvalidConfigException("Discord server id is invalid",
                                             "DISCORD_GUILD")
            log.info(
                f'{self.user} is connected to the following guild: {self.guild.name}(id: {self.guild.id})'
            )

            self.me = await self.guild.fetch_member(self.user.id)

            # Attach control channel
            channel = self.get_channel(self.control_channel_id)
            if channel is None:
                raise InvalidConfigException(f'Control channel id is invalid',
                                             'DISCORD_CONTROL_CHANNEL')
            if not is_text_channel(channel):
                raise InvalidConfigException(
                    f"{channel.name}({channel.id}) is not text channel",
                    'DISCORD_CONTROL_CHANNEL')
            log.info(
                f'Attached to {channel.name} as control channel ({channel.id})'
            )
            self.control_channel = channel

            # Attach error channel
            if self.error_channel_id:
                channel = self.get_channel(self.error_channel_id)
                if channel is None:
                    raise InvalidConfigException(
                        f'Error channel id is invalid',
                        'DISCORD_ERROR_CHANNEL')
                if not is_text_channel(channel):
                    raise InvalidConfigException(
                        f"{channel.name}({channel.id}) is not text channel",
                        'DISCORD_ERROR_CHANNEL')
                log.info(
                    f'Attached to {channel.name} as error channel ({channel.id})'
                )
                self.error_channel = channel

            # Sync roles and users
            await self.sync_users()

            # Check config value
            self.check_config()

            # Schedule tasks
            self.tasks.append(
                self.s_stats.get_stat_update_task(
                    self.sync(), hours=24, loop=asyncio.get_running_loop()))
            self.tasks.append(
                self.get_user_sync_task(minutes=1,
                                        loop=asyncio.get_running_loop()))

            # Start tasks
            for task in self.tasks:
                task.start()

            # Message for pterodactyl panel
            print(self.config["egg_done"])
            self.__initialized = True

    @after_initialized
    @event_config("message.new")
    @skip_bots
    @guild_member_event
    async def on_message(self, message: discord.Message):
        """
            Async new message event handler

            Saves event in database
        """
        # handle control commands seperately
        if message.channel == self.control_channel:
            await self.on_control_message(message)
            return
        # Sync code part
        async with self.sync():
            user = self.s_users.get(message.author)
            # Skip non-existing users
            if user is None:
                log.warn(
                    f'{qualified_name(message.author)} does not exist in db! Skipping new message event!'
                )
                return
            # Save event
            self.s_events.create_new_message_event(user, message)
            # Update stats
            inc_value = self.s_stats.get(user, 'new_message_count') + 1
            self.s_stats.set(user, 'new_message_count', inc_value)
            # Update user rank
            await self.update_user_rank(message.author)

    async def on_control_message(self, message: discord.Message):
        """
            Async new control message event handler

            Calls appropriate control callback
        """
        if not self.is_admin(message.author):
            return

        prefix = self.config["control.prefix"]
        argv = parse_control_message(prefix, message)

        if argv is None or len(argv) == 0:
            return

        cmd_name = argv[0]

        control_hooks = self.config["commands"]

        if cmd_name == "help":
            help_lines = []
            line_fmt = res.get("messages.commands_list_entry")
            for cmd in control_hooks:
                hook = get_module_element(control_hooks[cmd])
                base_line = build_cmdcoro_usage(prefix, cmd, hook.or_cmdcoro)
                help_lines.append(line_fmt.format(base_line))
            help_header = res.get("messages.commands_list_head")
            help_msg = '\n'.join(help_lines)
            await message.channel.send(f'{help_header}\n{help_msg}\n')
            return

        if cmd_name not in control_hooks:
            await message.channel.send(res.get("messages.unknown_command"))
            return

        if self.awaiting_sync():
            await self.send_warning('Awaiting role syncronization')

        hook = get_module_element(control_hooks[cmd_name])
        check_coroutine(hook)
        await hook(self, message, prefix, argv)

    @after_initialized
    @event_config("message.edit")
    async def on_raw_message_edit(self,
                                  payload: discord.RawMessageUpdateEvent):
        """
            Async message edit event handler

            Saves event in database
        """
        if self.is_special_channel_id(payload.channel_id):
            return
        # ingore absent
        msg = self.s_events.get_message(payload.message_id)
        if msg is None:
            return
        # Sync code part
        async with self.sync():
            self.s_events.create_message_edit_event(msg)
            # Update stats
            inc_value = self.s_stats.get(msg.user, 'edit_message_count') + 1
            self.s_stats.set(msg.user, 'edit_message_count', inc_value)
            # Update user rank
            if self.s_users.is_absent(msg.user):
                return
            member = await self.guild.fetch_member(msg.user.did)
            await self.update_user_rank(member)

    @after_initialized
    @event_config("message.delete")
    async def on_raw_message_delete(self,
                                    payload: discord.RawMessageDeleteEvent):
        """
            Async message delete event handler

            Saves event in database
        """
        if self.is_special_channel_id(payload.channel_id):
            return
        # ingore absent
        msg = self.s_events.get_message(payload.message_id)
        if msg is None:
            return
        # Sync code part
        async with self.sync():
            self.s_events.create_message_delete_event(msg)
            # Update stats
            inc_value = self.s_stats.get(msg.user, 'delete_message_count') + 1
            self.s_stats.set(msg.user, 'delete_message_count', inc_value)
            # Update user rank
            if self.s_users.is_absent(msg.user):
                return
            member = await self.guild.fetch_member(msg.user.did)
            await self.update_user_rank(member)

    @after_initialized
    @event_config("user.join")
    @skip_bots
    @guild_member_event
    async def on_member_join(self, member: discord.Member):
        """
            Async member join event handler

            Saves user in database
        """
        if self.awaiting_sync():
            return
        # Sync code part
        async with self.sync():
            # Add/update user
            user = self.s_users.update_member(member)
            # Add event
            self.s_events.create_member_join_event(user, member)

    @after_initialized
    @event_config("user.update")
    @skip_bots
    @guild_member_event
    async def on_member_update(self, before: discord.Member,
                               after: discord.Member):
        """
            Async member remove event handler

            Removes user from database (or keep it, depends on config)
        """
        if self.awaiting_sync():
            return
        # track only role/nickname change
        if not (before.roles != after.roles or \
                before.display_name != after.display_name or \
                before.name != after.name or \
                before.discriminator != after.discriminator):
            return
        # Skip absent
        if self.s_users.get(before) is None:
            log.warn(
                f'{qualified_name(after)} does not exist in db! Skipping user update event!'
            )
            return
        # Sync code part
        async with self.sync():
            # Update user
            self.s_users.update_member(after)

    @after_initialized
    @event_config("user.leave")
    @skip_bots
    @guild_member_event
    async def on_member_remove(self, member: discord.Member):
        """
            Async member remove event handler

            Removes user from database (or keep it, depends on config)
        """
        # Sync code part
        async with self.sync():
            if self.config["user.leave.keep"]:
                user = self.s_users.mark_absent(member)
                if user is None:
                    log.warn(
                        f'{qualified_name(member)} does not exist in db! Skipping user leave event!'
                    )
                    return
                self.s_events.create_user_leave_event(user)
            else:
                user = self.s_users.remove(member)
                if user is None:
                    log.warn(
                        f'{qualified_name(member)} does not exist in db! Skipping user leave event!'
                    )
                    return

    @after_initialized
    @skip_bots
    @guild_member_event
    async def on_voice_state_update(self, user: discord.Member,
                                    before: discord.VoiceState,
                                    after: discord.VoiceState):
        """
            Async vc state change event handler

            Saves event in database
        """
        if before.channel == after.channel:
            return
        if before.channel is not None and self.check_afk_state(before):
            await self.on_vc_leave(user, before.channel)
        if after.channel is not None and self.check_afk_state(after):
            await self.on_vc_join(user, after.channel)

    @event_config("voice.join")
    async def on_vc_join(self, member: discord.Member,
                         channel: discord.VoiceChannel):
        """
            Async vc join event handler

            Saves event in database
        """
        # Sync code part
        async with self.sync():
            user = self.s_users.get(member)
            # Skip non-existing users
            if user is None:
                log.warn(
                    f'{qualified_name(member)} does not exist in db! Skipping vc join event!'
                )
                return
            # Apply constraints
            self.s_events.repair_vc_leave_event(user, channel)
            # Save event
            self.s_events.create_vc_join_event(user, channel)

    @event_config("voice.leave")
    async def on_vc_leave(self, member: discord.Member,
                          channel: discord.VoiceChannel):
        """
            Async vc join event handler

            Saves event in database
        """
        # Sync code part
        async with self.sync():
            user = self.s_users.get(member)
            # Skip non-existing users
            if user is None:
                log.warn(
                    f'{qualified_name(member)} does not exist in db! Skipping vc leave event!'
                )
                return
            # Close event
            join_event = self.s_events.close_vc_join_event(user, channel)
            if join_event is None:
                return
            # Update stats
            stat_val = self.s_stats.get(user, 'vc_time')
            stat_val += (join_event.updated_at -
                         join_event.created_at).total_seconds()
            self.s_stats.set(user, 'vc_time', stat_val)
            # Update user rank
            await self.update_user_rank(member)

    async def on_guild_role_create(self, role: discord.Role):
        if self.awaiting_sync():
            return
        self.set_awaiting_sync()
        await self.send_warning(
            'New role detected. Awaiting role syncronization.')

    async def on_guild_role_delete(self, role: discord.Role):
        if self.awaiting_sync():
            return
        self.set_awaiting_sync()
        await self.send_warning(
            'Role remove detected. Awaiting role syncronization.')

    async def on_guild_role_update(self, before: discord.Role,
                                   after: discord.Role):
        if self.awaiting_sync():
            return
        self.set_awaiting_sync()
        await self.send_warning(
            'Role change detected. Awaiting role syncronization.')