From deefeb11068289bfff1d4296e04c129710f0584b Mon Sep 17 00:00:00 2001 From: stijndcl Date: Thu, 13 Oct 2022 20:00:46 +0200 Subject: [PATCH] Check for free games --- .../versions/9fb84b4d9f0b_add_free_games.py | 30 +++++++ database/crud/free_games.py | 20 +++++ database/schemas.py | 9 +++ didier/cogs/tasks.py | 32 +++++++- didier/data/embeds/free_games.py | 67 ++++++++++++++++ didier/data/embeds/ufora/announcements.py | 74 ------------------ didier/data/rss_feeds/__init__.py | 0 didier/data/rss_feeds/free_games.py | 45 +++++++++++ didier/data/rss_feeds/ufora.py | 78 +++++++++++++++++++ didier/utils/discord/colours.py | 14 ++++ settings.py | 3 + 11 files changed, 297 insertions(+), 75 deletions(-) create mode 100644 alembic/versions/9fb84b4d9f0b_add_free_games.py create mode 100644 database/crud/free_games.py create mode 100644 didier/data/embeds/free_games.py create mode 100644 didier/data/rss_feeds/__init__.py create mode 100644 didier/data/rss_feeds/free_games.py create mode 100644 didier/data/rss_feeds/ufora.py diff --git a/alembic/versions/9fb84b4d9f0b_add_free_games.py b/alembic/versions/9fb84b4d9f0b_add_free_games.py new file mode 100644 index 0000000..d04f223 --- /dev/null +++ b/alembic/versions/9fb84b4d9f0b_add_free_games.py @@ -0,0 +1,30 @@ +"""Add free games + +Revision ID: 9fb84b4d9f0b +Revises: 11388e39bb90 +Create Date: 2022-10-13 19:17:58.032182 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "9fb84b4d9f0b" +down_revision = "11388e39bb90" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "free_games", sa.Column("free_game_id", sa.Integer(), nullable=False), sa.PrimaryKeyConstraint("free_game_id") + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("free_games") + # ### end Alembic commands ### diff --git a/database/crud/free_games.py b/database/crud/free_games.py new file mode 100644 index 0000000..39b98b6 --- /dev/null +++ b/database/crud/free_games.py @@ -0,0 +1,20 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database.schemas import FreeGame + +__all__ = ["add_free_games", "filter_present_games"] + + +async def add_free_games(session: AsyncSession, game_ids: list[int]): + """Bulk-add a list of IDs into the database""" + games = [FreeGame(free_game_id=game_id) for game_id in game_ids] + session.add_all(games) + await session.commit() + + +async def filter_present_games(session: AsyncSession, game_ids: list[int]) -> list[int]: + """Filter a list of game IDs down to the ones that aren't in the database yet""" + query = select(FreeGame.free_game_id).where(FreeGame.free_game_id.in_(game_ids)) + matches: list[int] = (await session.execute(query)).scalars().all() + return list(set(game_ids).difference(matches)) diff --git a/database/schemas.py b/database/schemas.py index e5322c1..ffda6ba 100644 --- a/database/schemas.py +++ b/database/schemas.py @@ -33,6 +33,7 @@ __all__ = [ "DadJoke", "Deadline", "EasterEgg", + "FreeGame", "GitHubLink", "Link", "MemeTemplate", @@ -174,6 +175,14 @@ class EasterEgg(Base): startswith: bool = Column(Boolean, nullable=False, server_default="1") +class FreeGame(Base): + """A temporarily free game""" + + __tablename__ = "free_games" + + free_game_id: int = Column(Integer, primary_key=True) + + class GitHubLink(Base): """A user's GitHub link""" diff --git a/didier/cogs/tasks.py b/didier/cogs/tasks.py index 14e7699..cf0164e 100644 --- a/didier/cogs/tasks.py +++ b/didier/cogs/tasks.py @@ -18,7 +18,8 @@ from didier.data.embeds.schedules import ( get_schedule_for_day, parse_schedule_from_content, ) -from didier.data.embeds.ufora.announcements import fetch_ufora_announcements +from didier.data.rss_feeds.free_games import fetch_free_games +from didier.data.rss_feeds.ufora import fetch_ufora_announcements from didier.decorators.tasks import timed_task from didier.utils.discord.checks import is_owner from didier.utils.types.datetime import LOCAL_TIMEZONE, tz_aware_now @@ -48,6 +49,7 @@ class Tasks(commands.Cog): self._tasks = { "birthdays": self.check_birthdays, + "free_games": self.pull_free_games, "schedules": self.pull_schedules, "reminders": self.reminders, "ufora": self.pull_ufora_announcements, @@ -61,6 +63,10 @@ class Tasks(commands.Cog): if settings.BIRTHDAY_ANNOUNCEMENT_CHANNEL is not None: self.check_birthdays.start() + # Only pull free gmaes if a channel was provided + if settings.FREE_GAMES_CHANNEL is not None: + self.pull_free_games.start() + # Only pull announcements if a token was provided if settings.UFORA_RSS_TOKEN is not None and settings.UFORA_ANNOUNCEMENTS_CHANNEL is not None: self.pull_ufora_announcements.start() @@ -128,6 +134,26 @@ class Tasks(commands.Cog): async def _before_check_birthdays(self): await self.client.wait_until_ready() + @tasks.loop(minutes=15) + async def pull_free_games(self, **kwargs): + """Task that checks for free games occasionally""" + _ = kwargs + + # No channel to send the embeds to + if settings.FREE_GAMES_CHANNEL is None: + return + + async with self.client.postgres_session as session: + games = await fetch_free_games(self.client.http_session, session) + channel = self.client.get_channel(settings.FREE_GAMES_CHANNEL) + + for game in games: + await channel.send(embed=game.to_embed()) + + @pull_free_games.before_loop + async def _before_free_games(self): + await self.client.wait_until_ready() + @tasks.loop(time=DAILY_RESET_TIME) @timed_task(enums.TaskType.SCHEDULES) async def pull_schedules(self, **kwargs): @@ -166,6 +192,10 @@ class Tasks(commands.Cog): # Only replace cached version if all schedules succeeded self.client.schedules = new_schedules + @pull_schedules.before_loop + async def _before_pull_schedules(self): + await self.client.wait_until_ready() + @tasks.loop(minutes=10) @timed_task(enums.TaskType.UFORA_ANNOUNCEMENTS) async def pull_ufora_announcements(self, **kwargs): diff --git a/didier/data/embeds/free_games.py b/didier/data/embeds/free_games.py new file mode 100644 index 0000000..7949b30 --- /dev/null +++ b/didier/data/embeds/free_games.py @@ -0,0 +1,67 @@ +import html +from typing import Optional + +import discord +from overrides import overrides +from pydantic import validator + +from didier.data.embeds.base import EmbedPydantic +from didier.utils.discord import colours + +__all__ = ["SEPARATOR", "FreeGameEmbed"] + +SEPARATOR = " • Free • " + + +def _get_store_info(store: str) -> tuple[Optional[str], discord.Colour]: + """Get the image url for a given store""" + store = store.lower() + + if "epic" in store: + return ( + "https://cdn2.unrealengine.com/" + "Unreal+Engine%2Feg-logo-filled-1255x1272-0eb9d144a0f981d1cbaaa1eb957de7a3207b31bb.png", + colours.epic_games_white(), + ) + + if "gog" in store: + return ( + "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/GOG.com_logo.svg/1679px-GOG.com_logo.svg.png", + colours.gog_purple(), + ) + + if "steam" in store: + return ( + "https://upload.wikimedia.org/wikipedia/commons/thumb/8/83/" + "Steam_icon_logo.svg/2048px-Steam_icon_logo.svg.png", + colours.steam_blue(), + ) + + return None, discord.Colour.random() + + +class FreeGameEmbed(EmbedPydantic): + """Embed for free games""" + + dc_identifier: int + link: str + summary: str = "" + title: str + + @validator("title") + def _clean_title(cls, value: str) -> str: + return html.unescape(value) + + @overrides + def to_embed(self, **kwargs) -> discord.Embed: + name, store = self.title.split(SEPARATOR) + embed = discord.Embed(title=name, url=self.link, description=self.summary or None) + embed.set_author(name=store) + + image, colour = _get_store_info(store) + if image is not None: + embed.set_thumbnail(url=image) + + embed.colour = colour + + return embed diff --git a/didier/data/embeds/ufora/announcements.py b/didier/data/embeds/ufora/announcements.py index 7ded012..abca631 100644 --- a/didier/data/embeds/ufora/announcements.py +++ b/didier/data/embeds/ufora/announcements.py @@ -1,18 +1,11 @@ -import re from dataclasses import dataclass, field from datetime import datetime from typing import Optional from zoneinfo import ZoneInfo -import async_timeout import discord -import feedparser -from aiohttp import ClientSession from markdownify import markdownify as md -from sqlalchemy.ext.asyncio import AsyncSession -import settings -from database.crud import ufora_announcements as crud from database.schemas import UforaCourse from didier.data.embeds.base import EmbedBaseModel from didier.utils.discord.colours import ghent_university_blue @@ -20,8 +13,6 @@ from didier.utils.types.datetime import LOCAL_TIMEZONE, int_to_weekday from didier.utils.types.string import leading __all__ = [ - "fetch_ufora_announcements", - "parse_ids", "UforaNotification", ] @@ -107,68 +98,3 @@ class UforaNotification(EmbedBaseModel): ":" f"{leading('0', str(self.published_dt.second))}" ) - - -def parse_ids(url: str) -> Optional[tuple[int, int]]: - """Parse the notification & course id out of a notification url""" - match = re.search(r"\d+-\d+$", url) - - if not match: - return None - - spl = match[0].split("-") - return int(spl[0]), int(spl[1]) - - -async def fetch_ufora_announcements( - http_session: ClientSession, database_session: AsyncSession -) -> list[UforaNotification]: - """Fetch all new announcements""" - notifications: list[UforaNotification] = [] - - # No token provided, don't fetch announcements - if settings.UFORA_RSS_TOKEN is None: - return notifications - - courses = await crud.get_courses_with_announcements(database_session) - - for course in courses: - course_announcement_ids = list(map(lambda announcement: announcement.announcement_id, course.announcements)) - - course_url = ( - f"https://ufora.ugent.be/d2l/le/news/rss/{course.course_id}/course?token={settings.UFORA_RSS_TOKEN}" - ) - - # Get the updated feed - with async_timeout.timeout(10): - async with http_session.get(course_url) as response: - feed = feedparser.parse(await response.text()) - - # Remove old notifications - fresh_feed: list[dict] = [] - for entry in feed["entries"]: - parsed = parse_ids(entry["id"]) - if parsed is None: - continue - - if parsed[0] not in course_announcement_ids: - fresh_feed.append(entry) - - if fresh_feed: - for item in fresh_feed: - # Parse id's out - # Technically this can't happen but Mypy angry - parsed = parse_ids(item["id"]) - - if parsed is None: - continue - - # Create a new notification - notification_id, course_id = parsed - notification = UforaNotification(item, course, notification_id, course_id) - notifications.append(notification) - - # Create new db entry - await crud.create_new_announcement(database_session, notification_id, course, notification.published_dt) - - return notifications diff --git a/didier/data/rss_feeds/__init__.py b/didier/data/rss_feeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/didier/data/rss_feeds/free_games.py b/didier/data/rss_feeds/free_games.py new file mode 100644 index 0000000..abc8753 --- /dev/null +++ b/didier/data/rss_feeds/free_games.py @@ -0,0 +1,45 @@ +import logging +from http import HTTPStatus + +import feedparser +from aiohttp import ClientSession +from sqlalchemy.ext.asyncio import AsyncSession + +from database.crud.free_games import add_free_games, filter_present_games +from didier.data.embeds.free_games import SEPARATOR, FreeGameEmbed + +logger = logging.getLogger(__name__) + + +__all__ = ["fetch_free_games"] + + +async def fetch_free_games(http_session: ClientSession, database_session: AsyncSession) -> list[FreeGameEmbed]: + """Get a fresh list of free games""" + url = "https://pepeizqdeals.com/?call_custom_simple_rss=1&csrp_cat=12" + async with http_session.get(url) as response: + if response.status != HTTPStatus.OK: + logger.error("Free games GET-request failed with status code %d." % response.status) + return [] + + feed = feedparser.parse(await response.text()) + + games: list[FreeGameEmbed] = [] + game_ids: list[int] = [] + + for entry in feed["entries"]: + # Game isn't free + if SEPARATOR not in entry["title"]: + continue + + game = FreeGameEmbed.parse_obj(entry) + games.append(game) + game_ids.append(game.dc_identifier) + + # Filter out games that we already know + filtered_ids = await filter_present_games(database_session, game_ids) + + # Insert new games into the database + await add_free_games(database_session, filtered_ids) + + return list(filter(lambda x: x.dc_identifier in filtered_ids, games)) diff --git a/didier/data/rss_feeds/ufora.py b/didier/data/rss_feeds/ufora.py new file mode 100644 index 0000000..97db8eb --- /dev/null +++ b/didier/data/rss_feeds/ufora.py @@ -0,0 +1,78 @@ +import re +from typing import Optional + +import async_timeout +import feedparser +from aiohttp import ClientSession +from sqlalchemy.ext.asyncio import AsyncSession + +import settings +from database.crud import ufora_announcements as crud +from didier.data.embeds.ufora.announcements import UforaNotification + +__all__ = ["parse_ids", "fetch_ufora_announcements"] + + +def parse_ids(url: str) -> Optional[tuple[int, int]]: + """Parse the notification & course id out of a notification url""" + match = re.search(r"\d+-\d+$", url) + + if not match: + return None + + spl = match[0].split("-") + return int(spl[0]), int(spl[1]) + + +async def fetch_ufora_announcements( + http_session: ClientSession, database_session: AsyncSession +) -> list[UforaNotification]: + """Fetch all new announcements""" + notifications: list[UforaNotification] = [] + + # No token provided, don't fetch announcements + if settings.UFORA_RSS_TOKEN is None: + return notifications + + courses = await crud.get_courses_with_announcements(database_session) + + for course in courses: + course_announcement_ids = list(map(lambda announcement: announcement.announcement_id, course.announcements)) + + course_url = ( + f"https://ufora.ugent.be/d2l/le/news/rss/{course.course_id}/course?token={settings.UFORA_RSS_TOKEN}" + ) + + # Get the updated feed + with async_timeout.timeout(10): + async with http_session.get(course_url) as response: + feed = feedparser.parse(await response.text()) + + # Remove old notifications + fresh_feed: list[dict] = [] + for entry in feed["entries"]: + parsed = parse_ids(entry["id"]) + if parsed is None: + continue + + if parsed[0] not in course_announcement_ids: + fresh_feed.append(entry) + + if fresh_feed: + for item in fresh_feed: + # Parse id's out + # Technically this can't happen but Mypy angry + parsed = parse_ids(item["id"]) + + if parsed is None: + continue + + # Create a new notification + notification_id, course_id = parsed + notification = UforaNotification(item, course, notification_id, course_id) + notifications.append(notification) + + # Create new db entry + await crud.create_new_announcement(database_session, notification_id, course, notification.published_dt) + + return notifications diff --git a/didier/utils/discord/colours.py b/didier/utils/discord/colours.py index e0ebb5c..dc58608 100644 --- a/didier/utils/discord/colours.py +++ b/didier/utils/discord/colours.py @@ -1,15 +1,21 @@ import discord __all__ = [ + "epic_games_white", "error_red", "github_white", "ghent_university_blue", "ghent_university_yellow", "google_blue", + "steam_blue", "urban_dictionary_green", ] +def epic_games_white() -> discord.Colour: + return discord.Colour.from_rgb(255, 255, 255) + + def error_red() -> discord.Colour: return discord.Colour.red() @@ -26,9 +32,17 @@ def ghent_university_yellow() -> discord.Colour: return discord.Colour.from_rgb(255, 210, 0) +def gog_purple() -> discord.Colour: + return discord.Colour.purple() + + def google_blue() -> discord.Colour: return discord.Colour.from_rgb(66, 133, 244) +def steam_blue() -> discord.Colour: + return discord.Colour.from_rgb(102, 192, 244) + + def urban_dictionary_green() -> discord.Colour: return discord.Colour.from_rgb(220, 255, 0) diff --git a/settings.py b/settings.py index aa0f6fe..3342ea0 100644 --- a/settings.py +++ b/settings.py @@ -27,6 +27,8 @@ __all__ = [ "DISCORD_TEST_GUILDS", "DISCORD_BOOS_REACT", "DISCORD_CUSTOM_COMMAND_PREFIX", + "ERRORS_CHANNEL", + "FREE_GAMES_CHANNEL", "UFORA_ANNOUNCEMENTS_CHANNEL", "UFORA_RSS_TOKEN", "IMGFLIP_NAME", @@ -65,6 +67,7 @@ DISCORD_BOOS_REACT: str = env.str("DISCORD_BOOS_REACT", "<:boos:6296037858402631 DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISCORD_CUSTOM_COMMAND_PREFIX", "?") BIRTHDAY_ANNOUNCEMENT_CHANNEL: Optional[int] = env.int("BIRTHDAY_ANNOUNCEMENT_CHANNEL", None) ERRORS_CHANNEL: Optional[int] = env.int("ERRORS_CHANNEL", None) +FREE_GAMES_CHANNEL: Optional[int] = env.int("FREE_GAMES_CHANNEL", None) UFORA_ANNOUNCEMENTS_CHANNEL: Optional[int] = env.int("UFORA_ANNOUNCEMENTS_CHANNEL", None) """Discord Role ID's"""