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", }
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
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)
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)
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)
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)
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
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)
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
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
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
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),
("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
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": {}, }
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),
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
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
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
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),
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