diff --git a/core/__init__.py b/core/__init__.py index 1325210..cdb0fbf 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -2,5 +2,5 @@ from . import constants as constants from .bot import Substiify as Substiify -__version__ = "0.96" +__version__ = "0.97" __author__ = "jackra1n" diff --git a/core/events.py b/core/events.py index 0707a84..40b0f43 100644 --- a/core/events.py +++ b/core/events.py @@ -26,6 +26,8 @@ async def on_command(self, ctx: commands.Context): async def on_guild_join(self, guild: discord.Guild): await self.bot.get_channel(EVENTS_CHANNEL_ID).send(f"Joined {guild.owner}'s guild `{guild.name}` ({guild.id})") await self._insert_server(guild) + for channel in guild.channels: + await self._insert_channel(channel) @commands.Cog.listener() async def on_guild_update(self, before: discord.Guild, after: discord.Guild): diff --git a/extensions/free_games.py b/extensions/free_games.py index 633c04c..5c19b63 100644 --- a/extensions/free_games.py +++ b/extensions/free_games.py @@ -1,21 +1,46 @@ +from __future__ import annotations + import logging -from datetime import datetime +from abc import ABC, abstractmethod +from datetime import datetime, timedelta import aiohttp import discord -from discord.ext import commands +from discord.ext import commands, tasks -from core.bot import Substiify +import core logger = logging.getLogger(__name__) -EPIC_STORE_FREE_GAMES_API = "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions" -EPIC_GAMES_LOGO_URL = ( - "https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Epic_Games_logo.svg/50px-Epic_Games_logo.svg.png" -) +class Game(ABC): + title: str + start_date: datetime + end_date: datetime + original_price: str + discount_price: str + cover_image_url: str + store_link: str + platform: Platform + + +class Platform(ABC): + api_url: str + logo_path: str + name: str + + @staticmethod + @abstractmethod + async def get_free_games() -> list[Game]: + pass + + @staticmethod + @abstractmethod + def _create_game(game_info_json: str) -> Game: + pass -class Game: + +class EpicGamesGame(Game): def __init__(self, game_info_json: str) -> None: self.title: str = game_info_json["title"] self.start_date: datetime = self._create_start_date(game_info_json) @@ -23,7 +48,8 @@ def __init__(self, game_info_json: str) -> None: self.original_price: str = game_info_json["price"]["totalPrice"]["fmtPrice"]["originalPrice"] self.discount_price: str = self._create_discount_price(game_info_json["price"]) self.cover_image_url: str = self._create_thumbnail(game_info_json["keyImages"]) - self.epic_store_link: str = self._create_store_link(game_info_json) + self.store_link: str = self._create_store_link(game_info_json) + self.platform: Platform = EpicGames def _create_store_link(self, game_info_json: str) -> str: offer_mappings = game_info_json["offerMappings"] @@ -45,8 +71,8 @@ def _parse_date(self, game_info_json: str, date_field: str) -> datetime: return datetime.strptime(date_str.split("T")[0], "%Y-%m-%d") def _create_discount_price(self, game_price_str: str) -> str: - discount_price = game_price_str["totalPrice"]["fmtPrice"]["discountPrice"] - return "Free" if discount_price == "0" else discount_price + discount_price = game_price_str["totalPrice"]["discountPrice"] + return "Free" if discount_price == 0 else discount_price def _create_thumbnail(self, key_images: str) -> str: for image in key_images: @@ -55,22 +81,20 @@ def _create_thumbnail(self, key_images: str) -> str: return key_images[0]["url"] -class FreeGames(commands.Cog): - COG_EMOJI = "🕹ī¸" - - def __init__(self, bot: Substiify): - self.bot = bot +class EpicGames(Platform): + api_url: str = "https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions" + logo_path: str = "https://media.discordapp.net/attachments/1073161276802482196/1073161428804055140/epic.png" + name: str = "epicgames" - @commands.cooldown(3, 30) - @commands.command() - async def epic(self, ctx: commands.Context): + @staticmethod + async def get_free_games() -> list[Game]: """ - Show all free games from Epic Games that are currently available. + Get all free games from Epic Games """ all_games = "" try: async with aiohttp.ClientSession() as session: - async with session.get(EPIC_STORE_FREE_GAMES_API) as response: + async with session.get(EpicGames.api_url) as response: json_response = await response.json() all_games = json_response["data"]["Catalog"]["searchStore"]["elements"] except Exception as ex: @@ -82,41 +106,314 @@ async def epic(self, ctx: commands.Context): if game["promotions"] is None: continue # Check if game is free - if game["price"]["totalPrice"]["fmtPrice"]["discountPrice"] != "0": + if game["price"]["totalPrice"]["discountPrice"] != 0: + continue + # Check if game was already free + if game["price"]["totalPrice"]["originalPrice"] == 0: continue - # Check if the game is currently free + # Check if the game is _currently_ free if game["status"] != "ACTIVE": continue try: - current_free_games.append(Game(game)) + current_free_games.append(EpicGamesGame(game)) except Exception as ex: logger.error(f"Error while creating 'Game' object: {ex}") + return current_free_games + + +STORES = { + "epicgames": EpicGames, +} + - if not current_free_games: +class FreeGames(commands.Cog): + COG_EMOJI = "🕹ī¸" + + def __init__(self, bot: core.Substiify): + self.bot = bot + self.check_free_games.start() + + @commands.is_owner() + @commands.command(hidden=True) + async def fgc(self, ctx: commands.Context, action: str): + if action == "start": + self.check_free_games.start() + await ctx.message.add_reaction("✅") + elif action == "stop": + self.check_free_games.stop() + await ctx.message.add_reaction("✅") + + @tasks.loop(hours=1) + async def check_free_games(self): + all_enabled_platforms_stmt = """SELECT DISTINCT store_name FROM store_options;""" + all_enabled_platforms = await self.bot.db.fetch(all_enabled_platforms_stmt) + platforms = [record["store_name"] for record in all_enabled_platforms] + logger.debug(f"Checking free games for platforms: {platforms}") + + current_free_games: list[Game] = [] + for platform in platforms: + current_free_games += await STORES[platform].get_free_games() + logger.debug(f"Found {len(current_free_games)} free games") + + freegames_and_options_stmt = """ + SELECT fgc.discord_server_id, fgc.discord_channel_id, so.store_name + FROM free_games_channel AS fgc + JOIN store_options AS so ON fgc.id = so.free_games_channel_id; + """ + freegames_and_options = await self.bot.db.fetch(freegames_and_options_stmt) + + total_sent_messages = 0 + for game in current_free_games: + if await self._is_game_in_history(game): + continue + logger.info(f"Starting to send new free game: {game.title}") + await self._add_game_to_history(game) + embed = self._create_game_embed(game) + + for fg_setting in freegames_and_options: + channel: discord.TextChannel = self.bot.get_channel(fg_setting["discord_channel_id"]) + if not channel: + continue + if fg_setting["store_name"] == game.platform.name: + try: + await channel.send(embed=embed) + total_sent_messages += 1 + except Exception as ex: + srv_chnl = ( + f"[server: {fg_setting['discord_server_id']}, channel: {fg_setting['discord_channel_id']}]" + ) + logger.error(f"Fail while sending free game for {srv_chnl} -> {ex}") + + if total_sent_messages: + logger.info(f"Sent [{total_sent_messages}] new free games messages") + + async def _is_game_in_history(self, game: Game) -> bool: + game_in_history_stmt = """SELECT * FROM free_game_history WHERE title = $1 AND store_name = $2;""" + game_row = await self.bot.db.fetchrow(game_in_history_stmt, game.title, game.platform.name) + if not game_row: + return False + + # If game was in history for more than 30 days, it will be considered as a new game + created_at = game_row["created_at"] + if created_at + timedelta(days=30) < datetime.now(): + return False + return True + + async def _add_game_to_history(self, game: Game): + game_insert_stmt = """ + INSERT INTO free_game_history (title, start_date, end_date, store_name, store_link) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING; + """ + await self.bot.db.execute( + game_insert_stmt, game.title, game.start_date, game.end_date, game.platform.name, game.store_link + ) + + @commands.hybrid_group(aliases=["fg"], usage="freegames [settings|send]") + @commands.cooldown(3, 30) + async def freegames(self, ctx: commands.Context): + """ + Get free games from various platforms. + See subcommands for more information. + By default, this command will check if the user has manage channels permission and then send the settings menu. + If the user doesn't have the permission, it will send the free games to the current channel. + """ + # check if the user has manage channels permission + if ctx.author.guild_permissions.manage_channels or ctx.author.id == self.bot.owner_id: + await ctx.invoke(self.bot.get_command("freegames settings")) + else: + return await ctx.invoke(self.bot.get_command("freegames send")) + + @freegames.command() + @commands.guild_only() + @commands.check_any(commands.has_permissions(manage_channels=True), commands.is_owner()) + async def settings(self, ctx: commands.Context): + """ + Show settings for the free games command. + If you can't see the channel you want to set it's because the menu is limited to 25 options. + In order to force the channel to show up, use the command in the channel you want to set. + """ + embed = discord.Embed(title="Free Games Settings", color=core.constants.SECONDARY_COLOR) + embed.description = "Here you can configure where free games should be sent and which platforms to check." + + channel_options = await _create_channels_select_options(ctx) + settings_view = SettingsView(ctx, channel_options) + await ctx.send(embed=embed, view=settings_view, delete_after=180) + + @freegames.command() + @commands.cooldown(2, 30) + async def send(self, ctx: commands.Context, platform: str = None): + """ + Show all free games that are currently available. + `:param platform:` The platform to get the free games from. If not specified, all platforms will be checked. + Valid platforms are: `epicgames`. More platforms will be added in the future. + """ + platforms: list[Platform] = Platform.__subclasses__() + if any(platform == platform.__name__.lower() for platform in platforms): + platforms = [platform] + + total_free_games_count = 0 + for platform in platforms: + platform: Platform + current_free_games: list[Game] = await platform.get_free_games() + total_free_games_count += len(current_free_games) + + for game in current_free_games: + try: + embed = self._create_game_embed(game) + await ctx.send(embed=embed) + except Exception as ex: + logger.error(f"Fail while sending free game: {ex}") + + if total_free_games_count == 0: embed = discord.Embed(color=discord.Colour.dark_embed()) - embed.description = "Could not find any currently free games" + embed.description = "Could not find any free games at the moment." await ctx.send(embed=embed) - for game in current_free_games: - try: - embed = discord.Embed(title=game.title, url=game.epic_store_link, color=discord.Colour.dark_embed()) - embed.set_thumbnail(url=f"{EPIC_GAMES_LOGO_URL}") - available_string = ( - f"started , ends " - ) - embed.add_field(name="Available", value=available_string, inline=False) - price_field = ( - f"~~`{game.original_price}`~~ âŸļ `{game.discount_price}`" - if game.original_price != "0" - else f"`{game.discount_price}`" - ) - embed.add_field(name="Price", value=price_field, inline=False) - embed.set_image(url=game.cover_image_url) - - await ctx.send(embed=embed) - except Exception as ex: - logger.error(f"Fail while sending free game: {ex}") + def _create_game_embed(self, game: Game) -> discord.Embed: + embed = discord.Embed(title=game.title, url=game.store_link, color=core.constants.SECONDARY_COLOR) + date_timestamp = discord.utils.format_dt(game.end_date, "d") + embed.description = f"~~{game.original_price}~~ **{game.discount_price}** until {date_timestamp}" + embed.set_thumbnail(url=game.platform.logo_path) + embed.set_image(url=game.cover_image_url) + return embed + + +async def _create_channels_select_options(ctx: commands.Context) -> list[discord.SelectOption]: + selected_channel_id = 0 + free_games_channel_stmt = """ + SELECT fgc.discord_server_id, fgc.discord_channel_id, so.store_name + FROM free_games_channel AS fgc + JOIN store_options AS so ON fgc.id = so.free_games_channel_id + WHERE fgc.discord_server_id = $1; + """ + bot: core.Substiify = ctx.bot + free_games_channel = await bot.db.fetchrow(free_games_channel_stmt, ctx.guild.id) + if free_games_channel: + selected_channel_id = int(free_games_channel["discord_channel_id"]) + + channel_options = [] + disabled_option = discord.SelectOption( + label="Click here to disable", + description="Free games will not be sent to this server.", + value=0, + emoji="❌", + default=(selected_channel_id == 0), + ) + + bot_member = ctx.guild.get_member(bot.user.id) + channel_emoji = bot.get_emoji(1221097471946522725) + channel_active_emoji = bot.get_emoji(1221097459745292398) + + is_selected = selected_channel_id == ctx.channel.id + current_channel_option = discord.SelectOption( + label=f"{ctx.channel.name} (here)", + value=ctx.channel.id, + emoji=(channel_active_emoji if is_selected else channel_emoji), + default=is_selected, + ) + + # At the top add disabled and current channel options + channel_options.append(disabled_option) + channel_options.append(current_channel_option) + + channels_list = [channel for channel in ctx.guild.text_channels if channel != ctx.channel] + # First add only channels where the bot can read and send messages + for channel in channels_list[:]: + if len(channel_options) >= 25: + break + can_read = channel.permissions_for(bot_member).read_messages + can_write = channel.permissions_for(bot_member).send_messages + if not can_read or not can_write: + continue + channel_option = discord.SelectOption(label=channel.name, value=channel.id, emoji=channel_emoji) + if selected_channel_id == channel.id: + channel_option.emoji = channel_active_emoji + channel_option.default = True + channel_options.append(channel_option) + channels_list.remove(channel) + + # Then if there are less than 25 options, add the rest + for channel in channels_list[:]: + if len(channel_options) >= 25: + break + channel_option = discord.SelectOption(label=channel.name, value=channel.id, emoji=channel_emoji) + if selected_channel_id == channel.id: + channel_option.default = True + channel_option.emoji = channel_active_emoji + if not channel.permissions_for(bot_member).read_messages: + channel_option.description = "⚠ī¸ Missing 'View Channel' permission" + elif not channel.permissions_for(bot_member).send_messages: + channel_option.description = "⚠ī¸ Missing 'Send Messages' permission" + channel_options.append(channel_option) + return channel_options + + +class SettingsView(discord.ui.View): + def __init__(self, ctx: commands.Context, channel_options: list[discord.SelectOption] = None): + self.ctx = ctx + super().__init__() + self.add_item(ChannelsSelector(channel_options=channel_options)) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return interaction.user.id == self.ctx.author.id + + @discord.ui.button(label="Close", style=discord.ButtonStyle.grey, row=4) + async def close_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.message.delete() + + +class ChannelsSelector(discord.ui.Select): + def __init__(self, channel_options: list[discord.SelectOption] = None): + options = channel_options or [] + super().__init__(placeholder="Select a channel", options=options) + + async def callback(self, interaction: discord.Interaction): + bot: core.Substiify = self.view.ctx.bot + + channel: discord.TextChannel = interaction.guild.get_channel(int(self.values[0])) + embed = discord.Embed(title="Free Games Settings", color=core.constants.SECONDARY_COLOR) + embed.description = "Here you can configure where free games should be sent and which platforms to check." + + if int(self.values[0]) == 0: + fg_stmt = """DELETE FROM free_games_channel WHERE discord_server_id = $1;""" + await bot.db.execute(fg_stmt, interaction.guild.id) + elif not channel.permissions_for(interaction.guild.me).read_messages: + embed.description += f"\n\n**⚠ī¸ Can't set channel to {channel.mention}. Missing 'View Channel' permission.**" + elif not channel.permissions_for(interaction.guild.me).send_messages: + embed.description += ( + f"\n\n**⚠ī¸ Can't set channel to {channel.mention}. Missing 'Send Messages' permission.**" + ) + + else: + insert_channel_stmt = """ + INSERT INTO discord_channel (discord_channel_id, channel_name, discord_server_id) + VALUES ($1, $2, $3) + ON CONFLICT (discord_channel_id) DO UPDATE SET channel_name = $2; + """ + await bot.db.execute(insert_channel_stmt, channel.id, channel.name, interaction.guild.id) + + fg_stmt = """ + INSERT INTO free_games_channel (discord_server_id, discord_channel_id) VALUES ($1, $2) + ON CONFLICT (discord_server_id) DO UPDATE SET discord_channel_id = $2; + """ + await bot.db.execute(fg_stmt, interaction.guild.id, channel.id) + + fg_get_id_stmt = """ + SELECT id FROM free_games_channel + WHERE discord_server_id = $1 AND discord_channel_id = $2; + """ + fg_id = await bot.db.fetchval(fg_get_id_stmt, interaction.guild.id, channel.id) + + fg_settings_stmt = """ + INSERT INTO store_options (free_games_channel_id, store_name) VALUES ($1, $2) + ON CONFLICT (free_games_channel_id, store_name) DO NOTHING; + """ + await bot.db.execute(fg_settings_stmt, fg_id, "epicgames") + + self.options = await _create_channels_select_options(self.view.ctx) + return await interaction.response.edit_message(embed=embed, view=self.view) -async def setup(bot: Substiify): +async def setup(bot: core.Substiify): await bot.add_cog(FreeGames(bot)) diff --git a/pyproject.toml b/pyproject.toml index 2bdce74..8501c29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "substiify" -version = "0.96" +version = "0.97" readme = "README.md" requires-python = ">=3.12" dependencies = [ diff --git a/resources/CreateDatabase.sql b/resources/CreateDatabase.sql index ace0e79..ffd24b2 100644 --- a/resources/CreateDatabase.sql +++ b/resources/CreateDatabase.sql @@ -99,4 +99,29 @@ CREATE TABLE IF NOT EXISTS feedback ( content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, accepted BOOLEAN -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS free_games_channel ( + id SERIAL PRIMARY KEY, + discord_server_id BIGINT REFERENCES discord_server(discord_server_id) ON DELETE CASCADE, + discord_channel_id BIGINT REFERENCES discord_channel(discord_channel_id) ON DELETE CASCADE, + UNIQUE (discord_server_id) +); + +CREATE TABLE IF NOT EXISTS store_options ( + id SERIAL PRIMARY KEY, + free_games_channel_id BIGINT REFERENCES free_games_channel(id) ON DELETE CASCADE, + store_name VARCHAR(255), + UNIQUE (free_games_channel_id, store_name) +); + +CREATE TABLE IF NOT EXISTS free_game_history ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + start_date TIMESTAMP, + end_date TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + store_name VARCHAR(255) NOT NULL, + store_link VARCHAR(255) NOT NULL, + UNIQUE (title, store_name) +);