Check for free games

pull/141/head
stijndcl 2022-10-13 20:00:46 +02:00
parent 41b5efd12d
commit deefeb1106
11 changed files with 297 additions and 75 deletions

View File

@ -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 ###

View File

@ -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))

View File

@ -33,6 +33,7 @@ __all__ = [
"DadJoke", "DadJoke",
"Deadline", "Deadline",
"EasterEgg", "EasterEgg",
"FreeGame",
"GitHubLink", "GitHubLink",
"Link", "Link",
"MemeTemplate", "MemeTemplate",
@ -174,6 +175,14 @@ class EasterEgg(Base):
startswith: bool = Column(Boolean, nullable=False, server_default="1") 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): class GitHubLink(Base):
"""A user's GitHub link""" """A user's GitHub link"""

View File

@ -18,7 +18,8 @@ from didier.data.embeds.schedules import (
get_schedule_for_day, get_schedule_for_day,
parse_schedule_from_content, 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.decorators.tasks import timed_task
from didier.utils.discord.checks import is_owner from didier.utils.discord.checks import is_owner
from didier.utils.types.datetime import LOCAL_TIMEZONE, tz_aware_now from didier.utils.types.datetime import LOCAL_TIMEZONE, tz_aware_now
@ -48,6 +49,7 @@ class Tasks(commands.Cog):
self._tasks = { self._tasks = {
"birthdays": self.check_birthdays, "birthdays": self.check_birthdays,
"free_games": self.pull_free_games,
"schedules": self.pull_schedules, "schedules": self.pull_schedules,
"reminders": self.reminders, "reminders": self.reminders,
"ufora": self.pull_ufora_announcements, "ufora": self.pull_ufora_announcements,
@ -61,6 +63,10 @@ class Tasks(commands.Cog):
if settings.BIRTHDAY_ANNOUNCEMENT_CHANNEL is not None: if settings.BIRTHDAY_ANNOUNCEMENT_CHANNEL is not None:
self.check_birthdays.start() 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 # Only pull announcements if a token was provided
if settings.UFORA_RSS_TOKEN is not None and settings.UFORA_ANNOUNCEMENTS_CHANNEL is not None: if settings.UFORA_RSS_TOKEN is not None and settings.UFORA_ANNOUNCEMENTS_CHANNEL is not None:
self.pull_ufora_announcements.start() self.pull_ufora_announcements.start()
@ -128,6 +134,26 @@ class Tasks(commands.Cog):
async def _before_check_birthdays(self): async def _before_check_birthdays(self):
await self.client.wait_until_ready() 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) @tasks.loop(time=DAILY_RESET_TIME)
@timed_task(enums.TaskType.SCHEDULES) @timed_task(enums.TaskType.SCHEDULES)
async def pull_schedules(self, **kwargs): async def pull_schedules(self, **kwargs):
@ -166,6 +192,10 @@ class Tasks(commands.Cog):
# Only replace cached version if all schedules succeeded # Only replace cached version if all schedules succeeded
self.client.schedules = new_schedules 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) @tasks.loop(minutes=10)
@timed_task(enums.TaskType.UFORA_ANNOUNCEMENTS) @timed_task(enums.TaskType.UFORA_ANNOUNCEMENTS)
async def pull_ufora_announcements(self, **kwargs): async def pull_ufora_announcements(self, **kwargs):

View File

@ -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

View File

@ -1,18 +1,11 @@
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import async_timeout
import discord import discord
import feedparser
from aiohttp import ClientSession
from markdownify import markdownify as md 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 database.schemas import UforaCourse
from didier.data.embeds.base import EmbedBaseModel from didier.data.embeds.base import EmbedBaseModel
from didier.utils.discord.colours import ghent_university_blue 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 from didier.utils.types.string import leading
__all__ = [ __all__ = [
"fetch_ufora_announcements",
"parse_ids",
"UforaNotification", "UforaNotification",
] ]
@ -107,68 +98,3 @@ class UforaNotification(EmbedBaseModel):
":" ":"
f"{leading('0', str(self.published_dt.second))}" 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

View File

View File

@ -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))

View File

@ -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

View File

@ -1,15 +1,21 @@
import discord import discord
__all__ = [ __all__ = [
"epic_games_white",
"error_red", "error_red",
"github_white", "github_white",
"ghent_university_blue", "ghent_university_blue",
"ghent_university_yellow", "ghent_university_yellow",
"google_blue", "google_blue",
"steam_blue",
"urban_dictionary_green", "urban_dictionary_green",
] ]
def epic_games_white() -> discord.Colour:
return discord.Colour.from_rgb(255, 255, 255)
def error_red() -> discord.Colour: def error_red() -> discord.Colour:
return discord.Colour.red() return discord.Colour.red()
@ -26,9 +32,17 @@ def ghent_university_yellow() -> discord.Colour:
return discord.Colour.from_rgb(255, 210, 0) return discord.Colour.from_rgb(255, 210, 0)
def gog_purple() -> discord.Colour:
return discord.Colour.purple()
def google_blue() -> discord.Colour: def google_blue() -> discord.Colour:
return discord.Colour.from_rgb(66, 133, 244) 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: def urban_dictionary_green() -> discord.Colour:
return discord.Colour.from_rgb(220, 255, 0) return discord.Colour.from_rgb(220, 255, 0)

View File

@ -27,6 +27,8 @@ __all__ = [
"DISCORD_TEST_GUILDS", "DISCORD_TEST_GUILDS",
"DISCORD_BOOS_REACT", "DISCORD_BOOS_REACT",
"DISCORD_CUSTOM_COMMAND_PREFIX", "DISCORD_CUSTOM_COMMAND_PREFIX",
"ERRORS_CHANNEL",
"FREE_GAMES_CHANNEL",
"UFORA_ANNOUNCEMENTS_CHANNEL", "UFORA_ANNOUNCEMENTS_CHANNEL",
"UFORA_RSS_TOKEN", "UFORA_RSS_TOKEN",
"IMGFLIP_NAME", "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", "?") DISCORD_CUSTOM_COMMAND_PREFIX: str = env.str("DISCORD_CUSTOM_COMMAND_PREFIX", "?")
BIRTHDAY_ANNOUNCEMENT_CHANNEL: Optional[int] = env.int("BIRTHDAY_ANNOUNCEMENT_CHANNEL", None) BIRTHDAY_ANNOUNCEMENT_CHANNEL: Optional[int] = env.int("BIRTHDAY_ANNOUNCEMENT_CHANNEL", None)
ERRORS_CHANNEL: Optional[int] = env.int("ERRORS_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) UFORA_ANNOUNCEMENTS_CHANNEL: Optional[int] = env.int("UFORA_ANNOUNCEMENTS_CHANNEL", None)
"""Discord Role ID's""" """Discord Role ID's"""