Example #1
0
class ConfigManager:

    public_keys = {
        # activity
        "twitch_url": "https://www.twitch.tv/fxcommunityevents/",
        # bot settings
        "main_category_id": None,
        "fallback_category_id": None,
        "prefix": "?",
        "mention": "@here",
        "main_color": str(discord.Color.blurple()),
        "error_color": str(discord.Color.red()),
        "user_typing": False,
        "mod_typing": False,
        "account_age": isodate.Duration(),
        "guild_age": isodate.Duration(),
        "thread_cooldown": isodate.Duration(),
        "reply_without_command": False,
        "anon_reply_without_command": False,
        # logging
        "log_channel_id": None,
        # threads
        "sent_emoji": "✅",
        "blocked_emoji": "🚫",
        "close_emoji": "🔒",
        "recipient_thread_close": False,
        "thread_auto_close_silently": False,
        "thread_auto_close": isodate.Duration(),
        "thread_auto_close_response": "Este hilo se ha cerrado automáticamente debido a la inactividad después de {timeout}.",
        "thread_creation_response": "El **STAFF** se comunicará con usted lo antes posible. Mientras tanto escriba cual es el problema.",
        "thread_creation_footer": "Tu mensaje ha sido enviado.",
        "thread_self_closable_creation_footer": "Haga clic en el candado para cerrar el hilo.",
        "thread_creation_title": "Bienvenido al Soporte Fx",
        "thread_close_footer": "Fx Community",
        "thread_close_title": "Espero haber solucionado el problema",
        "thread_close_response": "{closer.mention} cerro este hilo de Soporte.",
        "thread_self_close_response": "Si tienes cualquier otra consulta, no dudes en escribirnos",
        "thread_move_notify": False,
        "thread_move_response": "Este hilo ha sido movido.",
        "disabled_new_thread_title": "No entregado",
        "disabled_new_thread_response": "No estamos aceptando nuevos soportes.",
        "disabled_new_thread_footer": "Por favor, inténtelo de nuevo más tarde...",
        "disabled_current_thread_title": "No entregado",
        "disabled_current_thread_response": "No aceptamos ningún mensaje.",
        "disabled_current_thread_footer": "Por favor, inténtelo de nuevo más tarde...",
        # moderation
        "recipient_color": str(discord.Color.gold()),
        "mod_color": str(discord.Color.green()),
        "mod_tag": None,
        # anonymous message
        "anon_username": Fx Community,
        "anon_avatar_url": None,
        "anon_tag": "STAFF",
    }
Example #2
0
    async def get_thread_cooldown(self, author: discord.Member):
        thread_cooldown = self.config.get("thread_cooldown")
        now = datetime.utcnow()

        if thread_cooldown == isodate.Duration():
            return

        last_log = await self.api.get_latest_user_logs(author.id)

        if last_log is None:
            logger.debug("Last thread wasn't found, %s.", author.name)
            return

        last_log_closed_at = last_log.get("closed_at")

        if not last_log_closed_at:
            logger.debug("Last thread was not closed, %s.", author.name)
            return

        try:
            cooldown = datetime.fromisoformat(last_log_closed_at) + thread_cooldown
        except ValueError:
            logger.warning("Error with 'thread_cooldown'.", exc_info=True)
            cooldown = datetime.fromisoformat(last_log_closed_at) + self.config.remove(
                "thread_cooldown"
            )

        if cooldown > now:
            # User messaged before thread cooldown ended
            delta = human_timedelta(cooldown)
            logger.debug("Blocked due to thread cooldown, user %s.", author.name)
            return delta
        return
Example #3
0
    async def _restart_close_timer(self):
        """
        This will create or restart a timer to automatically close this
        thread.
        """
        timeout = self.bot.config.get("thread_auto_close")

        # Exit if timeout was not set
        if timeout == isodate.Duration():
            return

        # Set timeout seconds
        seconds = timeout.total_seconds()
        # seconds = 20  # Uncomment to debug with just 20 seconds
        reset_time = datetime.utcnow() + timedelta(seconds=seconds)
        human_time = human_timedelta(dt=reset_time)

        if self.bot.config.get("thread_auto_close_silently"):
            return await self.close(closer=self.bot.user, silent=True, after=int(seconds), auto_close=True)

        # Grab message
        close_message = self.bot.formatter.format(
            self.bot.config["thread_auto_close_response"], timeout=human_time
        )

        time_marker_regex = "%t"
        if len(re.findall(time_marker_regex, close_message)) == 1:
            close_message = re.sub(time_marker_regex, str(human_time), close_message)
        elif len(re.findall(time_marker_regex, close_message)) > 1:
            logger.warning(
                "The thread_auto_close_response should only contain one '%s' to specify time.",
                time_marker_regex,
            )

        await self.close(closer=self.bot.user, after=int(seconds), message=close_message, auto_close=True)
Example #4
0
    def test_add_duration(self):
        dt = datetime.datetime(2012, 10, 31)
        dr = isodate.Duration(months=1)

        result = dateutils.add_interval_to_datetime(dr, dt)
        self.assertEqual(result.month, 11)
        self.assertEqual(result.day, 30)
Example #5
0
def timedelta2duration(timedelta):
    components = {
        "days": getattr(timedelta, "days", 0),
        "hours": 0,
        "minutes": 0,
        "seconds": getattr(timedelta, "seconds", 0),
    }
    if components["seconds"]:
        components["hours"], components["minutes"], components["seconds"] = [
            int(i) for i in str(datetime.timedelta(seconds=components["seconds"])).split(":")
        ]
    return isodate.Duration(**components)
Example #6
0
    def _new_queue(self, queue: str, **kwargs) -> SendReceive:
        """Ensure a queue exists in ServiceBus."""
        queue = self.entity_name(self.queue_name_prefix + queue)

        try:
            return self._queue_cache[queue]
        except KeyError:
            # Converts seconds into ISO8601 duration format ie 66seconds = P1M6S
            lock_duration = isodate.duration_isoformat(
                isodate.Duration(seconds=self.peek_lock_seconds))
            try:
                self.queue_mgmt_service.create_queue(
                    queue_name=queue, lock_duration=lock_duration)
            except azure.core.exceptions.ResourceExistsError:
                pass
            return self._add_queue_to_cache(queue)
Example #7
0
    async def get_thread_cooldown(self, author: discord.Member):
        thread_cooldown = self.config.get("thread_cooldown")
        now = datetime.utcnow()

        if thread_cooldown == isodate.Duration():
            return

        last_log = await self.api.get_latest_user_logs(author.id)

        if last_log is None:
            logger.debug("L'ultima stanza non è stata trovata, %s.",
                         author.name)
            return

        last_log_closed_at = last_log.get("closed_at")

        if not last_log_closed_at:
            logger.debug("L'ultima stanza non è stata chiusa., %s.",
                         author.name)
            return

        try:
            cooldown = datetime.fromisoformat(
                last_log_closed_at) + thread_cooldown
        except ValueError:
            logger.warning("Errore con la configurazione 'thread_cooldown'.",
                           exc_info=True)
            cooldown = datetime.fromisoformat(
                last_log_closed_at) + self.config.remove("thread_cooldown")

        if cooldown > now:
            # User messaged before thread cooldown ended
            delta = human_timedelta(cooldown)
            logger.debug(
                "L'utente %s è stato bloccato per il cooldown della creazione di thread.",
                author.name,
            )
            return delta
        return
Example #8
0
def parse_ISO8601(time_gap):
    """
    P1D to (1, ("DAYS", isodate.Duration(days=1)).
    P1Y to (1, ("YEARS", isodate.Duration(years=1)).
    :param time_gap: ISO8601 string.
    :return: tuple with quantity and unit of time.
    """
    matcher = None

    if time_gap.count("T"):
        units = {
            "H": ("HOURS", isodate.Duration(hours=1)),
            "M": ("MINUTES", isodate.Duration(minutes=1)),
            "S": ("SECONDS", isodate.Duration(seconds=1))
        }
        matcher = re.search("PT(\d+)([HMS])", time_gap)
        if matcher:
            quantity = int(matcher.group(1))
            unit = matcher.group(2)
            return quantity, units.get(unit)
        else:
            raise Exception("Does not match the pattern: {}".format(time_gap))
    else:
        units = {
            "Y": ("YEARS", isodate.Duration(years=1)),
            "M": ("MONTHS", isodate.Duration(months=1)),
            "W": ("WEEKS", isodate.Duration(weeks=1)),
            "D": ("DAYS", isodate.Duration(days=1))
        }
        matcher = re.search("P(\d+)([YMWD])", time_gap)
        if matcher:
            quantity = int(matcher.group(1))
            unit = matcher.group(2)
        else:
            raise Exception("Does not match the pattern: {}".format(time_gap))

    return quantity, units.get(unit)
