From e8d13ae26b3de884c453888474d8b89209547757 Mon Sep 17 00:00:00 2001 From: corbz Date: Sun, 24 Dec 2023 23:01:48 +0000 Subject: [PATCH 1/3] Created Followup and move functions to feeds.py --- src/db/models.py | 3 +- src/errors.py | 5 +- src/extensions/rss.py | 302 ++++++++++++++++++++-------------------- src/extensions/tasks.py | 18 ++- src/feed.py | 183 ++++++++++++++++++++---- src/utils.py | 111 ++++++++++++--- 6 files changed, 422 insertions(+), 200 deletions(-) diff --git a/src/db/models.py b/src/db/models.py index c462038..97554af 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -4,8 +4,7 @@ All table classes should be suffixed with `Model`. """ from sqlalchemy.sql import func -from sqlalchemy.orm import relationship -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, declarative_base from sqlalchemy import ( Column, Integer, diff --git a/src/errors.py b/src/errors.py index 3f528af..d21d74b 100644 --- a/src/errors.py +++ b/src/errors.py @@ -1,4 +1,5 @@ class IllegalFeed(Exception): - pass - + def __init__(self, message: str, **items): + super().__init__(message) + self.items = items diff --git a/src/extensions/rss.py b/src/extensions/rss.py index 8d91f13..b9d4b03 100644 --- a/src/extensions/rss.py +++ b/src/extensions/rss.py @@ -12,18 +12,25 @@ from discord.ext import commands from discord import Interaction, Embed, Colour, TextChannel, Permissions from discord.app_commands import Choice, Group, autocomplete, choices, rename from sqlalchemy import insert, select, and_, delete -from sqlalchemy.exc import NoResultFound +from sqlalchemy.exc import NoResultFound, IntegrityError -from utils import get_rss_data, followup, audit, followup_error, extract_error_info # pylint: disable=E0401 -from feed import Source # pylint: disable=E0401 -from db import ( # pylint: disable=E0401 +from feed import Source +from errors import IllegalFeed +from db import ( DatabaseManager, SentArticleModel, RssSourceModel, FeedChannelModel, AuditModel ) -from errors import IllegalFeed +from utils import ( + Followup, + get_rss_data, + followup, + audit, + extract_error_info, + get_unparsed_feed +) log = logging.getLogger(__name__) @@ -81,7 +88,7 @@ async def validate_rss_source(nickname: str, url: str) -> Tuple[str | None, Feed return None, feed async def set_all_articles_as_sent(inter, channel: TextChannel, feed_id: int, rss_url: str): - unparsed_feed = await self.bot.functions.get_unparsed_feed(rss_url) + unparsed_feed = await get_unparsed_feed(rss_url) source = Source.from_parsed(parse(unparsed_feed)) articles = source.get_latest_articles() @@ -148,101 +155,89 @@ class FeedCog(commands.Cog): feed_group = Group( name="feed", description="Commands for rss sources.", - guild_only=True, # We store guild IDs in the database, so guild only = True - default_permissions=Permissions.elevated() + default_permissions=Permissions.elevated(), + guild_only=True # We store guild IDs in the database, so guild only = True ) @feed_group.command(name="add") async def add_rss_source(self, inter: Interaction, nickname: str, url: str): - """Add a new RSS source. + """Add a new Feed for this server. Parameters ---------- inter : Interaction Represents an app command interaction. nickname : str - A name used to identify the RSS source. + A name used to identify the Feed. url : str - The RSS feed URL. + The Feed URL. """ await inter.response.defer() try: - source = self.bot.functions.create_new_feed(nickname, url) + source = await self.bot.functions.create_new_feed(nickname, url, inter.guild_id) except IllegalFeed as error: title, desc = extract_error_info(error) - followup_error(inter, title=title, description=desc) - - embed = Embed(title="RSS Feed Added", colour=Colour.dark_green()) - embed.add_field(name="Nickname", value=nickname) - embed.add_field(name="URL", value=url) - embed.set_thumbnail(url=source.thumb_url) - - await followup(inter, embed=embed) + await Followup(title, desc).fields(**error.items).error().send(inter) + except IntegrityError as error: + await ( + Followup( + "Duplicate Feed Error", + "A Feed with the same nickname already exist." + ) + .fields(nickname=nickname) + .error() + .send(inter) + ) + else: + await ( + Followup("Feed Added") + .image(source.icon_url) + .fields(nickname=nickname, url=url) + .added() + .send(inter) + ) @feed_group.command(name="remove") @rename(url="option") @autocomplete(url=source_autocomplete) async def remove_rss_source(self, inter: Interaction, url: str): - """Delete an existing RSS source. + """Delete an existing Feed from this server. Parameters ---------- inter : Interaction Represents an app command interaction. url : str - The RSS source to be removed. Autocomplete or enter the URL. + The Feed to be removed. Autocomplete or enter the URL. """ await inter.response.defer() - log.debug("Attempting to remove RSS source (url=%s)", url) - - async with DatabaseManager() as database: - whereclause = and_( - RssSourceModel.discord_server_id == inter.guild_id, - RssSourceModel.rss_url == url - ) - - # We will select the item first, so we can reference it's nickname later. - select_query = select(RssSourceModel).filter(whereclause) - select_result = await database.session.execute(select_query) - - try: - rss_source = select_result.scalars().one() - except NoResultFound: - await followup_error(inter, - title="Error Deleting Feed", - message=f"I couldn't find anything for `{url}`" + try: + source = await self.bot.functions.delete_feed(url, inter.guild_id) + except NoResultFound: + await ( + Followup( + "Feed Not Found Error", + "A Feed with these parameters could not be found." ) - return - - nickname = rss_source.nick - - delete_query = delete(RssSourceModel).filter(whereclause) - delete_result = await database.session.execute(delete_query) - - await audit(self, - f"Deleted RSS source ({nickname=}, {url=})", - inter.user.id, database=database + .error() + .send(inter) + ) + else: + await ( + Followup("Feed Deleted") + .image(source.icon_url) + .fields(url=url) + .trash() + .send(inter) ) - - source = await Source.from_url(url) - - embed = Embed(title="RSS Feed Deleted", colour=Colour.dark_red()) - embed.add_field(name="Nickname", value=nickname) - embed.add_field(name="URL", value=url) - embed.set_thumbnail(url=source.icon_url) - - await followup(inter, embed=embed) @feed_group.command(name="list") - @choices(sort=rss_list_sort_choices) - async def list_rss_sources( - self, inter: Interaction, sort: Choice[int]=None, sort_reverse: bool=False - ): - """Provides a with a list of RSS sources available for the current server. + async def list_rss_sources(self, inter: Interaction): + """Provides a with a list of Feeds available for this server. Parameters ---------- @@ -252,55 +247,32 @@ class FeedCog(commands.Cog): await inter.response.defer() - # Default to the first choice if not specified. - if isinstance(sort, Choice): - description = "Sort by " - description += "Nickname " if sort.value == 0 else "Date Added " - description += '\U000025BC' if sort_reverse else '\U000025B2' - else: - sort = rss_list_sort_choices[0] - description = "" - - match sort.value, sort_reverse: - case 0, False: - order_by = RssSourceModel.nick.asc() - case 0, True: - order_by = RssSourceModel.nick.desc() - case 1, False: - order_by = RssSourceModel.created.desc() - case 1, True: - order_by = RssSourceModel.created.asc() - case _, _: - raise ValueError(f"Unknown sort: {sort}") - - async with DatabaseManager() as database: - whereclause = and_(RssSourceModel.discord_server_id == inter.guild_id) - query = select(RssSourceModel).where(whereclause).order_by(order_by) - result = await database.session.execute(query) - - rss_sources = result.scalars().all() - rowcount = len(rss_sources) - - if not rss_sources: - await followup_error(inter, - title="No Feeds Found", - message="I couldn't find any Feeds for this server." + try: + sources = await self.bot.functions.get_feeds(inter.guild_id) + except NoResultFound: + await ( + Followup( + "Feeds Not Found Error", + "There are no available Feeds for this server.\n" + "Add a new feed with `/feed add`." ) - return - - output = "\n".join([ - f"{i}. **[{rss.nick}]({rss.rss_url})** " - for i, rss in enumerate(rss_sources) + .error() + .send() + ) + else: + description = "\n".join([ + f"{i}. **[{source.name}]({source.url})**" + for i, source in enumerate(sources) ]) + await ( + Followup( + f"Available Feeds in {inter.guild.name}", + description + ) + .info() + .send(inter) + ) - embed = Embed( - title="Saved RSS Feeds", - description=f"{description}\n\n{output}", - colour=Colour.blue() - ) - embed.set_footer(text=f"Showing {rowcount} results") - - await followup(inter, embed=embed) # @feed_group.command(name="fetch") # @rename(max_="max") @@ -431,7 +403,7 @@ class FeedCog(commands.Cog): query = select(RssSourceModel).where(whereclause) result = await database.session.execute(query) sources = [ - Choice(name=rss.nick, value=rss.id) + Choice(name=rss.nick, value=rss.rss_url) for rss in result.scalars().all() ] @@ -483,10 +455,10 @@ class FeedCog(commands.Cog): # ) @feed_group.command(name="assign") - @rename(rss="feed") - @autocomplete(rss=autocomplete_rss_sources) + @rename(url="feed") + @autocomplete(url=autocomplete_rss_sources) async def include_feed( - self, inter: Interaction, rss: int, channel: TextChannel = None, prevent_spam: bool = True + self, inter: Interaction, url: str, channel: TextChannel = None, prevent_spam: bool = True ): """Include a feed within the specified channel. @@ -494,7 +466,7 @@ class FeedCog(commands.Cog): ---------- inter : Interaction Represents an app command interaction. - rss : int + url : int The RSS feed to include. channel : TextChannel The channel to include the feed in. @@ -504,30 +476,41 @@ class FeedCog(commands.Cog): channel = channel or inter.channel - async with DatabaseManager() as database: - select_query = select(RssSourceModel).where(and_( - RssSourceModel.id == rss, - RssSourceModel.discord_server_id == inter.guild_id - )) - - select_result = await database.session.execute(select_query) - rss_source = select_result.scalars().one() - nick, rss_url = rss_source.nick, rss_source.rss_url - - insert_query = insert(FeedChannelModel).values( - discord_server_id = inter.guild_id, - discord_channel_id = channel.id, - rss_source_id=rss, - search_name=f"{nick} #{channel.name}" + try: + feed_id, source = await self.bot.functions.assign_feed( + url, channel.name, channel.id, inter.guild_id + ) + except IntegrityError: + await ( + Followup( + "Duplicate Assigned Feed Error", + f"This Feed has already been assigned to {channel.mention}" + ) + .error() + .send(inter) + ) + except NoResultFound: + await ( + Followup( + "Feed Not Found Error", + "A Feed with these parameters could not be found." + ) + .error() + .send(inter) + ) + else: + await ( + Followup( + "Feed Assigned", + f"I've assigned {channel.mention} to receive content from " + f"[{source.name}]({source.url})." + ) + .assign() + .send(inter) ) - insert_result = await database.session.execute(insert_query) - feed_id = insert_result.inserted_primary_key.id - if prevent_spam: - await set_all_articles_as_sent(inter, channel, feed_id, rss_url) - - await followup(inter, f"I've included [{nick}]({rss_url}) to {channel.mention}") + await set_all_articles_as_sent(inter, channel, feed_id, url) @feed_group.command(name="unassign") @autocomplete(option=autocomplete_existing_feeds) @@ -544,20 +527,41 @@ class FeedCog(commands.Cog): await inter.response.defer() - async with DatabaseManager() as database: - query = delete(FeedChannelModel).where(and_( - FeedChannelModel.id == option, - FeedChannelModel.discord_server_id == inter.guild_id - )) - - result = await database.session.execute(query) - - if not result.rowcount: - await followup_error(inter, - title="Assigned Feed Not Found", - message=f"I couldn't find any assigned feeds for the option: {option}" + try: + await self.bot.functions.unassign_feed(option, inter.guild_id) + except NoResultFound: + await ( + Followup( + "Assigned Feed Not Found", + "The assigned Feed doesn't exist." + ) + .error() + .send(inter) ) - return + else: + await ( + Followup( + "Unassigned Feed", + "Feed has been unassigned." + ) + .trash() + .send(inter) + ) + + # async with DatabaseManager() as database: + # query = delete(FeedChannelModel).where(and_( + # FeedChannelModel.id == option, + # FeedChannelModel.discord_server_id == inter.guild_id + # )) + + # result = await database.session.execute(query) + + # if not result.rowcount: + # await followup_error(inter, + # title="Assigned Feed Not Found", + # message=f"I couldn't find any assigned feeds for the option: {option}" + # ) + # return await followup(inter, "I've removed this item (placeholder response)") diff --git a/src/extensions/tasks.py b/src/extensions/tasks.py index 5e25966..8b5599a 100644 --- a/src/extensions/tasks.py +++ b/src/extensions/tasks.py @@ -6,14 +6,20 @@ Loading this file via `commands.Bot.load_extension` will add `TaskCog` to the bo import logging from time import process_time -from feedparser import parse -from sqlalchemy import insert, select, and_ -from discord import Interaction, TextChannel +from discord import TextChannel from discord.ext import commands, tasks from discord.errors import Forbidden +from sqlalchemy import insert, select, and_ +from feedparser import parse -from feed import Source, Article # pylint disable=E0401 -from db import DatabaseManager, FeedChannelModel, RssSourceModel, SentArticleModel # pylint disable=E0401 +from feed import Source, Article +from db import ( + DatabaseManager, + FeedChannelModel, + RssSourceModel, + SentArticleModel +) +from utils import get_unparsed_feed log = logging.getLogger(__name__) @@ -68,7 +74,7 @@ class TaskCog(commands.Cog): channel = self.bot.get_channel(feed.discord_channel_id) - unparsed_content = await self.bot.functions.get_unparsed_feed(feed.rss_source.rss_url) + unparsed_content = await get_unparsed_feed(feed.rss_source.rss_url) parsed_feed = parse(unparsed_content) source = Source.from_parsed(parsed_feed) articles = source.get_latest_articles(5) diff --git a/src/feed.py b/src/feed.py index 1bdcc37..7472c6d 100644 --- a/src/feed.py +++ b/src/feed.py @@ -1,22 +1,23 @@ import json import logging -import async_timeout from dataclasses import dataclass from datetime import datetime -from typing import Tuple -import aiohttp +import aiohttp import validators -from textwrap import shorten -from markdownify import markdownify from discord import Embed, Colour from bs4 import BeautifulSoup as bs4 from feedparser import FeedParserDict, parse +from markdownify import markdownify +from sqlalchemy import select, insert, delete, and_ +from sqlalchemy.exc import NoResultFound +from textwrap import shorten -from utils import audit from errors import IllegalFeed +from db import DatabaseManager, RssSourceModel, FeedChannelModel +from utils import get_rss_data, get_unparsed_feed log = logging.getLogger(__name__) dumps = lambda _dict: json.dumps(_dict, indent=8) @@ -162,8 +163,8 @@ class Source: @classmethod async def from_url(cls, url: str): - unparsed_content = await Functions.get_unparsed_feed(url) - return + unparsed_content = await get_unparsed_feed(url) + return cls.from_parsed(parse(unparsed_content)) def get_latest_articles(self, max: int = 999) -> list[Article]: """Returns a list of Article objects. @@ -193,19 +194,26 @@ class Functions: def __init__(self, bot): self.bot = bot - @staticmethod - async def fetch(session, url: str) -> str: - async with async_timeout.timeout(20): - async with session.get(url) as response: - return await response.text() - - @staticmethod - async def get_unparsed_feed(url: str): - async with aiohttp.ClientSession() as session: - return await self.fetch(session, url) # TODO: work from here - async def validate_feed(self, nickname: str, url: str) -> FeedParserDict: - """""" + """Validates a feed based on the given nickname and url. + + Parameters + ---------- + nickname : str + Human readable nickname used to refer to the feed. + url : str + URL to fetch content from the feed. + + Returns + ------- + FeedParserDict + A Parsed Dictionary of the feed. + + Raises + ------ + IllegalFeed + If the feed is invalid. + """ # Ensure the URL is valid if not validators.url(url): @@ -215,28 +223,47 @@ class Functions: if validators.url(nickname): raise IllegalFeed( "It looks like the nickname you have entered is a URL.\n" \ - f"For security reasons, this is not allowed.\n`{nickname=}`" + "For security reasons, this is not allowed.", + nickname=nickname ) feed_data, status_code = await get_rss_data(url) if status_code != 200: raise IllegalFeed( - f"The URL provided returned an invalid status code:\n{url=}, {status_code=}" + "The URL provided returned an invalid status code:", + url=url, status_code=status_code ) # Check the contents is actually an RSS feed. feed = parse(feed_data) if not feed.version: raise IllegalFeed( - f"The provided URL '{url}' does not seem to be a valid RSS feed." + "The provided URL does not seem to be a valid RSS feed.", + url=url ) return feed - async def create_new_feed(self, nickname: str, url: str, guild_id: int) -> Source: - """""" + """Create a new Feed, and return it as a Source object. + + Parameters + ---------- + nickname : str + Human readable nickname used to refer to the feed. + url : str + URL to fetch content from the feed. + guild_id : int + Discord Server ID associated with the feed. + + Returns + ------- + Source + Dataclass containing attributes of the feed. + """ + + log.info("Creating new Feed: %s - %s", nickname, guild_id) parsed_feed = await self.validate_feed(nickname, url) @@ -248,4 +275,110 @@ class Functions: ) await database.session.execute(query) + log.info("Created Feed: %s - %s", nickname, guild_id) + return Source.from_parsed(parsed_feed) + + async def delete_feed(self, url: str, guild_id: int) -> Source: + """Delete an existing Feed, then return it as a Source object. + + Parameters + ---------- + url : str + URL of the feed, used in the whereclause. + guild_id : int + Discord Server ID of the feed, used in the whereclause. + + Returns + ------- + Source + Dataclass containing attributes of the feed. + """ + + log.info("Deleting Feed: %s - %s", url, guild_id) + + async with DatabaseManager() as database: + whereclause = and_( + RssSourceModel.discord_server_id == guild_id, + RssSourceModel.rss_url == url + ) + + # Select the Feed entry, because an exception is raised if not found. + select_query = select(RssSourceModel).filter(whereclause) + select_result = await database.session.execute(select_query) + select_result.scalars().one() + + delete_query = delete(RssSourceModel).filter(whereclause) + await database.session.execute(delete_query) + + log.info("Deleted Feed: %s - %s", url, guild_id) + + return await Source.from_url(url) + + async def get_feeds(self, guild_id: int) -> list[Source]: + """Returns a list of fetched Feed objects from the database. + Note: a request will be made too all found Feed URLs. + + Parameters + ---------- + guild_id : int + The Discord Server ID, used to filter down the Feed query. + + Returns + ------- + list[Source] + List of Source objects, resulting from the query. + + Raises + ------ + NoResultFound + Raised if no results are found. + """ + + async with DatabaseManager() as database: + whereclause = and_(RssSourceModel.discord_server_id == guild_id) + query = select(RssSourceModel).where(whereclause) + result = await database.session.execute(query) + rss_sources = result.scalars().all() + + if not rss_sources: + raise NoResultFound + + return [await Source.from_url(feed.rss_url) for feed in rss_sources] + + async def assign_feed( + self, url: str, channel_name: str, channel_id: int, guild_id: int + ) -> tuple[int, Source]: + """""" + + async with DatabaseManager() as database: + select_query = select(RssSourceModel).where(and_( + RssSourceModel.rss_url == url, + RssSourceModel.discord_server_id == guild_id + )) + select_result = await database.session.execute(select_query) + + rss_source = select_result.scalars().one() + + insert_query = insert(FeedChannelModel).values( + discord_server_id = guild_id, + discord_channel_id = channel_id, + rss_source_id=rss_source.id, + search_name=f"{rss_source.nick} #{channel_name}" + ) + + insert_result = await database.session.execute(insert_query) + return insert_result.inserted_primary_key.id, await Source.from_url(url) + + async def unassign_feed( self, assigned_feed_id: int, guild_id: int): + """""" + + async with DatabaseManager() as database: + query = delete(FeedChannelModel).where(and_( + FeedChannelModel.id == assigned_feed_id, + FeedChannelModel.discord_server_id == guild_id + )) + + result = await database.session.execute(query) + if not result.rowcount: + raise NoResultFound diff --git a/src/utils.py b/src/utils.py index 8bc0ed9..d4a03f5 100644 --- a/src/utils.py +++ b/src/utils.py @@ -2,11 +2,21 @@ import aiohttp import logging +import async_timeout from discord import Interaction, Embed, Colour log = logging.getLogger(__name__) +async def fetch(session, url: str) -> str: + async with async_timeout.timeout(20): + async with session.get(url) as response: + return await response.text() + +async def get_unparsed_feed(url: str): + async with aiohttp.ClientSession() as session: + return await fetch(session, url) + async def get_rss_data(url: str): async with aiohttp.ClientSession() as session: async with session.get(url) as response: @@ -30,25 +40,94 @@ async def audit(cog, *args, **kwargs): await cog.bot.audit(*args, **kwargs) -async def followup_error(inter: Interaction, title: str, message: str, *args, **kwargs): - """Shorthand for following up on an interaction, except returns an embed styled in - error colours. - Parameters - ---------- - inter : Interaction - Represents an app command interaction. - """ +# https://img.icons8.com/fluency-systems-filled/48/FA5252/trash.png - await inter.followup.send( - *args, - embed=Embed( + +class FollowupIcons: + error = "https://img.icons8.com/fluency-systems-filled/48/DC573C/box-important.png" + success = "https://img.icons8.com/fluency-systems-filled/48/5BC873/ok--v1.png" + trash = "https://img.icons8.com/fluency-systems-filled/48/DC573C/trash.png" + info = "https://img.icons8.com/fluency-systems-filled/48/4598DA/info.png" + added = "https://img.icons8.com/fluency-systems-filled/48/4598DA/plus.png" + assigned = "https://img.icons8.com/fluency-systems-filled/48/4598DA/hashtag-large.png" + + +class Followup: + """Wrapper for a discord embed to follow up an interaction.""" + + def __init__( + self, + title: str = None, + description: str = None, + ): + self._embed = Embed( title=title, - description=message, - colour=Colour.red() - ), - **kwargs - ) + description=description + ) + + async def send(self, inter: Interaction, message: str = None): + """""" + + await inter.followup.send(content=message, embed=self._embed) + + def fields(self, inline: bool = False, **fields: dict): + """""" + + for key, value in fields.items(): + self._embed.add_field(name=key, value=value, inline=inline) + + return self + + def image(self, url: str): + """""" + + self._embed.set_image(url=url) + + return self + + def error(self): + """""" + + self._embed.colour = Colour.red() + self._embed.set_thumbnail(url=FollowupIcons.error) + return self + + def success(self): + """""" + + self._embed.colour = Colour.green() + self._embed.set_thumbnail(url=FollowupIcons.success) + return self + + def info(self): + """""" + + self._embed.colour = Colour.blue() + self._embed.set_thumbnail(url=FollowupIcons.info) + return self + + def added(self): + """""" + + self._embed.colour = Colour.blue() + self._embed.set_thumbnail(url=FollowupIcons.added) + return self + + def assign(self): + """""" + + self._embed.colour = Colour.blue() + self._embed.set_thumbnail(url=FollowupIcons.assigned) + return self + + def trash(self): + """""" + + self._embed.colour = Colour.red() + self._embed.set_thumbnail(url=FollowupIcons.trash) + return self + def extract_error_info(error: Exception) -> str: class_name = error.__class__.__name__ From eb3547f604f693f59618d17317acaa87181c74cf Mon Sep 17 00:00:00 2001 From: corbz Date: Sun, 24 Dec 2023 23:01:52 +0000 Subject: [PATCH 2/3] Create pyrss.png --- pyrss.png | Bin 0 -> 13606 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 pyrss.png diff --git a/pyrss.png b/pyrss.png new file mode 100644 index 0000000000000000000000000000000000000000..1eb9ebea7544a5b8a2b7cf55c6285886c8167756 GIT binary patch literal 13606 zcmb_@Wl&tr(=S1TLvWV_5AN=o0Kwf|7I&B6!Gk-&-4=HV?hcE?0>RzkZl34X|9#)8 zTldSoAGYf3IWyBUJ>5Oi-M^0brXY=qM2G|h1%)arBcTih^^WWB?>#)^%~5-2Ddf+0 zD{=8}vf|?84o>#wR<>qPP_*&B@q#jaBE$m*Duv4QILF`mDa2jtq9S<~=&|#}N^rDY zSz4}<(;Gg~F~tPg^z_6Ilm$oCSD{q}k>9`D{#Sq(<98qI)2=xrkD zE9M|`{1*xhxF5vGC8EK{_dRc$?oH;7)-&=*RDrK}tFQPl&}|}ZR;wF>jcY>ho+rDL z)^8TIyN_uO$E$Gpp?g=m2V6B&ZUaV1$RAZxP?zJMIYNvvJ%oSRkHGXbi8zd(RMyAN zB9MR4E3^*?7B_5{SUw^>BRx1d*h!~q0rI`PNR1xrFs`l|C43io=pYMci#h_XxB;9g|s* zD@WQQcSEI~KKQB8e#1t*%@le$I?KGuI-{)wN@2iKNNCQ$29bv^!HQx?*anfmr{ogF zfW^!pe;*Vs|KA=&`>EdtksD({n$;ryw}=1P@&7Qwe|7wyBmAf1e;)qdNW)_4XihswLfLs+`!k;!x5v@eSP5aZ4odHl>G_&F$iDN55JY(| zgpCZoOM;um*Th}o(rkRFmy>OaSo#iR%NgJC5(@ss{&mchAWhrUnnJPXCe6T!&O?ZG zkP;K%E%8FTuhO}X?!L4vCfj% znDg5rtZVet5_Ghc1ErJ~gu@>-RWxRUd zdTqu~lhiw#&R9wd0J7e?%uO$@^*6@A|50%?j;HU~t3rMt{(;tWK%U8uhD?CZi@Hp4 zezmC&V_UN-${fTEqrlMLO1_Sgg^*VTcMC$y5}X2|UEGgbDwN>=T+lAEr2n3OwB;ov zt%&OM`iR1;mu1d%#%gdKuE{_KjpHj1=%+SkI6VRsG*u=W@XUP?_=DkCwrh0lJH*@* zGV5?dO#G%pJ0=Dm1OuyVyJ4_}{Tr1>PzmA)? z2ndWH!R!clnxhzqug_|lrOs%@fQi3c1fE2LHWr5=D_fZbQjiT!_U_^n`y#=|^bFN+ z%HxMsYNd+!pf`|&u961vlLfdid{UlCleC5IKJO>q@U#lxmfH6;b1_nzUT&j0v$`#| zSz1&Y{pEgFXJW&b8O`Sg^QhQez7fW55L5!wy!XP*VK|VN8rOp_QWg-cQr!o2{ zwI(!mg}CJSp7a^N!BuV_w^oft+=Tr(j7HJXjxDOKr%4n>yRUJDw(BW=b!l;xFh;6l z4*hK>iscdMb2ez{t32wQFqF<&kpE?MUcC-qOL*mD!R4|!e%~$&Db1)ffk0H z9>83n^ZC3#@{VGX{&I!~<2ow-v(5bqS@%$!iwCC8eT$ILsQ=@w)gyz5^{7NJQ&ZmP z`EHIVSel4E=e8=eP^g1Xin8&XHD~-PaaLS6UDtH@is6zFhNkJ566Lz@fvgV{mBIh) zLxauoO45AJrrToJ=!i(!$iga^ve$e5Sz{kb^^)t4$S}YSDaIEM{>^*u))X%D$$#^P zOx#VM6r$)Jw5_Crk71tXhJ^f30~F8{O+IM)ez+4pwU_P!tKGH2Au5Ao*=nNwxGp{h z&bOENlee*$z#x^&qWnFuw|URzrECq1YRm36V*N)kwh9Sb(ZMTUyRS?vb(;O?OF)eZ zkN|pBc36}FLKd==NvnwiIKyFQWSq|-D4en6hdJ2ucIUZG}vWe2nOu(m70n zS9t)4?m*c5Pd8*Szi`#16fxIzXgyO4H*Q1ed3O~QF-DL-g&n9X5I2*KPjMleCnIA${a0}e$K=)>3O zpxdD3@O6~0(Xt8|;f&3HwThz_zf7foOV6%eg~bW8Le+t+WsBQT$!SHx_T9jZPqMDC z%is>640QR0#S`;6B)}~a<*Pnt*zfm{smcM2slRaLmgnKnaZd&h@H%@$BAsIu3u4ti zB-~OMBjan^BwsSYF}If+5HWF|Wbif`b1k8245bgL#I)kp!l6!HISLA2Bj5o_Kt3DC zeCNr~&%a^X+aHKK!{z&NIesp?enb5gjhd2ygXq#Fvdo5pQw!H%lq5zc33S;&P0Xn4 zj)ZTkJ)R*bkjuux)R`h&sI!zAC~+(+zN=~j*%rIlSpAy4GI2!7U6@;iNyrzZ^C)dWv4%LzXPN++xXg!I+Cx?B zHaQ(nyC^k;JddfbgXtSpeJbo^^BUk8bm5d?QRv5+53E5pYoT z56Ms^h>X6V_iz7g2F>?pDjoa?J02mU#_h7ax6lgbCIGMgSrgT$*l4nEYAI-}I${Yr zN;#55C!^V3gIz!AZl`WoUTdlmr}3B-xEv(G3bXb^z83mVJLaYnlAG(f;Y_*ZU9iMv zw_wdxVh@rJ+AG?z(EDT=$jlumW8@7Kk-b+{cFpV!J$J!8Q_HA2cY#Uohge*YUCzQ5 z6lBM6YD0oUR|tiiCf(4uXdDU`d_8v;;zGU2Lau|q9S*qYZNY{6n57ztA^JXU&*l- zuTzHRMjFs5oojBtH{N8Gd&@$6PkwzGnEoB)0x>`kgE%sHwjnD%mOQ&SLIK$bZvQJ(cKVVifcN_MK;ba{Go2lDWlLrR{danq+#4NK=XFx%BWz}I75MZC z&_A?xzaAy;Bz!^oa@{HoW`3ElmpG}oEuf`|{<^NPP#$6N#Y%HX7Yt~0^&F*%<* z43f#gHtJa9cLP41c?9B`?Eo=JJ|#}U5=&(DG6Woe!~kLRW|^g3lUF~umBkIlPB-w? z#9^m`H)%D4wGWTm*%{l}hNTh2r#_=<9!|#c^l~d;TQ}rR#?c~MuKOFWB}B%~EH0gi zDL+N(!hABnKIOcgMua&~*nrTyt#G1L?k%^|?Ed5(>ycg7Xm{ID#P@< z|s2?!u{9CBbg^a$E*aGW8R{l6)9>0-LxRrjAHjMkoUwDC49>x&!di&AENf14;x@QQ*Gzvpe zY<7gSzq0b?nya5jh(&gNR+*EqCW$ifj>p?9K@dQvO_<2wVWu$VE=+Aw%G@ejOE;bj#iLc4!oeNdi$C zmwSX(_bk3b*|kHpypE@dxbaZ{kxAc#w*>DZLA|t1oO0t?JfYq%!hynSgkIW28%k=7 z^sN~(XZ9wAtrE6L3_#KQ(Ki_Qx0MP*J8Hs`780S0LN!;PcpbRWN@8WA=a1|mwTGVO zNH}fLL$_}v3>13!J*()##PgIlVtrqu0sohwKqjF#O)_Ae z_?CRKJFU-_PBE{)t8s|nS||c(NUXmNAVX$^5-1ESC~fv>(%NN9V8F&dFoy)QRtllB8v~2uAifJgnCxOb2PUB*|M}CEXkx3h?=3+V9f2Fnu&G ze_w~}a0`o)8L+(1;84Tt#Nb`5<^bRt+#n$_^f0zF*~L{aWpr1zhZXk5wg2;}wVBp< zRn>=#ZO7r3R`4%HQ^UYUW2zPTt&A@_0Eqs+YGG+yg80rs3tCpdB3OPaC9=Alcg}z|rZ{me&Wi znxX(_;I-ECrk+WsqH9b=U646I@4SUk$X+=>j*6}_0A7h3Jf(PmW+{M{A+@+1jyo3M z0QLG@&vGdoOyFMS82-`Ijyr?(Q1khe`E<{>$zG@LeRtS2MQTQ1A)L&DxbMe?N&yDp zW@YaytXxX(aN9RxbwD+WB=-+7($H!#BZ}r=D}vUh&ieU69A+H+PcJ@F`h9TCbUl5Y~_hR**ZM z&m?x-n0)Bbux|3I#v4`pYyJ>`Q)%`3!$a?Hv@9_OPL34=5j^uTb{bStj;g%VRbUCgL?`lQ<*SOC zGR#!SjOZEMv8Gs3d6<(?ZLUA8|8Ln5l@VkI; z55zpqXo0c*qu4?X3yModNjhJm#WlVlm4CgmnP=pLBEwmYDYL?<1&lRsu({SLTRDi& zgmKAO8zp5Fb4FjhUtJ*#=drODjwj5$qHQC^0g#8!e+~9rG-h3L$Dg^2nv<~W2SjxM z=n^<>atyE_n_f5@t>(@@5(DQ-P9l=;Xn7WD>07gi&LwMYl$Zhk*qUK!Clfw5GM6tm zZ%EY`6b=ZsIt%LSLUD_dsT|pJyG!PCb1&8!)s2%mVT^}Se__u3kQMj>+tU$T%X57k z91{j5U7%j*q#~mDCBenlcwR-664=yO5_#G$cOtcX(d`$xxh^>vDA?2!^YPpJDSELG zS;ir!(Wi8Neq{kKQ-o5riRy^C^>tx9r3e_!XkSC#z!&_U&cs^6<~t>xbDf?gBegtb zJ6-V@Xc)+`lBsR1%ZSSxZo%8FR14L@vO+VukEfH39P3EK`TPzmq&;#j-*>R!Za>|-Zz~Hw4_FvI zmm)1mS*OX7bUmVaY{qkbsXRzeSbPZjGJP|ICx~0`DPRE600{xP{@M~c-=?4RUvT}X zvUs{a11E(S2IFD$D1mkD6j2z$5Mj>5JHOyGW1PNy&{Gm1geU)hT5=wb4mbMv6?)|`4Bm8{kQ+aS4};%a8#GK3cz{?2zyh3R|mT)@8Lk=!a)w}v8 zj$t^x^B&MV?h_p*#mf`4t2WtKJBEPM1o}j&o0t8RACW!Tc+sWlbStMdVuMkN!5{r30{kywZ*F0Exz>*Z*=YWLUHi~C$0UX9`E3UoN0AxA&x-YA7l3e=^1 ze3G=L1X|+|bx)Z=Jpar$R+QJfo5`)4fY7JhGfbXq43OJh5GoWjdRg{GLNHCYPpHCz?)QPNdBPDv_CuF zvsV6W(=!1JGE8asI858jb;G=)r6NtVD`z=TZ{%E=w)H#}7t4OU7wOj+QS&hwJ8Nty zrrupi_GyE$pe9oVaBV&}SqIp6YU&-ys?n&M<(e@i2aWMTM*C)=<~`j zl>smgmCF~H^92Ph2aWz!mdp+T+)RRy5Q_F;vb-pbzArKZK)eYw zb}mkXhe>>>DFjLYywFd@HbmMw+HjM>^( z57BY1c`Y&)H@Ettc)c%WWwZ`=(2T^mx)`OFWkSyBza;mj-tX$>L0b$$~%Z#c_hqoVi+NIAYzW5 z&-f}3A{!ybaJEc~q$_%`W-4kAHk6#088Vga*aG9ajp0XX-QQHbH_b}R=D+S^q zK(M(WaNKtY7(wBhqp0-|A|U`Z5e=mg7cCq__g|0;ge@iThRQmF z9f`ONTByFRM*-u{2g!fv0J3swl@VkY7crt$Mb=a6loVz@Q)2W+-v0X)Mj(inlG#`X zCo#ADn1_A_r?U!TM<8Uln2e9gVyBmtbyyKw=@-67x_u}U*ZG$rzh_dzzoq=pn1JJT zRIMNhkC=m`eq;c9)7pUk?HEX43oGd(rrBU^0)KZI&G{(6iYpuLB%ApL6RM~8#{wDuekNY|fF=lV zmB>W;5&0RR+)kL@FZO@)gTzy@zDsZ*PcTd3BSd7(ObQ!~=8r#pd$9d?iOvqdB4)k5 zu(Q*`+NvVsY86T4rAqXC<0c}b@P8(LLXsnxO9wvBK84rLNWk&$pkEKl@`J@$mX%Cl z`|z)1g;nOVo7BV<%(*MgabOUkUxvd;uL%gS^Cb(jl z;?|msjo8R*C>-@0@qZ)`P&Eg!VmY3s%+|=aj%CAs^Cv zrq>x-FDqhyN2n|^NxaZ;E9DXR_KdxDHP{+a?-Ch2`jv=Cpb3?GuC;cp^-7PEoWDJ% z6Mqc<^ZsT`kp9x9Xass08qUeU;UnG>s<(!f<)Va<$)eFSJg6DvV~UYEEcGO#a4YrW zO_brH$ftLm2;*&eCfpoA@kxD|CQ)airAEwLh_Rwpe&SVNVA^EX?St5duBF(CEat4K z?7_^Zhx+`YJ!^X=4486KoQ)iZOHe>mROkwT- z?ia$>WAy2VMG>k6C2K9!aTv{;J7ey(h$P)Dfx?9v77NOTUywwhv%?{SIql*87x8N~ zfuh!H{aS6KyFsI;6Nfu-^OAI#(AL;6ZHT&Gei=8w@6BloNbw}h5^jmP(j>%bBjJB} zuf9fFF-H%ZnbB|19?1Qw=koeth2-fkvSg*SXukXfAMOEiUrHDu_dU|NE^tJ8k zEsdWI+8yf*Ogc=5@V1vG-Xrgy@B16(i!-Yb=2z9&9}q8*KrP+G<|?<dR_S0uf5B)`yT27=wd zBq>QR2R=WXwudQt|F5JcOxu$NzbDbQCcB{5DOWn|zy(n3+?Ga{TLQV3ewEv~+a*I> zH7c5}VKpBFHHVp0KOrvAgU@}?^}TRkUl~{)_&#TCG`pgam3TAT@}iKvU?0CXh|xQM z>?cFl94Nx1S^VwYbJ|Q_=k!Tgv|$B&W|iVj1F_cZ|~s#a!BjAVJAfL&h1*Od>x8Q&2agpj&k;g**1(*4K?YqPredhoIZ z2KHJ6Fypm7LvvJ$Y0-QUBQ;7geN%->v@=gP7-B)|tUr~?V}JWq&1DBw`~^cgaP=)r|-n$lLZ_E`*L31UUV~QUEPJcTn`)9hu18pS>WX;@tM}#*F_w6OG z_M?qkU%>(?Gg*0 zuU3_VMtsHBnSgRcP4A*;wq}$Lgx_7&L*n%X2VS~FX)Ar#S6Q}PFL0mxoi%~!Dv^j z*$eHG7jsq08DJWUSM_yAtz~gUOL07>OLxVoAZR&>LDViVdBtg0=-uEOu2h(WEzvN@ zIOes7?lQaT4_xr2EUF7@R1QggoiaZ(brXwB^o(~U3-YhvZ$ofE8rWE|>7Cvo;;Vr_ zjA6P8rD;Y7Vpeaa)SHa;kQqeHB@aL2PmqN9==V2LDAk>@a|n$t`P`hc&^FCY3GoTT zts6tBi!pq&GfE^(z4SsRNX_ch%Gd30>~y@8F%Kj{hAWG&`vTCiT{coVq~uTp!w)n5 zP=-+nbl-*G5g?J4l5j@$l)kE70Q?apCrd_m6noRpGeqR0&)gfqc%A6}H(i*W!}{oT z;^uf_Q0(Z?G>twzWFMumgkF0|+$u^%~q{5Zd2 z5eA?oW7bwOS3v*;erkoD6~%MOAGw}C7ft04o#=6-%7%En@X{)zjG%|0qMmXNS-`Y( zj38;M)c<5oVDwN+1lOJ_i>Fw}wS0eiK6VGnF^KPg@m<@q?C=DW=*iQYH11tl$G%x7!Ba@Zv06Lr5 z(&Rw)PqO1@0kcT8gjjizOADS$_F5c;#uDP1WtJ)h@U#p;ksN6z;Ic^0k(0*0^9$kc zOY4zk*LqyWdJud_BS}9$(Pu~kPg*6RMvY+t9{wr*RjyZrVNB;)8@BL-IpgaV@`M^{XU6^CFxr;dU?r z=cJBB?y~A+PR1+FIfdjEP}SEcYWPd^CG6~H9z~a&X?Wsu*M~QiGQu4PC)EF#xEb!R zEPMvm5;uKUjNumBtwUD>v6sBfr z?T)B&IXLOm&nb|FS-cTuzY(_I0z6&_@N-}lMJ?{=vFJHRB1L;7_b1d^K-h7EIo<@6 z9h$?H3{OWSYg29rnVy)SVZ#%JDF{;Jxz}EFgCbl4BJ(nY);3FRz`)%3blv(neSgf( zTP=?h;l8CNqgS}p2a3!`Vi6ur z;jji9o9_&{$poixQzgH1emR-P+hpiMbe8UEbG$mG>m~|)#<{oeGDae+g7k!KJ9Jas zHk4sd4+r@%4G@EFF^-RsP1B_}2O2*&@w+uRX?T}yhy5~(Uom)f*5pHdEVlKX(VDs{ zN;cCcQs=iy30fgoI@wP?b|3@|r04JPe)w+U51-p{iT$^c{u!!esut zQ?Lzoc}j*;jWu7*pEF5Gw-2X!>y%9y%#ykLST%2uI~TkYk(y;c{FG|WX3FVuPZbze z0S>%s+dMZy&W&T!ZP$vG!~<0p^G9E%4A#R@B5<^Hh~*X+?dqSlBD>1&6XpK1G{%SG@_Q z_{boj9@WJ)8>!LGsl26wZMWz{%}wyR;wxu^Ybclrjw|+r4kyXxvK zuA)zVL#BDTW7f6xf>uvY6pw*Y`4J$vl_!`J-|SH)ddAT^2m z(lY)&H`#jMR;SvhmZ}s;)HjK)sgZGN0lAE{4l}i_<~32AwC1j9D+ax%ugkcK;)lD` z(K+c9Z*tl`O>tN=i@+#5%&Y`sh~h(5{2eDk1{U!tVEa7V?e?ZoJ$r8b_4n(2(`KNw zM&|d!@yuTyH?eshg{lg=P-Lo%3U_B)kUPwS0a*u{22*!z=eyty_Tfb{GP#B{1F{E_ z<{iI-H52Q~keehd^V)Y~7FS;<&3G4t$Le29etgE&`us=>^zyeHi<+OEQo%E`_l}GO z)n~c*2D$jw)fG}Zm49tvG&A!00D;rMPxh7yhsd;IlT^PZ)CAm%@|?3S`MyUZ4U}N) zH_q!M%EMb_gT!x+_gEp=ne*c`%%Wu7P&}lP8TKlFsZUMMZw>IPK`Y_%&Xm2rZRyMF zN{SFr0xFQRysc(DE`6wyZ4W+9FW|zc*#ilFvL~b~te0bVA{FPW{{@&fwaHbN%wYzM z=LX}_j^212*7>ofa1n){q?rCxVm=(T`;IgFj6FTg!oxTld9fHjXWY@sIG$>eM(3K& zgK(4$C%`B7w-S$BeB_EPAUsh)l6PtKtFvzq1Yz+_4%6{?Q&xUgy~XKDFp>EuLGU4B zvm?&$Dp7NMY6Lk8U$0G^ad&?B>F;TGwx@v;dQ)@odi=Q*0EIbab#i2>$R%}(mi&Fz zrc?`SuBf922s}`0q^&tScK?aC@Go*j@pmmi(#9k~;hHYXF6f-k>OXx z_|66--H8dwSK7JqtmPPVfb38QFL`OP4IR;tc!&1n5V)Sqj}px?W(yL4MPuLUIOZC% z$`aDTPl`9agtTo0CK2G0l{J$?Omr;uehWE0Hq!}mT>U~Soi`J8!4r7(?-N9?_Q=T{ zIw_VJwxgN3Avgx}kK*eyu+waNb7T21n)z+i6)fA|S{e}yHiq+XU1)yy;1J{3-u$VM z#hfw6lofLJ0{6LHNfhqAuLJw}ok8F%b9M8F>b06!naO?f83ulAyWc3Aid!+SsOw`7 zmoH;`6PEnuonww)|9Voj0K#93TS@zdyRglDNv?qPPhUIHXtG0k-eKW{gBI`&_ohQe zJ<{;b@xlrPC2T27>1>>mCIn!v1{tb#8=#XX#NTEe4pBajRo+X7gVwh$E(fQt2G935 zMB0?wRr+zcifH7a!9z{-P4kR~U^tJ{Q5H8}HDR&cTXbrksevnx#0}B?;sRdC(3EZ8 zsa^GCOP zJkt#IT64kz&wB4y!X_!3%#iMvu?}=Y4YTq;8)Ml~<*+|JBhB_44ayN9t~H}90~I}@ zKytAekb%(hJ2C~xC>wJ-bCJ!joKC;8giQ~K;Fiu?6=vJGgM5 zPCcT;uBGK59}<*m?1r~p`I(}1D0`eFV~5y&=@;`SDFfQ4q3M$qWD?}?{Mn^xg&hIR z0d)1wz0RYriRCJGQnnsiS9%z=a16$MKi@7QdtQnCjPyQ6{YlK=V&*^jLJvW&l~md! z@j1o~sO#Qo8_ZL211LGEVza{;;~2}oz7yfcJ?7)=vg(F9jR`?2A(t&|J| zTGdZfe{px;er1*LqK(TUG`JV!dNxG}a64TSS+@*)i&v=Gog#uXkX=Y;Ck-a7dqAG5 zCHj-W^_+uN4dN&(Lcl3a+86J~ol9{ZBi6OyhP`@BLX|%;j{#u4F9fN=;s8XKSv`gR zV(#3=tUx4Z$of-7f!ATV7S*NWjau^f^evtx2MQIclac5}xDvKF*KX=laiNS)!HY9U zmS@2(t3^3l1f(s?B};A)FH{Q?0+h>mlse#tz6kD-n*(a+o#|WJm7`F>>e~rv0yS^W zLS|A)2w5~V&!c2UR&jWbZ0_Hh>Z6*j^Nn$reY5=LC{DsOmWk}diHpZbyuQqm{ ziubgbGb60phgF^6aN}W{wrByHjHvib2O;eH#DhjG04?n|xND)##^kWc4hdIaL}~0& z!7JFchO6D-yUkM65POpT)h{JvcM$rMUz^kZicjv7#^#VoMnW5|0%Vc>FPhPJBA=)x zAtRdh8&9<%+=5UmyHq_PdBHVrqqCQqU+5o(BkgBKQZl3@-3$xSj2BPiK%5YyS*gwU zk7VCZ#WBvxIlgo!_c@$>oSGp`rLMGWDb-f>L3cZPQmTbNe4}i{n4Li*dqrJxGT0-& z+*C#D(hKmCITW3$5E<_NAzLm)0Xh)N?a^{S?^#-`0Jm}1h-wE&k0ITZ{-%ga_ zj7pg38Rv&B^e|X+B$Askm%%H2@h2yM-(xgv^}CcBtEvlg{JJ6p5K~YcwNxf=7ktkB zhFR4GT|a4m?0f(-yqfpIbU&CAnB|*isca91KD}Z7g$%Q)2y^?Jt86VW@`SqXWU)3u zr!W@M{b#L_$7u$O19X+2s|huN03=lkNqydTGxs%Ss+G9&@|p5JYL2xz~q@?0|adtv!E)c%J(f*YHhZ+GdB%rp*a-$W1p~mU9ZvAASobqd-C8}m_N{zK}ked7ZzxPoAMqllIYAA4c+Za##qd=w~?(Z1! zQ!!6U9@HhqsSAu_9_TwE>)U{Zg|JpxS0l76qCpaW+$_OhCS7QxRPIo1lVGphKi}f@ zD_Xs4X76&h8B1i8AL}4EcAM0#V4d#}NXrRy`~BiK&43FF^89tl{@P~?TW7vx zOF`mSvx8*>Z##Az$4YuFj76c9(JjTh9cJDBoR@dn%xQ4&IP92sualT-jL4XLQIW^c zID^*D(we0tOE=!*G0=23jK)}g$(sVsk=5|KCO?IMe=)6e=vtX;ueA~8{M2tzn$g% z$b!Sk3?C_iU4cC!C+fmaKVy39L*4d`vyztI4#^3LlFJAV`;s~`V*-L68I$h=Veh8o z#<72BuyyVf`}__VZ62Ox$4!-J%o_f?ki>pq^jC*KD@(9Gp){Vw^l8QNbM+Sl35M17 zPY^WVOn4+$uCbMB1)>=*SW^;GAyMHfQ)n;coNW(xdWss}AqKtwZ)F;PD?a|KUgMuakN;F>{O95S jtxV(pRv|)B@XWks6j;$Wl^zMHpn;N=RFJ3=GYtA)MSeu< literal 0 HcmV?d00001 From b837c54b3f3e77c0cc250e8b61942f8bf6a1fa46 Mon Sep 17 00:00:00 2001 From: corbz Date: Tue, 26 Dec 2023 23:46:54 +0000 Subject: [PATCH 3/3] tasks now trigger on set interval interval is every 10 minutes per day, exactly as the final digit of the minute is 0 --- src/extensions/tasks.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/extensions/tasks.py b/src/extensions/tasks.py index 8b5599a..8664b3b 100644 --- a/src/extensions/tasks.py +++ b/src/extensions/tasks.py @@ -4,6 +4,8 @@ Loading this file via `commands.Bot.load_extension` will add `TaskCog` to the bo """ import logging +import datetime +from os import getenv from time import process_time from discord import TextChannel @@ -23,6 +25,16 @@ from utils import get_unparsed_feed log = logging.getLogger(__name__) +TASK_INTERVAL_MINUTES = getenv("TASK_INTERVAL_MINUTES") + +times = [ + datetime.time(hour, minute, tzinfo=datetime.timezone.utc) + for hour in range(24) + for minute in range(0, 60, int(TASK_INTERVAL_MINUTES)) +] + +log.debug("Task will trigger every %s minutes", TASK_INTERVAL_MINUTES) + class TaskCog(commands.Cog): """ @@ -38,11 +50,11 @@ class TaskCog(commands.Cog): async def on_ready(self): """Instructions to call when the cog is ready.""" - self.rss_task.start() # pylint disable=E1101 + self.rss_task.start() log.info("%s cog is ready", self.__class__.__name__) - @tasks.loop(minutes=10) + @tasks.loop(time=times) async def rss_task(self): """Automated task responsible for processing rss feeds."""