Example #9
0
class ConfigManager:

    public_keys = {
        # activity
        "twitch_url": "https://www.twitch.tv/discordmodmail/",
        # bot settings
        "main_category_id": None,
        "fallback_category_id": None,
        "prefix": "k",
        "mention": "@here",
        "main_color": str(discord.Color.blurple()),
        "error_color": str(discord.Color.red()),
        "user_typing": False,
        "mod_typing": False,
        "account_age": isodate.Duration(),
        "guild_age": isodate.Duration(),
        "thread_cooldown": isodate.Duration(),
        "reply_without_command": False,
        "anon_reply_without_command": False,
        # logging
        "log_channel_id": None,
        # threads
        "sent_emoji": "✅",
        "blocked_emoji": "🚫",
        "close_emoji": "🔒",
        "recipient_thread_close": False,
        "thread_auto_close_silently": False,
        "thread_auto_close": isodate.Duration(),
        "thread_auto_close_response":
        "This thread has been closed automatically due to inactivity after {timeout}.",
        "thread_creation_response":
        "The staff team will get back to you as soon as possible.",
        "thread_creation_footer": "Your message has been sent",
        "thread_self_closable_creation_footer":
        "Click the lock to close the thread",
        "thread_creation_title": "Thread Created",
        "thread_close_footer": "Replying will create a new thread",
        "thread_close_title": "Thread Closed",
        "thread_close_response":
        "{closer.mention} has closed this Modmail thread.",
        "thread_self_close_response": "You have closed this Modmail thread.",
        "thread_move_notify": False,
        "thread_move_response": "This thread has been moved.",
        "disabled_new_thread_title": "Not Delivered",
        "disabled_new_thread_response": "We are not accepting new threads.",
        "disabled_new_thread_footer": "Please try again later...",
        "disabled_current_thread_title": "Not Delivered",
        "disabled_current_thread_response":
        "We are not accepting any messages.",
        "disabled_current_thread_footer": "Please try again later...",
        # moderation
        "recipient_color": str(discord.Color.gold()),
        "mod_color": str(discord.Color.green()),
        "mod_tag": None,
        # anonymous message
        "anon_username": None,
        "anon_avatar_url": None,
        "anon_tag": "Response",
    }

    private_keys = {
        # bot presence
        "activity_message": "",
        "activity_type": None,
        "status": None,
        # dm_disabled 0 = none, 1 = new threads, 2 = all threads
        # TODO: use enum
        "dm_disabled": 0,
        "oauth_whitelist": [],
        # moderation
        "blocked": {},
        "blocked_whitelist": [],
        "command_permissions": {},
        "level_permissions": {},
        "override_command_level": {},
        # threads
        "snippets": {},
        "notification_squad": {},
        "subscriptions": {},
        "closures": {},
        # misc
        "plugins": [],
        "aliases": {},
    }

    protected_keys = {
        # Modmail
        "modmail_guild_id": None,
        "guild_id": None,
        "log_url": "https://example.com/",
        "log_url_prefix": "/logs",
        "mongo_uri": None,
        "database_type": "mongodb",
        "connection_uri": None,  # replace mongo uri in the future
        "owners": None,
        # bot
        "token": None,
        "enable_plugins": True,
        "enable_eval": True,
        # github access token for private repositories
        "github_token": None,
        # Logging
        "log_level": "INFO",
    }

    colors = {"mod_color", "recipient_color", "main_color", "error_color"}

    time_deltas = {
        "account_age", "guild_age", "thread_auto_close", "thread_cooldown"
    }

    booleans = {
        "user_typing",
        "mod_typing",
        "reply_without_command",
        "anon_reply_without_command",
        "recipient_thread_close",
        "thread_auto_close_silently",
        "thread_move_notify",
        "enable_plugins",
        "enable_eval",
    }

    special_types = {"status", "activity_type"}

    defaults = {**public_keys, **private_keys, **protected_keys}
    all_keys = set(defaults.keys())

    def __init__(self, bot):
        self.bot = bot
        self._cache = {}
        self.ready_event = asyncio.Event()
        self.config_help = {}

    def __repr__(self):
        return repr(self._cache)

    def populate_cache(self) -> dict:
        data = deepcopy(self.defaults)

        # populate from env var and .env file
        data.update({
            k.lower(): v
            for k, v in os.environ.items() if k.lower() in self.all_keys
        })
        config_json = os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
            "config.json")
        if os.path.exists(config_json):
            logger.debug("Loading envs from config.json.")
            with open(config_json, "r", encoding="utf-8") as f:
                # Config json should override env vars
                try:
                    data.update({
                        k.lower(): v
                        for k, v in json.load(f).items()
                        if k.lower() in self.all_keys
                    })
                except json.JSONDecodeError:
                    logger.critical("Failed to load config.json env values.",
                                    exc_info=True)
        self._cache = data

        config_help_json = os.path.join(
            os.path.dirname(os.path.abspath(__file__)), "config_help.json")
        with open(config_help_json, "r", encoding="utf-8") as f:
            self.config_help = dict(sorted(json.load(f).items()))

        return self._cache

    async def update(self):
        """Updates the config with data from the cache"""
        await self.bot.api.update_config(self.filter_default(self._cache))

    async def refresh(self) -> dict:
        """Refreshes internal cache with data from database"""
        for k, v in (await self.bot.api.get_config()).items():
            k = k.lower()
            if k in self.all_keys:
                self._cache[k] = v
        if not self.ready_event.is_set():
            self.ready_event.set()
            logger.debug("Successfully fetched configurations from database.")
        return self._cache

    async def wait_until_ready(self) -> None:
        await self.ready_event.wait()

    def __setitem__(self, key: str, item: typing.Any) -> None:
        key = key.lower()
        logger.info("Setting %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        self._cache[key] = item

    def __getitem__(self, key: str) -> typing.Any:
        key = key.lower()
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key not in self._cache:
            self._cache[key] = deepcopy(self.defaults[key])
        return self._cache[key]

    def __delitem__(self, key: str) -> None:
        return self.remove(key)

    def get(self, key: str, convert=True) -> typing.Any:
        value = self.__getitem__(key)

        if not convert:
            return value

        if key in self.colors:
            try:
                return int(value.lstrip("#"), base=16)
            except ValueError:
                logger.error("Invalid %s provided.", key)
            value = int(self.remove(key).lstrip("#"), base=16)

        elif key in self.time_deltas:
            if not isinstance(value, isodate.Duration):
                try:
                    value = isodate.parse_duration(value)
                except isodate.ISO8601Error:
                    logger.warning(
                        "The {account} age limit needs to be a "
                        'ISO-8601 duration formatted duration, not "%s".',
                        value,
                    )
                    value = self.remove(key)

        elif key in self.booleans:
            try:
                value = strtobool(value)
            except ValueError:
                value = self.remove(key)

        elif key in self.special_types:
            if value is None:
                return None

            if key == "status":
                try:
                    # noinspection PyArgumentList
                    value = discord.Status(value)
                except ValueError:
                    logger.warning("Invalid status %s.", value)
                    value = self.remove(key)

            elif key == "activity_type":
                try:
                    # noinspection PyArgumentList
                    value = discord.ActivityType(value)
                except ValueError:
                    logger.warning("Invalid activity %s.", value)
                    value = self.remove(key)

        return value

    def set(self, key: str, item: typing.Any, convert=True) -> None:
        if not convert:
            return self.__setitem__(key, item)

        if key in self.colors:
            try:
                hex_ = str(item)
                if hex_.startswith("#"):
                    hex_ = hex_[1:]
                if len(hex_) == 3:
                    hex_ = "".join(s for s in hex_ for _ in range(2))
                if len(hex_) != 6:
                    raise InvalidConfigError("Invalid color name or hex.")
                try:
                    int(hex_, 16)
                except ValueError:
                    raise InvalidConfigError("Invalid color name or hex.")

            except InvalidConfigError:
                name = str(item).lower()
                name = re.sub(r"[\-+|. ]+", " ", name)
                hex_ = ALL_COLORS.get(name)
                if hex_ is None:
                    name = re.sub(r"[\-+|. ]+", "", name)
                    hex_ = ALL_COLORS.get(name)
                    if hex_ is None:
                        raise
            return self.__setitem__(key, "#" + hex_)

        if key in self.time_deltas:
            try:
                isodate.parse_duration(item)
            except isodate.ISO8601Error:
                try:
                    converter = UserFriendlyTimeSync()
                    time = converter.convert(None, item)
                    if time.arg:
                        raise ValueError
                except BadArgument as exc:
                    raise InvalidConfigError(*exc.args)
                except Exception as e:
                    logger.debug(e)
                    raise InvalidConfigError(
                        "Unrecognized time, please use ISO-8601 duration format "
                        'string or a simpler "human readable" time.')
                item = isodate.duration_isoformat(time.dt - converter.now)
            return self.__setitem__(key, item)

        if key in self.booleans:
            try:
                return self.__setitem__(key, strtobool(item))
            except ValueError:
                raise InvalidConfigError("Must be a yes/no value.")

        # elif key in self.special_types:
        #     if key == "status":

        return self.__setitem__(key, item)

    def remove(self, key: str) -> typing.Any:
        key = key.lower()
        logger.info("Removing %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key in self._cache:
            del self._cache[key]
        self._cache[key] = deepcopy(self.defaults[key])
        return self._cache[key]

    def items(self) -> typing.Iterable:
        return self._cache.items()

    @classmethod
    def filter_valid(
            cls,
            data: typing.Dict[str,
                              typing.Any]) -> typing.Dict[str, typing.Any]:
        return {
            k.lower(): v
            for k, v in data.items()
            if k.lower() in cls.public_keys or k.lower() in cls.private_keys
        }

    @classmethod
    def filter_default(
            cls,
            data: typing.Dict[str,
                              typing.Any]) -> typing.Dict[str, typing.Any]:
        # TODO: use .get to prevent errors
        filtered = {}
        for k, v in data.items():
            default = cls.defaults.get(k.lower(), Default)
            if default is Default:
                logger.error("Unexpected configuration detected: %s.", k)
                continue
            if v != default:
                filtered[k.lower()] = v
        return filtered
Example #10
0
    async def _process_blocked(self, message: discord.Message) -> bool:
        sent_emoji, blocked_emoji = await self.retrieve_emoji()

        if str(message.author.id) in self.blocked_whitelisted_users:
            if str(message.author.id) in self.blocked_users:
                self.blocked_users.pop(str(message.author.id))
                await self.config.update()

            if sent_emoji != "disable":
                try:
                    await message.add_reaction(sent_emoji)
                except (discord.HTTPException, discord.InvalidArgument):
                    logger.warning("Failed to add sent_emoji.", exc_info=True)

            return False

        now = datetime.utcnow()

        account_age = self.config["account_age"]
        guild_age = self.config["guild_age"]

        if account_age is None:
            account_age = isodate.Duration()
        if guild_age is None:
            guild_age = isodate.Duration()

        if not isinstance(account_age, isodate.Duration):
            try:
                account_age = isodate.parse_duration(account_age)
            except isodate.ISO8601Error:
                logger.warning(
                    "The account age limit needs to be a "
                    "ISO-8601 duration formatted duration string "
                    'greater than 0 days, not "%s".',
                    str(account_age),
                )
                account_age = self.config.remove("account_age")

        if not isinstance(guild_age, isodate.Duration):
            try:
                guild_age = isodate.parse_duration(guild_age)
            except isodate.ISO8601Error:
                logger.warning(
                    "The guild join age limit needs to be a "
                    "ISO-8601 duration formatted duration string "
                    'greater than 0 days, not "%s".',
                    str(guild_age),
                )
                guild_age = self.config.remove("guild_age")

        reason = self.blocked_users.get(str(message.author.id)) or ""
        min_guild_age = min_account_age = now

        try:
            min_account_age = message.author.created_at + account_age
        except ValueError:
            logger.warning("Error with 'account_age'.", exc_info=True)
            self.config.remove("account_age")

        try:
            joined_at = getattr(message.author, "joined_at", None)
            if joined_at is not None:
                min_guild_age = joined_at + guild_age
        except ValueError:
            logger.warning("Error with 'guild_age'.", exc_info=True)
            self.config.remove("guild_age")

        if min_account_age > now:
            # User account has not reached the required time
            reaction = blocked_emoji
            changed = False
            delta = human_timedelta(min_account_age)
            logger.debug("Blocked due to account age, user %s.",
                         message.author.name)

            if str(message.author.id) not in self.blocked_users:
                new_reason = (
                    f"System Message: New Account. Required to wait for {delta}."
                )
                self.blocked_users[str(message.author.id)] = new_reason
                changed = True

            if reason.startswith("System Message: New Account.") or changed:
                await message.channel.send(embed=discord.Embed(
                    title="Message not sent!",
                    description=f"Your must wait for {delta} "
                    f"before you can contact me.",
                    color=discord.Color.red(),
                ))

        elif min_guild_age > now:
            # User has not stayed in the guild for long enough
            reaction = blocked_emoji
            changed = False
            delta = human_timedelta(min_guild_age)
            logger.debug("Blocked due to guild age, user %s.",
                         message.author.name)

            if str(message.author.id) not in self.blocked_users:
                new_reason = (
                    f"System Message: Recently Joined. Required to wait for {delta}."
                )
                self.blocked_users[str(message.author.id)] = new_reason
                changed = True

            if reason.startswith(
                    "System Message: Recently Joined.") or changed:
                await message.channel.send(embed=discord.Embed(
                    title="Message not sent!",
                    description=f"Your must wait for {delta} "
                    f"before you can contact me.",
                    color=discord.Color.red(),
                ))

        elif str(message.author.id) in self.blocked_users:
            if reason.startswith(
                    "System Message: New Account.") or reason.startswith(
                        "System Message: Recently Joined."):
                # Met the age limit already, otherwise it would've been caught by the previous if's
                reaction = sent_emoji
                logger.debug("No longer internally blocked, user %s.",
                             message.author.name)
                self.blocked_users.pop(str(message.author.id))
            else:
                reaction = blocked_emoji
                end_time = re.search(r"%(.+?)%$", reason)
                if end_time is not None:
                    logger.debug("No longer blocked, user %s.",
                                 message.author.name)
                    after = (datetime.fromisoformat(end_time.group(1)) -
                             now).total_seconds()
                    if after <= 0:
                        # No longer blocked
                        reaction = sent_emoji
                        self.blocked_users.pop(str(message.author.id))
                else:
                    logger.debug("User blocked, user %s.", message.author.name)
        else:
            reaction = sent_emoji

        await self.config.update()
        if reaction != "disable":
            try:
                await message.add_reaction(reaction)
            except (discord.HTTPException, discord.InvalidArgument):
                logger.warning("Failed to add reaction %s.",
                               reaction,
                               exc_info=True)
        return str(message.author.id) in self.blocked_users
Example #11
0
    async def _process_blocked(
        self, message: discord.Message
    ) -> typing.Tuple[bool, str]:
        sent_emoji, blocked_emoji = await self.retrieve_emoji()

        if str(message.author.id) in self.blocked_whitelisted_users:
            if str(message.author.id) in self.blocked_users:
                self.blocked_users.pop(str(message.author.id))
                await self.config.update()

            return False, sent_emoji

        now = datetime.utcnow()

        account_age = self.config.get("account_age")
        guild_age = self.config.get("guild_age")

        if account_age is None:
            account_age = isodate.Duration()
        if guild_age is None:
            guild_age = isodate.Duration()

        reason = self.blocked_users.get(str(message.author.id)) or ""
        min_guild_age = min_account_age = now

        try:
            min_account_age = message.author.created_at + account_age
        except ValueError:
            logger.warning("Error with 'account_age'.", exc_info=True)
            self.config.remove("account_age")

        try:
            joined_at = getattr(message.author, "joined_at", None)
            if joined_at is not None:
                min_guild_age = joined_at + guild_age
        except ValueError:
            logger.warning("Error with 'guild_age'.", exc_info=True)
            self.config.remove("guild_age")

        if min_account_age > now:
            # User account has not reached the required time
            reaction = blocked_emoji
            changed = False
            delta = human_timedelta(min_account_age)
            logger.debug("Blocked due to account age, user %s.", message.author.name)

            if str(message.author.id) not in self.blocked_users:
                new_reason = (
                    f"System Message: New Account. Required to wait for {delta}."
                )
                self.blocked_users[str(message.author.id)] = new_reason
                changed = True

            if reason.startswith("System Message: New Account.") or changed:
                await message.channel.send(
                    embed=discord.Embed(
                        title="Message not sent!",
                        description=f"Your must wait for {delta} "
                        f"before you can contact me.",
                        color=self.error_color,
                    )
                )

        elif min_guild_age > now:
            # User has not stayed in the guild for long enough
            reaction = blocked_emoji
            changed = False
            delta = human_timedelta(min_guild_age)
            logger.debug("Blocked due to guild age, user %s.", message.author.name)

            if str(message.author.id) not in self.blocked_users:
                new_reason = (
                    f"System Message: Recently Joined. Required to wait for {delta}."
                )
                self.blocked_users[str(message.author.id)] = new_reason
                changed = True

            if reason.startswith("System Message: Recently Joined.") or changed:
                await message.channel.send(
                    embed=discord.Embed(
                        title="Message not sent!",
                        description=f"Your must wait for {delta} "
                        f"before you can contact me.",
                        color=self.error_color,
                    )
                )

        elif str(message.author.id) in self.blocked_users:
            if reason.startswith("System Message: New Account.") or reason.startswith(
                "System Message: Recently Joined."
            ):
                # Met the age limit already, otherwise it would've been caught by the previous if's
                reaction = sent_emoji
                logger.debug(
                    "No longer internally blocked, user %s.", message.author.name
                )
                self.blocked_users.pop(str(message.author.id))
            else:
                reaction = blocked_emoji
                # etc "blah blah blah... until 2019-10-14T21:12:45.559948."
                end_time = re.search(r"until ([^`]+?)\.$", reason)
                if end_time is None:
                    # backwards compat
                    end_time = re.search(r"%([^%]+?)%", reason)
                    if end_time is not None:
                        logger.warning(
                            r"Deprecated time message for user %s, block and unblock again to update.",
                            message.author,
                        )

                if end_time is not None:
                    after = (
                        datetime.fromisoformat(end_time.group(1)) - now
                    ).total_seconds()
                    if after <= 0:
                        # No longer blocked
                        reaction = sent_emoji
                        self.blocked_users.pop(str(message.author.id))
                        logger.debug("No longer blocked, user %s.", message.author.name)
                    else:
                        logger.debug("User blocked, user %s.", message.author.name)
                else:
                    logger.debug("User blocked, user %s.", message.author.name)
        else:
            reaction = sent_emoji

        await self.config.update()
        return str(message.author.id) in self.blocked_users, reaction
Example #12
0
from __future__ import division
from __future__ import print_function
from __future__ import absolute_import
from __future__ import unicode_literals

import pytest
import datetime
import isodate
from tableschema import types
from tableschema.config import ERROR

# Tests


@pytest.mark.parametrize('format, value, result', [
    ('default', isodate.Duration(years=1), isodate.Duration(years=1)),
    ('default', 'P1Y10M3DT5H11M7S',
     isodate.Duration(
         years=1, months=10, days=3, hours=5, minutes=11, seconds=7)),
    ('default', 'P1Y', isodate.Duration(years=1)),
    ('default', 'P1M', isodate.Duration(months=1)),
    ('default', 'PT1S', datetime.timedelta(seconds=1)),
    ('default', datetime.timedelta(seconds=1), datetime.timedelta(seconds=1)),
    ('default', 'P1M1Y', ERROR),
    ('default', 'P-1Y', ERROR),
    ('default', 'year', ERROR),
    ('default', True, ERROR),
    ('default', False, ERROR),
    ('default', 1, ERROR),
    ('default', '', ERROR),
    ('default', [], ERROR),
Example #13
0
        ("PT6H", timedelta(hours=6)),
        ("P2DT1H", timedelta(hours=49)),
    ],
)
def test_duration_field_straightforward(duration_input, exp_deserialization):
    """Testing straightforward cases"""
    df = DurationField()
    deser = df.deserialize(duration_input, None, None)
    assert deser == exp_deserialization
    assert df.serialize("duration", {"duration": deser}) == duration_input


@pytest.mark.parametrize(
    "duration_input,exp_deserialization,grounded_timedelta",
    [
        ("P1M", isodate.Duration(months=1), timedelta(days=29)),
        ("PT24H", isodate.Duration(hours=24), timedelta(hours=24)),
        ("P2D", isodate.Duration(hours=48), timedelta(hours=48)),
        # following are calendar periods including a transition to daylight saving time (DST)
        ("P2M", isodate.Duration(months=2),
         timedelta(days=60) - timedelta(hours=1)),
        # ("P8W", isodate.Duration(days=7*8), timedelta(weeks=8) - timedelta(hours=1)),
        # ("P100D", isodate.Duration(days=100), timedelta(days=100) - timedelta(hours=1)),
        # following is a calendar period with transitions to DST and back again
        ("P1Y", isodate.Duration(years=1), timedelta(days=366)),
    ],
)
def test_duration_field_nominal_grounded(duration_input, exp_deserialization,
                                         grounded_timedelta):
    """Nominal durations are tricky:
    https://en.wikipedia.org/wiki/Talk:ISO_8601/Archive_2#Definition_of_Duration_is_incorrect
 def __init__(self, station_id, polling_interval = 30, stop_test_fraction = 2):
     self.station_id = station_id
     self.polling_interval = polling_interval
     self.continue_running = False
     self.stop_test_fraction = stop_test_fraction
     self.stop_event_request = StopEventRequest(LocationRef(stop_place=self.station_id), include_previous_calls=False, include_onward_calls=False, time_window=isodate.Duration(hours = 1), stop_event_type=StopEventType.DEPARTURE, pt_mode_filter=PtMode.RAIL, number_of_results=10)
     self.current_stop_events = []
     Session = sessionmaker(bind=engine)
     self.session = Session()
     self.wait_time = 30
Example #15
0
class ConfigManager:

    public_keys = {
        # activity
        "twitch_url": "https://www.twitch.tv/discordmodmail/",
        # bot settings
        "main_category_id": None,
        "fallback_category_id": None,
        "prefix": "-",
        "mention": "@here",
        "main_color": str(discord.Color.blurple()),
        "error_color": str(discord.Color.red()),
        "user_typing": False,
        "mod_typing": False,
        "account_age": isodate.Duration(),
        "guild_age": isodate.Duration(),
        "thread_cooldown": isodate.Duration(),
        "reply_without_command": False,
        "anon_reply_without_command": False,
        # logging
        "log_channel_id": None,
        # threads
        "sent_emoji": "✅",
        "blocked_emoji": "🚫",
        "close_emoji": "🔒",
        "recipient_thread_close": False,
        "thread_auto_close_silently": False,
        "thread_auto_close": isodate.Duration(),
        "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.",
        "thread_creation_response": "The staff team will get back to you as soon as possible.",
        "thread_creation_footer": "Your message has been sent",
        "thread_self_closable_creation_footer": "Click the lock to close the thread",
        "thread_creation_title": "Thread Created",
        "thread_close_footer": "Replying will create a new thread",
        "thread_close_title": "Thread Closed",
        "thread_close_response": "{closer.mention} has closed this Modmail thread.",
        "thread_self_close_response": "You have closed this Modmail thread.",
        "thread_move_notify": False,
        "thread_move_response": "This thread has been moved.",
        "disabled_new_thread_title": "Not Delivered",
        "disabled_new_thread_response": "We are not accepting new threads.",
        "disabled_new_thread_footer": "Please try again later...",
        "disabled_current_thread_title": "Not Delivered",
        "disabled_current_thread_response": "We are not accepting any messages.",
        "disabled_current_thread_footer": "Please try again later...",
        # moderation
        "recipient_color": str(discord.Color.gold()),
        "mod_color": str(discord.Color.green()),
        "mod_tag": None,
        # anonymous message
        "anon_username": None,
        "anon_avatar_url": None,
        "anon_tag": "Response",
    }

    private_keys = {
        # bot presence
        "activity_message": "AOC is god | Dm for support",
        "activity_type": Listening to,
        "status": AOC is God,
        # dm_disabled 0 = none, 1 = new threads, 2 = all threads
        # TODO: use enum
        "dm_disabled": 0,
        "oauth_whitelist": [],
        # moderation
        "blocked": {},
        "blocked_whitelist": [],
        "command_permissions": {},
        "level_permissions": {},
        "override_command_level": {},
        # threads
        "snippets": {},
        "notification_squad": {},
        "subscriptions": {},
        "closures": {},
        # misc
        "plugins": [],
        "aliases": {},
    }
Example #16
0
import pendulum

from k8s_snapshots import errors
from k8s_snapshots.rule import parse_deltas


@pytest.mark.parametrize(
    [
        'deltas',
        'expected_timedeltas',
    ],
    [
        pytest.param(
            'PT1M P1M',
            [
                isodate.Duration(minutes=1),
                isodate.Duration(months=1),
            ]
        ),
        pytest.param(
            'P7D P1D',
            [
                isodate.Duration(days=7),
                isodate.Duration(days=1),
            ]
        ),
        pytest.param(
            'PT1M PT7.5H P1M P5W P1Y',
            [
                isodate.Duration(minutes=1),
                isodate.Duration(hours=7.5),
Example #17
0
class ConfigManager:

    public_keys = {
        # activity
        "twitch_url": "https://www.twitch.tv/discordmodmail/",
        # bot settings
        "main_category_id": None,
        "fallback_category_id": None,
        "prefix": "?",
        "mention": "@here",
        "main_color": str(discord.Color.blurple()),
        "error_color": str(discord.Color.red()),
        "user_typing": False,
        "mod_typing": False,
        "account_age": isodate.Duration(),
        "guild_age": isodate.Duration(),
        "thread_cooldown": isodate.Duration(),
        "reply_without_command": False,
        "anon_reply_without_command": False,
        "plain_reply_without_command": False,
        # logging
        "log_channel_id": None,
        "mention_channel_id": None,
        "update_channel_id": None,
        # updates
        "update_notifications": True,
        # threads
        "sent_emoji": "\N{WHITE HEAVY CHECK MARK}",
        "blocked_emoji": "\N{NO ENTRY SIGN}",
        "close_emoji": "\N{LOCK}",
        "use_user_id_channel_name": False,
        "use_timestamp_channel_name": False,
        "recipient_thread_close": False,
        "thread_show_roles": True,
        "thread_show_account_age": True,
        "thread_show_join_age": True,
        "thread_cancelled": "Cancelled",
        "thread_auto_close_silently": False,
        "thread_auto_close": isodate.Duration(),
        "thread_auto_close_response": "This thread has been closed automatically due to inactivity after {timeout}.",
        "thread_creation_response": "The staff team will get back to you as soon as possible.",
        "thread_creation_footer": "Your message has been sent",
        "thread_contact_silently": False,
        "thread_self_closable_creation_footer": "Click the lock to close the thread",
        "thread_creation_contact_title": "New Thread",
        "thread_creation_self_contact_response": "You have opened a Modmail thread.",
        "thread_creation_contact_response": "{creator.name} has opened a Modmail thread.",
        "thread_creation_title": "Thread Created",
        "thread_close_footer": "Replying will create a new thread",
        "thread_close_title": "Thread Closed",
        "thread_close_response": "{closer.mention} has closed this Modmail thread.",
        "thread_self_close_response": "You have closed this Modmail thread.",
        "thread_move_title": "Thread Moved",
        "thread_move_notify": False,
        "thread_move_notify_mods": False,
        "thread_move_response": "This thread has been moved.",
        "cooldown_thread_title": "Message not sent!",
        "cooldown_thread_response": "You must wait for {delta} before you can contact me again.",
        "disabled_new_thread_title": "Not Delivered",
        "disabled_new_thread_response": "We are not accepting new threads.",
        "disabled_new_thread_footer": "Please try again later...",
        "disabled_current_thread_title": "Not Delivered",
        "disabled_current_thread_response": "We are not accepting any messages.",
        "disabled_current_thread_footer": "Please try again later...",
        "transfer_reactions": True,
        "close_on_leave": False,
        "close_on_leave_reason": "The recipient has left the server.",
        "alert_on_mention": False,
        "silent_alert_on_mention": False,
        "show_timestamp": True,
        "anonymous_snippets": False,
        # group conversations
        "private_added_to_group_title": "New Thread (Group)",
        "private_added_to_group_response": "{moderator.name} has added you to a Modmail thread.",
        "private_added_to_group_description_anon": "A moderator has added you to a Modmail thread.",
        "public_added_to_group_title": "New User",
        "public_added_to_group_response": "{moderator.name} has added {users} to the Modmail thread.",
        "public_added_to_group_description_anon": "A moderator has added {users} to the Modmail thread.",
        "private_removed_from_group_title": "Removed From Thread (Group)",
        "private_removed_from_group_response": "{moderator.name} has removed you from the Modmail thread.",
        "private_removed_from_group_description_anon": "A moderator has removed you from the Modmail thread.",
        "public_removed_from_group_title": "User Removed",
        "public_removed_from_group_response": "{moderator.name} has removed {users} from the Modmail thread.",
        "public_removed_from_group_description_anon": "A moderator has removed {users} from the Modmail thread.",
        # moderation
        "recipient_color": str(discord.Color.gold()),
        "mod_color": str(discord.Color.green()),
        "mod_tag": None,
        # anonymous message
        "anon_username": None,
        "anon_avatar_url": None,
        "anon_tag": "Response",
        # react to contact
        "react_to_contact_message": None,
        "react_to_contact_emoji": "\N{WHITE HEAVY CHECK MARK}",
        # confirm thread creation
        "confirm_thread_creation": False,
        "confirm_thread_creation_title": "Confirm thread creation",
        "confirm_thread_response": "React to confirm thread creation which will directly contact the moderators",
        "confirm_thread_creation_accept": "\N{WHITE HEAVY CHECK MARK}",
        "confirm_thread_creation_deny": "\N{NO ENTRY SIGN}",
        # regex
        "use_regex_autotrigger": False,
        "use_hoisted_top_role": True,
    }

    private_keys = {
        # bot presence
        "activity_message": "",
        "activity_type": None,
        "status": None,
        "dm_disabled": DMDisabled.NONE,
        "oauth_whitelist": [],
        # moderation
        "blocked": {},
        "blocked_roles": {},
        "blocked_whitelist": [],
        "command_permissions": {},
        "level_permissions": {},
        "override_command_level": {},
        # threads
        "snippets": {},
        "notification_squad": {},
        "subscriptions": {},
        "closures": {},
        # misc
        "plugins": [],
        "aliases": {},
        "auto_triggers": {},
    }

    protected_keys = {
        # Modmail
        "modmail_guild_id": None,
        "guild_id": None,
        "log_url": "https://example.com/",
        "log_url_prefix": "/logs",
        "mongo_uri": None,
        "database_type": "mongodb",
        "connection_uri": None,  # replace mongo uri in the future
        "mongo_db_name": "modmail_bot",
        "owners": None,
        # bot
        "token": None,
        "enable_plugins": True,
        "enable_eval": True,
        # github access token for private repositories
        "github_token": None,
        "disable_autoupdates": False,
        "disable_updates": False,
        # Logging
        "log_level": "INFO",
        # data collection
        "data_collection": True,
    }

    colors = {"mod_color", "recipient_color", "main_color", "error_color"}

    time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown"}

    booleans = {
        "use_user_id_channel_name",
        "use_timestamp_channel_name",
        "user_typing",
        "mod_typing",
        "reply_without_command",
        "anon_reply_without_command",
        "plain_reply_without_command",
        "recipient_thread_close",
        "thread_auto_close_silently",
        "thread_move_notify",
        "thread_move_notify_mods",
        "transfer_reactions",
        "close_on_leave",
        "alert_on_mention",
        "silent_alert_on_mention",
        "show_timestamp",
        "confirm_thread_creation",
        "use_regex_autotrigger",
        "enable_plugins",
        "data_collection",
        "enable_eval",
        "disable_autoupdates",
        "disable_updates",
        "update_notifications",
        "thread_contact_silently",
        "anonymous_snippets",
        "recipient_thread_close",
        "thread_show_roles",
        "thread_show_account_age",
        "thread_show_join_age",
        "use_hoisted_top_role",
    }

    enums = {
        "dm_disabled": DMDisabled,
        "status": discord.Status,
        "activity_type": discord.ActivityType,
    }

    force_str = {"command_permissions", "level_permissions"}

    defaults = {**public_keys, **private_keys, **protected_keys}
    all_keys = set(defaults.keys())

    def __init__(self, bot):
        self.bot = bot
        self._cache = {}
        self.ready_event = asyncio.Event()
        self.config_help = {}

    def __repr__(self):
        return repr(self._cache)

    def populate_cache(self) -> dict:
        data = deepcopy(self.defaults)

        # populate from env var and .env file
        data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys})
        config_json = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
        if os.path.exists(config_json):
            logger.debug("Loading envs from config.json.")
            with open(config_json, "r", encoding="utf-8") as f:
                # Config json should override env vars
                try:
                    data.update({k.lower(): v for k, v in json.load(f).items() if k.lower() in self.all_keys})
                except json.JSONDecodeError:
                    logger.critical("Failed to load config.json env values.", exc_info=True)
        self._cache = data

        config_help_json = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config_help.json")
        with open(config_help_json, "r", encoding="utf-8") as f:
            self.config_help = dict(sorted(json.load(f).items()))

        return self._cache

    async def update(self):
        """Updates the config with data from the cache"""
        await self.bot.api.update_config(self.filter_default(self._cache))

    async def refresh(self) -> dict:
        """Refreshes internal cache with data from database"""
        for k, v in (await self.bot.api.get_config()).items():
            k = k.lower()
            if k in self.all_keys:
                self._cache[k] = v
        if not self.ready_event.is_set():
            self.ready_event.set()
            logger.debug("Successfully fetched configurations from database.")
        return self._cache

    async def wait_until_ready(self) -> None:
        await self.ready_event.wait()

    def __setitem__(self, key: str, item: typing.Any) -> None:
        key = key.lower()
        logger.info("Setting %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        self._cache[key] = item

    def __getitem__(self, key: str) -> typing.Any:
        # make use of the custom methods in func:get:
        return self.get(key)

    def __delitem__(self, key: str) -> None:
        return self.remove(key)

    def get(self, key: str, convert=True) -> typing.Any:
        key = key.lower()
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key not in self._cache:
            self._cache[key] = deepcopy(self.defaults[key])
        value = self._cache[key]

        if not convert:
            return value

        if key in self.colors:
            try:
                return int(value.lstrip("#"), base=16)
            except ValueError:
                logger.error("Invalid %s provided.", key)
            value = int(self.remove(key).lstrip("#"), base=16)

        elif key in self.time_deltas:
            if not isinstance(value, isodate.Duration):
                try:
                    value = isodate.parse_duration(value)
                except isodate.ISO8601Error:
                    logger.warning(
                        "The {account} age limit needs to be a "
                        'ISO-8601 duration formatted duration, not "%s".',
                        value,
                    )
                    value = self.remove(key)

        elif key in self.booleans:
            try:
                value = strtobool(value)
            except ValueError:
                value = self.remove(key)

        elif key in self.enums:
            if value is None:
                return None
            try:
                value = self.enums[key](value)
            except ValueError:
                logger.warning("Invalid %s %s.", key, value)
                value = self.remove(key)

        elif key in self.force_str:
            # Temporary: as we saved in int previously, leading to int32 overflow,
            #            this is transitioning IDs to strings
            new_value = {}
            changed = False
            for k, v in value.items():
                new_v = v
                if isinstance(v, list):
                    new_v = []
                    for n in v:
                        if n != -1 and not isinstance(n, str):
                            changed = True
                            n = str(n)
                        new_v.append(n)
                new_value[k] = new_v

            if changed:
                # transition the database as well
                self.set(key, new_value)

            value = new_value

        return value

    def set(self, key: str, item: typing.Any, convert=True) -> None:
        if not convert:
            return self.__setitem__(key, item)

        if key in self.colors:
            try:
                hex_ = str(item)
                if hex_.startswith("#"):
                    hex_ = hex_[1:]
                if len(hex_) == 3:
                    hex_ = "".join(s for s in hex_ for _ in range(2))
                if len(hex_) != 6:
                    raise InvalidConfigError("Invalid color name or hex.")
                try:
                    int(hex_, 16)
                except ValueError:
                    raise InvalidConfigError("Invalid color name or hex.")

            except InvalidConfigError:
                name = str(item).lower()
                name = re.sub(r"[\-+|. ]+", " ", name)
                hex_ = ALL_COLORS.get(name)
                if hex_ is None:
                    name = re.sub(r"[\-+|. ]+", "", name)
                    hex_ = ALL_COLORS.get(name)
                    if hex_ is None:
                        raise
            return self.__setitem__(key, "#" + hex_)

        if key in self.time_deltas:
            try:
                isodate.parse_duration(item)
            except isodate.ISO8601Error:
                try:
                    converter = UserFriendlyTimeSync()
                    time = converter.convert(None, item)
                    if time.arg:
                        raise ValueError
                except BadArgument as exc:
                    raise InvalidConfigError(*exc.args)
                except Exception as e:
                    logger.debug(e)
                    raise InvalidConfigError(
                        "Unrecognized time, please use ISO-8601 duration format "
                        'string or a simpler "human readable" time.'
                    )
                item = isodate.duration_isoformat(time.dt - converter.now)
            return self.__setitem__(key, item)

        if key in self.booleans:
            try:
                return self.__setitem__(key, strtobool(item))
            except ValueError:
                raise InvalidConfigError("Must be a yes/no value.")

        elif key in self.enums:
            if isinstance(item, self.enums[key]):
                # value is an enum type
                item = item.value

        return self.__setitem__(key, item)

    def remove(self, key: str) -> typing.Any:
        key = key.lower()
        logger.info("Removing %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key in self._cache:
            del self._cache[key]
        self._cache[key] = deepcopy(self.defaults[key])
        return self._cache[key]

    def items(self) -> typing.Iterable:
        return self._cache.items()

    @classmethod
    def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
        return {
            k.lower(): v
            for k, v in data.items()
            if k.lower() in cls.public_keys or k.lower() in cls.private_keys
        }

    @classmethod
    def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
        # TODO: use .get to prevent errors
        filtered = {}
        for k, v in data.items():
            default = cls.defaults.get(k.lower(), Default)
            if default is Default:
                logger.error("Unexpected configuration detected: %s.", k)
                continue
            if v != default:
                filtered[k.lower()] = v
        return filtered
Example #18
0
class ConfigManager:

    public_keys = {
        # activity
        "twitch_url": "https://www.twitch.tv/discordmodmail/",
        # bot settings
        "main_category_id": None,
        "fallback_category_id": None,
        "prefix": "?",
        "mention": "@here",
        "main_color": str(discord.Color.blurple()),
        "error_color": str(discord.Color.red()),
        "user_typing": False,
        "mod_typing": False,
        "account_age": isodate.Duration(),
        "guild_age": isodate.Duration(),
        "thread_cooldown": isodate.Duration(),
        "reply_without_command": False,
        "anon_reply_without_command": False,
        "plain_reply_without_command": False,
        # logging
        "log_channel_id": None,
        "mention_channel_id": None,
        "update_channel_id": None,
        # updates
        "update_notifications": True,
        # threads
        "sent_emoji": "✅",
        "blocked_emoji": "🚫",
        "close_emoji": "🔒",
        "recipient_thread_close": True,
        "thread_auto_close_silently": False,
        "thread_auto_close": isodate.Duration(),
        "thread_auto_close_response":
        "Este ticket foi fechado devido a inatividade...",
        "thread_creation_response":
        "Obrigado por ter enviado uma mensagem para obter ajuda via suporte! Aguarde breves momentos. Será ajudado com brevidade!",
        "thread_creation_footer": "A sua mensagem foi enviada",
        "thread_contact_silently": False,
        "thread_self_closable_creation_footer":
        "Clique no cadeado para fechar o ticket",
        "thread_creation_title": "Ticket criado",
        "thread_close_footer":
        "Responder a esta mensagem criará um novo ticket",
        "thread_close_title": "Ticket criado fechado",
        "thread_close_response":
        "{closer.mention} fechou este ticket. Obrigado por contactar o suporte da nossa rede. Sinta-se à vontade para criar um novo ticket qualquer momento.",
        "thread_self_close_response":
        "{closer.mention} fechou este ticket. Obrigado por contactar o suporte da nossa rede. Sinta-se à vontade para criar um novo ticket a qualquer momento.",
        "thread_move_title": "Ticket movido",
        "thread_move_notify": False,
        "thread_move_notify_mods": False,
        "thread_move_response":
        "Este ticket foi movida para análise futura dos membros da administração. ***Isto pode criar uma variação do tempo de espera.***",
        "cooldown_thread_title": "Mensagem não enviada!",
        "cooldown_thread_response":
        "Você deve esperar por {delta} antes de poder contactar-nos outra vez.",
        "disabled_new_thread_title": "Não enviado",
        "disabled_new_thread_response": "Não estamos aceitando novos tickets",
        "disabled_new_thread_footer": "Por favor tente mais tarde...",
        "disabled_current_thread_title": "Não enviado",
        "disabled_current_thread_response":
        "Não estamos aceitandos novas mensagens.",
        "disabled_current_thread_footer": "Por favor tente mais tarde...",
        "transfer_reactions": True,
        "close_on_leave": False,
        "close_on_leave_reason": "O recipiente saiu do servidor.",
        "alert_on_mention": False,
        "silent_alert_on_mention": False,
        "show_timestamp": True,
        "anonymous_snippets": False,
        # moderation
        "recipient_color": str(discord.Color.gold()),
        "mod_color": str(discord.Color.red()),
        "mod_tag": None,
        # anonymous message
        "anon_username": None,
        "anon_avatar_url": None,
        "anon_tag": "Response",
        # react to contact
        "react_to_contact_message": None,
        "react_to_contact_emoji": "\u2705",
        # confirm thread creation
        "confirm_thread_creation": False,
        "confirm_thread_creation_title":
        "Confirme a criação de um novo ticket",
        "confirm_thread_response":
        "Reaja para confirmar a criação do novo ticket. Ao criar o ticket os moderadores serão contactados diretamente",
        "confirm_thread_creation_accept": "\u2705",
        "confirm_thread_creation_deny": "\U0001F6AB",
        # regex
        "use_regex_autotrigger": False,
    }

    private_keys = {
        # bot presence
        "activity_message": "",
        "activity_type": None,
        "status": None,
        "dm_disabled": DMDisabled.NONE,
        "oauth_whitelist": [],
        # moderation
        "blocked": {},
        "blocked_roles": {},
        "blocked_whitelist": [],
        "command_permissions": {},
        "level_permissions": {},
        "override_command_level": {},
        # threads
        "snippets": {},
        "notification_squad": {},
        "subscriptions": {},
        "closures": {},
        # misc
        "plugins": [],
        "aliases": {},
        "auto_triggers": {},
    }

    protected_keys = {
        # Modmail
        "modmail_guild_id": None,
        "guild_id": None,
        "log_url": "https://example.com/",
        "log_url_prefix": "/logs",
        "mongo_uri": None,
        "database_type": "mongodb",
        "connection_uri": None,  # replace mongo uri in the future
        "owners": None,
        # bot
        "token": None,
        "enable_plugins": True,
        "enable_eval": True,
        # github access token for private repositories
        "github_token": None,
        "disable_autoupdates": False,
        "disable_updates": False,
        # Logging
        "log_level": "INFO",
        # data collection
        "data_collection": True,
    }

    colors = {"mod_color", "recipient_color", "main_color", "error_color"}

    time_deltas = {
        "account_age", "guild_age", "thread_auto_close", "thread_cooldown"
    }

    booleans = {
        "user_typing",
        "mod_typing",
        "reply_without_command",
        "anon_reply_without_command",
        "plain_reply_without_command",
        "recipient_thread_close",
        "thread_auto_close_silently",
        "thread_move_notify",
        "thread_move_notify_mods",
        "transfer_reactions",
        "close_on_leave",
        "alert_on_mention",
        "silent_alert_on_mention",
        "show_timestamp",
        "confirm_thread_creation",
        "use_regex_autotrigger",
        "enable_plugins",
        "data_collection",
        "enable_eval",
        "disable_autoupdates",
        "disable_updates",
        "update_notifications",
        "thread_contact_silently",
        "anonymous_snippets",
    }

    enums = {
        "dm_disabled": DMDisabled,
        "status": discord.Status,
        "activity_type": discord.ActivityType,
    }

    force_str = {"command_permissions", "level_permissions"}

    defaults = {**public_keys, **private_keys, **protected_keys}
    all_keys = set(defaults.keys())

    def __init__(self, bot):
        self.bot = bot
        self._cache = {}
        self.ready_event = asyncio.Event()
        self.config_help = {}

    def __repr__(self):
        return repr(self._cache)

    def populate_cache(self) -> dict:
        data = deepcopy(self.defaults)

        # populate from env var and .env file
        data.update({
            k.lower(): v
            for k, v in os.environ.items() if k.lower() in self.all_keys
        })
        config_json = os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
            "config.json")
        if os.path.exists(config_json):
            logger.debug("Loading envs from config.json.")
            with open(config_json, "r", encoding="utf-8") as f:
                # Config json should override env vars
                try:
                    data.update({
                        k.lower(): v
                        for k, v in json.load(f).items()
                        if k.lower() in self.all_keys
                    })
                except json.JSONDecodeError:
                    logger.critical("Failed to load config.json env values.",
                                    exc_info=True)
        self._cache = data

        config_help_json = os.path.join(
            os.path.dirname(os.path.abspath(__file__)), "config_help.json")
        with open(config_help_json, "r", encoding="utf-8") as f:
            self.config_help = dict(sorted(json.load(f).items()))

        return self._cache

    async def update(self):
        """Updates the config with data from the cache"""
        await self.bot.api.update_config(self.filter_default(self._cache))

    async def refresh(self) -> dict:
        """Refreshes internal cache with data from database"""
        for k, v in (await self.bot.api.get_config()).items():
            k = k.lower()
            if k in self.all_keys:
                self._cache[k] = v
        if not self.ready_event.is_set():
            self.ready_event.set()
            logger.debug("Successfully fetched configurations from database.")
        return self._cache

    async def wait_until_ready(self) -> None:
        await self.ready_event.wait()

    def __setitem__(self, key: str, item: typing.Any) -> None:
        key = key.lower()
        logger.info("Setting %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        self._cache[key] = item

    def __getitem__(self, key: str) -> typing.Any:
        # make use of the custom methods in func:get:
        return self.get(key)

    def __delitem__(self, key: str) -> None:
        return self.remove(key)

    def get(self, key: str, convert=True) -> typing.Any:
        key = key.lower()
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key not in self._cache:
            self._cache[key] = deepcopy(self.defaults[key])
        value = self._cache[key]

        if not convert:
            return value

        if key in self.colors:
            try:
                return int(value.lstrip("#"), base=16)
            except ValueError:
                logger.error("Invalid %s provided.", key)
            value = int(self.remove(key).lstrip("#"), base=16)

        elif key in self.time_deltas:
            if not isinstance(value, isodate.Duration):
                try:
                    value = isodate.parse_duration(value)
                except isodate.ISO8601Error:
                    logger.warning(
                        "The {account} age limit needs to be a "
                        'ISO-8601 duration formatted duration, not "%s".',
                        value,
                    )
                    value = self.remove(key)

        elif key in self.booleans:
            try:
                value = strtobool(value)
            except ValueError:
                value = self.remove(key)

        elif key in self.enums:
            if value is None:
                return None
            try:
                value = self.enums[key](value)
            except ValueError:
                logger.warning("Invalid %s %s.", key, value)
                value = self.remove(key)

        elif key in self.force_str:
            # Temporary: as we saved in int previously, leading to int32 overflow,
            #            this is transitioning IDs to strings
            new_value = {}
            changed = False
            for k, v in value.items():
                new_v = v
                if isinstance(v, list):
                    new_v = []
                    for n in v:
                        if n != -1 and not isinstance(n, str):
                            changed = True
                            n = str(n)
                        new_v.append(n)
                new_value[k] = new_v

            if changed:
                # transition the database as well
                self.set(key, new_value)

            value = new_value

        return value

    def set(self, key: str, item: typing.Any, convert=True) -> None:
        if not convert:
            return self.__setitem__(key, item)

        if key in self.colors:
            try:
                hex_ = str(item)
                if hex_.startswith("#"):
                    hex_ = hex_[1:]
                if len(hex_) == 3:
                    hex_ = "".join(s for s in hex_ for _ in range(2))
                if len(hex_) != 6:
                    raise InvalidConfigError("Invalid color name or hex.")
                try:
                    int(hex_, 16)
                except ValueError:
                    raise InvalidConfigError("Invalid color name or hex.")

            except InvalidConfigError:
                name = str(item).lower()
                name = re.sub(r"[\-+|. ]+", " ", name)
                hex_ = ALL_COLORS.get(name)
                if hex_ is None:
                    name = re.sub(r"[\-+|. ]+", "", name)
                    hex_ = ALL_COLORS.get(name)
                    if hex_ is None:
                        raise
            return self.__setitem__(key, "#" + hex_)

        if key in self.time_deltas:
            try:
                isodate.parse_duration(item)
            except isodate.ISO8601Error:
                try:
                    converter = UserFriendlyTimeSync()
                    time = converter.convert(None, item)
                    if time.arg:
                        raise ValueError
                except BadArgument as exc:
                    raise InvalidConfigError(*exc.args)
                except Exception as e:
                    logger.debug(e)
                    raise InvalidConfigError(
                        "Unrecognized time, please use ISO-8601 duration format "
                        'string or a simpler "human readable" time.')
                item = isodate.duration_isoformat(time.dt - converter.now)
            return self.__setitem__(key, item)

        if key in self.booleans:
            try:
                return self.__setitem__(key, strtobool(item))
            except ValueError:
                raise InvalidConfigError("Must be a yes/no value.")

        elif key in self.enums:
            if isinstance(item, self.enums[key]):
                # value is an enum type
                item = item.value

        return self.__setitem__(key, item)

    def remove(self, key: str) -> typing.Any:
        key = key.lower()
        logger.info("Removing %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key in self._cache:
            del self._cache[key]
        self._cache[key] = deepcopy(self.defaults[key])
        return self._cache[key]

    def items(self) -> typing.Iterable:
        return self._cache.items()

    @classmethod
    def filter_valid(
            cls,
            data: typing.Dict[str,
                              typing.Any]) -> typing.Dict[str, typing.Any]:
        return {
            k.lower(): v
            for k, v in data.items()
            if k.lower() in cls.public_keys or k.lower() in cls.private_keys
        }

    @classmethod
    def filter_default(
            cls,
            data: typing.Dict[str,
                              typing.Any]) -> typing.Dict[str, typing.Any]:
        # TODO: use .get to prevent errors
        filtered = {}
        for k, v in data.items():
            default = cls.defaults.get(k.lower(), Default)
            if default is Default:
                logger.error("Unexpected configuration detected: %s.", k)
                continue
            if v != default:
                filtered[k.lower()] = v
        return filtered
Example #19
0
class ConfigManager:

    public_keys = {
        # activity
        "twitch_url": "https://www.twitch.tv/1/",
        # bot settings
        "main_category_id": None,
        "fallback_category_id": None,
        "prefix": "?",
        "mention": "<@812714133962096650> Nieuw ticket geopend! Reageer met ?reply (bericht). Alle normale berichten in dit kanaal worden niet verzonden naar de gebruiker dus het is mogelijk om hier te discusseren.",
        "main_color": str(discord.Color.orange()),
        "error_color": str(discord.Color.red()),
        "user_typing": True,
        "mod_typing": False,
        "account_age": isodate.Duration(),
        "guild_age": isodate.Duration(),
        "thread_cooldown": isodate.Duration(),
        "reply_without_command": False,
        "anon_reply_without_command": False,
        "plain_reply_without_command": False,
        # logging
        "log_channel_id": 803981495257399326,
        "mention_channel_id": None,
        "update_channel_id": None,
        # updates
        "update_notifications": True,
        # threads
        "sent_emoji": "✅",
        "blocked_emoji": "🚫",
        "close_emoji": "🔒",
        "use_user_id_channel_name": False,
        "recipient_thread_close": False,
        "thread_auto_close_silently": False,
        "thread_auto_close": isodate.Duration(),
        "thread_auto_close_response": "Dit ticket is automatisch gesloten door inactiviteit na {timeout}.",
        "thread_creation_response": "Het support team zal u zo snel mogelijk contacteren en helpen met uw vraag.",
        "thread_creation_footer": "Uw bericht is verzonden",
        "thread_contact_silently": False,
        "thread_self_closable_creation_footer": "Klik op het slotje om het ticket te sluiten.",
        "thread_creation_title": "Ticket Gemaakt",
        "thread_close_footer": "Een DM sturen naar deze bot zal een nieuw ticket openen.",
        "thread_close_title": "Ticket Gesloten",
        "thread_close_response": "{closer.mention} heeft dit ticket gesloten.",
        "thread_self_close_response": "Je hebt dit ticket gesloten.",
        "thread_move_title": "Ticket Doorverwezen",
        "thread_move_notify": True,
        "thread_move_notify_mods": False,
        "thread_move_response": "Uw ticket is doorverwezen naar een andere afdeling, er komt zo snel mogelijk een medewerker die gespecialiseerd is in uw vraag. Een moment alstublieft.",
        "cooldown_thread_title": "Geen ticket geopend.",
        "cooldown_thread_response": "Je moet {delta} wachten voordat je een nieuw ticket kunt openen.",
        "disabled_new_thread_title": "Bericht is niet geleverd.",
        "disabled_new_thread_response": "De helpdesk is op dit moment gesloten voor nieuwe tickets.",
        "disabled_new_thread_footer": "Probeer het nogmaals op een ander moment.",
        "disabled_current_thread_title": "Bericht is niet geleverd.",
        "disabled_current_thread_response": "De helpdesk is op dit moment gesloten.",
        "disabled_current_thread_footer": "Probeer het nogmaals op een ander moment.",
        "transfer_reactions": True,
        "close_on_leave": False,
        "close_on_leave_reason": "De ontvanger is uit de server gegaan.",
        "alert_on_mention": False,
        "silent_alert_on_mention": False,
        "show_timestamp": True,
        "anonymous_snippets": False,
        # moderation
        "recipient_color": str(discord.Color.gold()),
        "mod_color": str(discord.Color.green()),
        "mod_tag": None,
        # anonymous message
        "anon_username": None,
        "anon_avatar_url": None,
        "anon_tag": "Antwoord van Support medewerker",
        # react to contact
        "react_to_contact_message": None,
        "react_to_contact_emoji": "\u2705",
        # confirm thread creation
        "confirm_thread_creation": False,
        "confirm_thread_creation_title": "Weet je zeker dat je een ticket wilt aanmaken?",
        "confirm_thread_response": "Reageer om een ticket aan te maken.",
        "confirm_thread_creation_accept": "\u2705",
        "confirm_thread_creation_deny": "\U0001F6AB",
        # regex
        "use_regex_autotrigger": False,
    }

    private_keys = {
        # bot presence
        "activity_message": "",
        "activity_type": None,
        "status": None,
        "dm_disabled": DMDisabled.NONE,
        "oauth_whitelist": [],
        # moderation
        "blocked": {},
        "blocked_roles": {},
        "blocked_whitelist": [],
        "command_permissions": {},
        "level_permissions": {},
        "override_command_level": {},
        # threads
        "snippets": {},
        "notification_squad": {},
        "subscriptions": {},
        "closures": {},
        # misc
        "plugins": [],
        "aliases": {},
        "auto_triggers": {},
    }

    protected_keys = {
        # Modmail
        "modmail_guild_id": None,
        "guild_id": None,
        "log_url": "https://example.com/",
        "log_url_prefix": "/logs",
        "mongo_uri": None,
        "database_type": "mongodb",
        "connection_uri": None,  # replace mongo uri in the future
        "owners": None,
        # bot
        "token": None,
        "enable_plugins": True,
        "enable_eval": True,
        # github access token for private repositories
        "github_token": None,
        "disable_autoupdates": False,
        "disable_updates": False,
        # Logging
        "log_level": "INFO",
        # data collection
        "data_collection": True,
    }

    colors = {"mod_color", "recipient_color", "main_color", "error_color"}

    time_deltas = {"account_age", "guild_age", "thread_auto_close", "thread_cooldown"}

    booleans = {
        "use_user_id_channel_name",
        "user_typing",
        "mod_typing",
        "reply_without_command",
        "anon_reply_without_command",
        "plain_reply_without_command",
        "recipient_thread_close",
        "thread_auto_close_silently",
        "thread_move_notify",
        "thread_move_notify_mods",
        "transfer_reactions",
        "close_on_leave",
        "alert_on_mention",
        "silent_alert_on_mention",
        "show_timestamp",
        "confirm_thread_creation",
        "use_regex_autotrigger",
        "enable_plugins",
        "data_collection",
        "enable_eval",
        "disable_autoupdates",
        "disable_updates",
        "update_notifications",
        "thread_contact_silently",
        "anonymous_snippets",
    }

    enums = {
        "dm_disabled": DMDisabled,
        "status": discord.Status,
        "activity_type": discord.ActivityType,
    }

    force_str = {"command_permissions", "level_permissions"}

    defaults = {**public_keys, **private_keys, **protected_keys}
    all_keys = set(defaults.keys())

    def __init__(self, bot):
        self.bot = bot
        self._cache = {}
        self.ready_event = asyncio.Event()
        self.config_help = {}

    def __repr__(self):
        return repr(self._cache)

    def populate_cache(self) -> dict:
        data = deepcopy(self.defaults)

        # populate from env var and .env file
        data.update({k.lower(): v for k, v in os.environ.items() if k.lower() in self.all_keys})
        config_json = os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json"
        )
        if os.path.exists(config_json):
            logger.debug("Loading envs from config.json.")
            with open(config_json, "r", encoding="utf-8") as f:
                # Config json should override env vars
                try:
                    data.update(
                        {
                            k.lower(): v
                            for k, v in json.load(f).items()
                            if k.lower() in self.all_keys
                        }
                    )
                except json.JSONDecodeError:
                    logger.critical("Failed to load config.json env values.", exc_info=True)
        self._cache = data

        config_help_json = os.path.join(
            os.path.dirname(os.path.abspath(__file__)), "config_help.json"
        )
        with open(config_help_json, "r", encoding="utf-8") as f:
            self.config_help = dict(sorted(json.load(f).items()))

        return self._cache

    async def update(self):
        """Updates the config with data from the cache"""
        await self.bot.api.update_config(self.filter_default(self._cache))

    async def refresh(self) -> dict:
        """Refreshes internal cache with data from database"""
        for k, v in (await self.bot.api.get_config()).items():
            k = k.lower()
            if k in self.all_keys:
                self._cache[k] = v
        if not self.ready_event.is_set():
            self.ready_event.set()
            logger.debug("Successfully fetched configurations from database.")
        return self._cache

    async def wait_until_ready(self) -> None:
        await self.ready_event.wait()

    def __setitem__(self, key: str, item: typing.Any) -> None:
        key = key.lower()
        logger.info("Setting %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        self._cache[key] = item

    def __getitem__(self, key: str) -> typing.Any:
        # make use of the custom methods in func:get:
        return self.get(key)

    def __delitem__(self, key: str) -> None:
        return self.remove(key)

    def get(self, key: str, convert=True) -> typing.Any:
        key = key.lower()
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key not in self._cache:
            self._cache[key] = deepcopy(self.defaults[key])
        value = self._cache[key]

        if not convert:
            return value

        if key in self.colors:
            try:
                return int(value.lstrip("#"), base=16)
            except ValueError:
                logger.error("Invalid %s provided.", key)
            value = int(self.remove(key).lstrip("#"), base=16)

        elif key in self.time_deltas:
            if not isinstance(value, isodate.Duration):
                try:
                    value = isodate.parse_duration(value)
                except isodate.ISO8601Error:
                    logger.warning(
                        "The {account} age limit needs to be a "
                        'ISO-8601 duration formatted duration, not "%s".',
                        value,
                    )
                    value = self.remove(key)

        elif key in self.booleans:
            try:
                value = strtobool(value)
            except ValueError:
                value = self.remove(key)

        elif key in self.enums:
            if value is None:
                return None
            try:
                value = self.enums[key](value)
            except ValueError:
                logger.warning("Invalid %s %s.", key, value)
                value = self.remove(key)

        elif key in self.force_str:
            # Temporary: as we saved in int previously, leading to int32 overflow,
            #            this is transitioning IDs to strings
            new_value = {}
            changed = False
            for k, v in value.items():
                new_v = v
                if isinstance(v, list):
                    new_v = []
                    for n in v:
                        if n != -1 and not isinstance(n, str):
                            changed = True
                            n = str(n)
                        new_v.append(n)
                new_value[k] = new_v

            if changed:
                # transition the database as well
                self.set(key, new_value)

            value = new_value

        return value

    def set(self, key: str, item: typing.Any, convert=True) -> None:
        if not convert:
            return self.__setitem__(key, item)

        if key in self.colors:
            try:
                hex_ = str(item)
                if hex_.startswith("#"):
                    hex_ = hex_[1:]
                if len(hex_) == 3:
                    hex_ = "".join(s for s in hex_ for _ in range(2))
                if len(hex_) != 6:
                    raise InvalidConfigError("Invalid color name or hex.")
                try:
                    int(hex_, 16)
                except ValueError:
                    raise InvalidConfigError("Invalid color name or hex.")

            except InvalidConfigError:
                name = str(item).lower()
                name = re.sub(r"[\-+|. ]+", " ", name)
                hex_ = ALL_COLORS.get(name)
                if hex_ is None:
                    name = re.sub(r"[\-+|. ]+", "", name)
                    hex_ = ALL_COLORS.get(name)
                    if hex_ is None:
                        raise
            return self.__setitem__(key, "#" + hex_)

        if key in self.time_deltas:
            try:
                isodate.parse_duration(item)
            except isodate.ISO8601Error:
                try:
                    converter = UserFriendlyTimeSync()
                    time = converter.convert(None, item)
                    if time.arg:
                        raise ValueError
                except BadArgument as exc:
                    raise InvalidConfigError(*exc.args)
                except Exception as e:
                    logger.debug(e)
                    raise InvalidConfigError(
                        "Unrecognized time, please use ISO-8601 duration format "
                        'string or a simpler "human readable" time.'
                    )
                item = isodate.duration_isoformat(time.dt - converter.now)
            return self.__setitem__(key, item)

        if key in self.booleans:
            try:
                return self.__setitem__(key, strtobool(item))
            except ValueError:
                raise InvalidConfigError("Must be a yes/no value.")

        elif key in self.enums:
            if isinstance(item, self.enums[key]):
                # value is an enum type
                item = item.value

        return self.__setitem__(key, item)

    def remove(self, key: str) -> typing.Any:
        key = key.lower()
        logger.info("Removing %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(f'Configuration "{key}" is invalid.')
        if key in self._cache:
            del self._cache[key]
        self._cache[key] = deepcopy(self.defaults[key])
        return self._cache[key]

    def items(self) -> typing.Iterable:
        return self._cache.items()

    @classmethod
    def filter_valid(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
        return {
            k.lower(): v
            for k, v in data.items()
            if k.lower() in cls.public_keys or k.lower() in cls.private_keys
        }

    @classmethod
    def filter_default(cls, data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
        # TODO: use .get to prevent errors
        filtered = {}
        for k, v in data.items():
            default = cls.defaults.get(k.lower(), Default)
            if default is Default:
                logger.error("Unexpected configuration detected: %s.", k)
                continue
            if v != default:
                filtered[k.lower()] = v
        return filtered
Example #20
0
import pytest
import isodate
import datetime
from frictionless import Field

# General


@pytest.mark.parametrize(
    "format, source, target",
    [
        ("default", isodate.Duration(years=1), isodate.Duration(years=1)),
        (
            "default",
            "P1Y10M3DT5H11M7S",
            isodate.Duration(
                years=1, months=10, days=3, hours=5, minutes=11, seconds=7),
        ),
        ("default", "P1Y", isodate.Duration(years=1)),
        ("default", "P1M", isodate.Duration(months=1)),
        ("default", "PT1S", datetime.timedelta(seconds=1)),
        ("default", datetime.timedelta(seconds=1),
         datetime.timedelta(seconds=1)),
        ("default", "P1M1Y", None),
        ("default", "P-1Y", None),
        ("default", "year", None),
        ("default", True, None),
        ("default", False, None),
        ("default", 1, None),
        ("default", "", None),
        ("default", [], None),
Example #21
0
class ConfigManager:

    public_keys = {
        # Actividad
        "twitch_url": "https://www.twitch.tv/#",
        # Ajustes del BOT
        "main_category_id": None,
        "fallback_category_id": None,
        "prefix": "/",
        "mention": "@here",
        "main_color": str(discord.Color.blurple()),
        "error_color": str(discord.Color.red()),
        "user_typing": True,
        "mod_typing": False,
        "account_age": isodate.Duration(),
        "guild_age": isodate.Duration(),
        "thread_cooldown": isodate.Duration(),
        "reply_without_command": False,
        "anon_reply_without_command": False,
        # Registros
        "log_channel_id": None,
        # Hilos
        "sent_emoji": "✅",
        "blocked_emoji": "🚫",
        "close_emoji": "🔒",
        "recipient_thread_close": False,
        "thread_auto_close_silently": False,
        "thread_auto_close": isodate.Duration(),
        "thread_auto_close_response":
        "Este hilo se ha cerrado automáticamente debido a una inactividad luego de {timeout}.",
        "thread_creation_response":
        "Hemos recibido tu mensaje! Nuestro Equipo te estará respondiendo pronto. Ten paciencia!",
        "thread_creation_footer": "Tu mensaje fue enviado",
        "thread_self_closable_creation_footer":
        "Clickea en el candado para cerrar el hilo",
        "thread_creation_title": "Hilo creado",
        "thread_close_footer": "Responder creará otro hilo",
        "thread_close_title": "Hilo cerrado",
        "thread_close_response": "{closer.mention} ha cerrado este hilo.",
        "thread_self_close_response": "Tú has cerrado este hilo.",
        "thread_move_notify": False,
        "thread_move_response": "Este hilo fue movido.",
        "disabled_new_thread_title": "Mensaje no enviado.",
        "disabled_new_thread_response":
        "No estamos aceptando nuevos hilos, solo respondemos a hilos creados.",
        "disabled_new_thread_footer":
        "Por favor, inténtalo de nuevo más tarde.",
        "disabled_current_thread_title": "Mensaje no enviado.",
        "disabled_current_thread_response":
        "No estamos aceptando ningún mensaje.",
        "disabled_current_thread_footer":
        "Por favor, inténtalo de nuevo más tarde.",
        # Moderación
        "recipient_color": str(discord.Color.gold()),
        "mod_color": str(discord.Color.green()),
        "mod_tag": None,
        # Mensajes anónimos
        "anon_username": None,
        "anon_avatar_url": None,
        "anon_tag": "Respuesta",
    }

    private_keys = {
        # Presencia del BOT
        "activity_message":
        "Envíame un mensaje con tu duda / problema o reporte!",
        "activity_type": None,
        "status": None,
        # dm_disabled 0 = ninguno, 1 = nuevos hilos, 2 = todos los hilos
        # TODO: use emum
        "dm_disabled": 0,
        "oauth_whitelist": [],
        # Moderación
        "blocked": {},
        "blocked_whitelist": [],
        "command_permissions": {},
        "level_permissions": {},
        "override_command_level": {},
        # Hilos
        "snippets": {},
        "notification_squad": {},
        "subscriptions": {},
        "closures": {},
        # Misceláneo
        "plugins": [],
        "aliases": {},
    }

    protected_keys = {
        # ModMail
        "modmail_guild_id": None,
        "guild_id": None,
        "log_url": "https://example.com/",
        "log_url_prefix": "/logs",
        "mongo_uri": None,
        "owners": None,
        # BOT
        "token": None,
        # Registros
        "log_level": "INFO",
        "enable_plugins": True,
    }

    colors = {"mod_color", "recipient_color", "main_color", "error_color"}

    time_deltas = {
        "account_age", "guild_age", "thread_auto_close", "thread_cooldown"
    }

    booleans = {
        "user_typing",
        "mod_typing",
        "reply_without_command",
        "anon_reply_without_command",
        "recipient_thread_close",
        "thread_auto_close_silently",
        "thread_move_notify",
        "enable_plugins",
    }

    special_types = {"status", "activity_type"}

    defaults = {**public_keys, **private_keys, **protected_keys}
    all_keys = set(defaults.keys())

    def __init__(self, bot):
        self.bot = bot
        self._cache = {}
        self.ready_event = asyncio.Event()
        self.config_help = {}

    def __repr__(self):
        return repr(self._cache)

    def populate_cache(self) -> dict:
        data = deepcopy(self.defaults)

        # populate from env var and .env file
        data.update({
            k.lower(): v
            for k, v in os.environ.items() if k.lower() in self.all_keys
        })
        config_json = os.path.join(
            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
            "config.json")
        if os.path.exists(config_json):
            logger.debug("Loading envs from config.json.")
            with open(config_json, "r", encoding="utf-8") as f:
                # Config json should override env vars
                try:
                    data.update({
                        k.lower(): v
                        for k, v in json.load(f).items()
                        if k.lower() in self.all_keys
                    })
                except json.JSONDecodeError:
                    logger.critical(
                        "Falló al cargar valores de variables de .ENV",
                        exc_info=True)
        self._cache = data

        config_help_json = os.path.join(
            os.path.dirname(os.path.abspath(__file__)), "config_help.json")
        with open(config_help_json, "r", encoding="utf-8") as f:
            self.config_help = dict(sorted(json.load(f).items()))

        return self._cache

    async def update(self):
        """Updates the config with data from the cache"""
        await self.bot.api.update_config(self.filter_default(self._cache))

    async def refresh(self) -> dict:
        """Refreshes internal cache with data from database"""
        for k, v in (await self.bot.api.get_config()).items():
            k = k.lower()
            if k in self.all_keys:
                self._cache[k] = v
        if not self.ready_event.is_set():
            self.ready_event.set()
            logger.debug(
                "Se obtuvo la información de la base de datos correctamente.")
        return self._cache

    async def wait_until_ready(self) -> None:
        await self.ready_event.wait()

    def __setitem__(self, key: str, item: typing.Any) -> None:
        key = key.lower()
        logger.info("Setting %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(
                f'Clave de configuración "{key}" es inválida.')
        self._cache[key] = item

    def __getitem__(self, key: str) -> typing.Any:
        key = key.lower()
        if key not in self.all_keys:
            raise InvalidConfigError(
                f'Clave de configuración "{key}" es inválida.')
        if key not in self._cache:
            self._cache[key] = deepcopy(self.defaults[key])
        return self._cache[key]

    def __delitem__(self, key: str) -> None:
        return self.remove(key)

    def get(self, key: str, convert=True) -> typing.Any:
        value = self.__getitem__(key)

        if not convert:
            return value

        if key in self.colors:
            try:
                return int(value.lstrip("#"), base=16)
            except ValueError:
                logger.error("Inválido %s.", key)
            value = int(self.remove(key).lstrip("#"), base=16)

        elif key in self.time_deltas:
            if not isinstance(value, isodate.Duration):
                try:
                    value = isodate.parse_duration(value)
                except isodate.ISO8601Error:
                    logger.warning(
                        "El límite de edad de la cuenta ${account} debe ser un"
                        'ISO-8601 duración formateada, no "%s".',
                        value,
                    )
                    value = self.remove(key)

        elif key in self.booleans:
            try:
                value = strtobool(value)
            except ValueError:
                value = self.remove(key)

        elif key in self.special_types:
            if value is None:
                return None

            if key == "status":
                try:
                    # noinspection PyArgumentList
                    value = discord.Status(value)
                except ValueError:
                    logger.warning("Estado inválido %s.", value)
                    value = self.remove(key)

            elif key == "activity_type":
                try:
                    # noinspection PyArgumentList
                    value = discord.ActivityType(value)
                except ValueError:
                    logger.warning("Actividad inválida %s.", value)
                    value = self.remove(key)

        return value

    def set(self, key: str, item: typing.Any, convert=True) -> None:
        if not convert:
            return self.__setitem__(key, item)

        if key in self.colors:
            try:
                hex_ = str(item)
                if hex_.startswith("#"):
                    hex_ = hex_[1:]
                if len(hex_) == 3:
                    hex_ = "".join(s for s in hex_ for _ in range(2))
                if len(hex_) != 6:
                    raise InvalidConfigError(
                        "Nombre de color o HEX inválido.")
                try:
                    int(hex_, 16)
                except ValueError:
                    raise InvalidConfigError(
                        "Nombre de color o HEX inválido.")

            except InvalidConfigError:
                name = str(item).lower()
                name = re.sub(r"[\-+|. ]+", " ", name)
                hex_ = ALL_COLORS.get(name)
                if hex_ is None:
                    name = re.sub(r"[\-+|. ]+", "", name)
                    hex_ = ALL_COLORS.get(name)
                    if hex_ is None:
                        raise
            return self.__setitem__(key, "#" + hex_)

        if key in self.time_deltas:
            try:
                isodate.parse_duration(item)
            except isodate.ISO8601Error:
                try:
                    converter = UserFriendlyTimeSync()
                    time = converter.convert(None, item)
                    if time.arg:
                        raise ValueError
                except BadArgument as exc:
                    raise InvalidConfigError(*exc.args)
                except Exception as e:
                    logger.debug(e)
                    raise InvalidConfigError(
                        "Tiempo no reconocido, por favor use el formato de duración ISO-8601 "
                        'o un tiempo de "lectura humana" más simple.')
                item = isodate.duration_isoformat(time.dt - converter.now)
            return self.__setitem__(key, item)

        if key in self.booleans:
            try:
                return self.__setitem__(key, strtobool(item))
            except ValueError:
                raise InvalidConfigError("Debe ser un valor de Sí/No.")

        # elif key in self.special_types:
        #     if key == "status":

        return self.__setitem__(key, item)

    def remove(self, key: str) -> typing.Any:
        key = key.lower()
        logger.info("Removiendo %s.", key)
        if key not in self.all_keys:
            raise InvalidConfigError(
                f'Clave de configuración "{key}" es inválida.')
        if key in self._cache:
            del self._cache[key]
        self._cache[key] = deepcopy(self.defaults[key])
        return self._cache[key]

    def items(self) -> typing.Iterable:
        return self._cache.items()

    @classmethod
    def filter_valid(
            cls,
            data: typing.Dict[str,
                              typing.Any]) -> typing.Dict[str, typing.Any]:
        return {
            k.lower(): v
            for k, v in data.items()
            if k.lower() in cls.public_keys or k.lower() in cls.private_keys
        }

    @classmethod
    def filter_default(
            cls,
            data: typing.Dict[str,
                              typing.Any]) -> typing.Dict[str, typing.Any]:
        # TODO: use .get to prevent errors
        filtered = {}
        for k, v in data.items():
            default = cls.defaults.get(k.lower(), Default)
            if default is Default:
                logger.error("Configuración inesperada detectada: %s.", k)
                continue
            if v != default:
                filtered[k.lower()] = v
        return filtered