From 6b7caa1835ab809a8ccdfa5ea3ada705acaede70 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 26 Mar 2020 21:22:53 +0100 Subject: Deseasonify: season-lock commands where appropriate I left as many available as possible. Some of the commands also handle being outside of their original season, e.g. by showing the amount of time until Hacktoberfest begins, if not currently active. These were left available as well. If a group is to be locked, the `invoke_without_command` param must be False (default), otherwise the group's callback will be circumvented if a valid subcommand is invoked. I adjusted these where necessary, except for the `HacktoberStats` cog, whose cmd group takes an arg, which would require a more involved adjustment - I decided to leave it as is, and instead manually lock both subcommands. --- bot/seasons/christmas/adventofcode.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) (limited to 'bot/seasons/christmas/adventofcode.py') diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 8caf43bd..e6100056 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -13,8 +13,8 @@ from bs4 import BeautifulSoup from discord.ext import commands from pytz import timezone -from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Tokens, WHITELISTED_CHANNELS -from bot.decorators import override_in_channel +from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS +from bot.decorators import in_month, override_in_channel from bot.utils import unlocked_role log = logging.getLogger(__name__) @@ -153,11 +153,13 @@ class AdventOfCode(commands.Cog): status_coro = countdown_status(self.bot) self.status_task = self.bot.loop.create_task(status_coro) - @commands.group(name="adventofcode", aliases=("aoc",), invoke_without_command=True) + @in_month(Month.december) + @commands.group(name="adventofcode", aliases=("aoc",)) @override_in_channel(AOC_WHITELIST) async def adventofcode_group(self, ctx: commands.Context) -> None: """All of the Advent of Code commands.""" - await ctx.send_help(ctx.command) + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) @adventofcode_group.command( name="subscribe", -- cgit v1.2.3 From 99b23ff1500aa15b076ee2e1c5e1b5560ba2c366 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 28 Mar 2020 14:51:14 +0100 Subject: Deseasonify: move decorators module under utils --- bot/__main__.py | 2 +- bot/bot.py | 2 +- bot/branding.py | 2 +- bot/decorators.py | 321 ------------------------ bot/seasons/christmas/adventofcode.py | 2 +- bot/seasons/christmas/hanukkah_embed.py | 2 +- bot/seasons/easter/egg_facts.py | 2 +- bot/seasons/evergreen/error_handler.py | 2 +- bot/seasons/evergreen/game.py | 2 +- bot/seasons/evergreen/issues.py | 2 +- bot/seasons/evergreen/snakes/snakes_cog.py | 2 +- bot/seasons/halloween/candy_collection.py | 2 +- bot/seasons/halloween/hacktober-issue-finder.py | 2 +- bot/seasons/halloween/hacktoberstats.py | 2 +- bot/seasons/halloween/spookyreact.py | 2 +- bot/seasons/pride/pride_facts.py | 2 +- bot/seasons/valentines/be_my_valentine.py | 2 +- bot/utils/decorators.py | 321 ++++++++++++++++++++++++ 18 files changed, 337 insertions(+), 337 deletions(-) delete mode 100644 bot/decorators.py create mode 100644 bot/utils/decorators.py (limited to 'bot/seasons/christmas/adventofcode.py') diff --git a/bot/__main__.py b/bot/__main__.py index 780c8c4d..3662a63b 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -2,8 +2,8 @@ import logging from bot.bot import bot from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS -from bot.decorators import in_channel_check from bot.seasons import get_extensions +from bot.utils.decorators import in_channel_check log = logging.getLogger(__name__) diff --git a/bot/bot.py b/bot/bot.py index b47e1289..47d63de9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -11,7 +11,7 @@ from discord import DiscordException, Embed from discord.ext import commands from bot.constants import Channels, Client -from bot.decorators import mock_in_debug +from bot.utils.decorators import mock_in_debug log = logging.getLogger(__name__) diff --git a/bot/branding.py b/bot/branding.py index 6f5e7a5c..2eb563ea 100644 --- a/bot/branding.py +++ b/bot/branding.py @@ -12,8 +12,8 @@ from discord.ext import commands from bot.bot import SeasonalBot from bot.constants import Branding, Colours, Emojis, MODERATION_ROLES, Tokens -from bot.decorators import with_role from bot.seasons import SeasonBase, get_current_season, get_season +from bot.utils.decorators import with_role from bot.utils.exceptions import BrandingError log = logging.getLogger(__name__) diff --git a/bot/decorators.py b/bot/decorators.py deleted file mode 100644 index f85996b5..00000000 --- a/bot/decorators.py +++ /dev/null @@ -1,321 +0,0 @@ -import asyncio -import functools -import logging -import random -import typing as t -from asyncio import Lock -from datetime import datetime -from functools import wraps -from weakref import WeakValueDictionary - -from discord import Colour, Embed -from discord.ext import commands -from discord.ext.commands import CheckFailure, Command, Context - -from bot.constants import Client, ERROR_REPLIES, Month - -ONE_DAY = 24 * 60 * 60 - -log = logging.getLogger(__name__) - - -class InChannelCheckFailure(CheckFailure): - """Check failure when the user runs a command in a non-whitelisted channel.""" - - pass - - -class InMonthCheckFailure(CheckFailure): - """Check failure for when a command is invoked outside of its allowed month.""" - - pass - - -def seasonal_task(*allowed_months: Month, sleep_time: t.Union[float, int] = ONE_DAY) -> t.Callable: - """ - Perform the decorated method periodically in `allowed_months`. - - This provides a convenience wrapper to avoid code repetition where some task shall - perform an operation repeatedly in a constant interval, but only in specific months. - - The decorated function will be called once every `sleep_time` seconds while - the current UTC month is in `allowed_months`. Sleep time defaults to 24 hours. - - The wrapped task is responsible for waiting for the bot to be ready, if necessary. - """ - def decorator(task_body: t.Callable) -> t.Callable: - @functools.wraps(task_body) - async def decorated_task(*args, **kwargs) -> None: - """Call `task_body` once every `sleep_time` seconds in `allowed_months`.""" - log.info(f"Starting seasonal task {task_body.__qualname__} ({allowed_months})") - - while True: - current_month = Month(datetime.utcnow().month) - - if current_month in allowed_months: - await task_body(*args, **kwargs) - else: - log.debug(f"Seasonal task {task_body.__qualname__} sleeps in {current_month.name}") - - await asyncio.sleep(sleep_time) - return decorated_task - return decorator - - -def in_month_listener(*allowed_months: Month) -> t.Callable: - """ - Shield a listener from being invoked outside of `allowed_months`. - - The check is performed against current UTC month. - """ - def decorator(listener: t.Callable) -> t.Callable: - @functools.wraps(listener) - async def guarded_listener(*args, **kwargs) -> None: - """Wrapped listener will abort if not in allowed month.""" - current_month = Month(datetime.utcnow().month) - - if current_month in allowed_months: - # Propagate return value although it should always be None - return await listener(*args, **kwargs) - else: - log.debug(f"Guarded {listener.__qualname__} from invoking in {current_month.name}") - return guarded_listener - return decorator - - -def in_month_command(*allowed_months: Month) -> t.Callable: - """ - Check whether the command was invoked in one of `enabled_months`. - - Uses the current UTC month at the time of running the predicate. - """ - async def predicate(ctx: Context) -> bool: - current_month = datetime.utcnow().month - can_run = current_month in allowed_months - - human_months = ", ".join(m.name for m in allowed_months) - log.debug( - f"Command '{ctx.command}' is locked to months {human_months}. " - f"Invoking it in month {current_month} is {'allowed' if can_run else 'disallowed'}." - ) - if can_run: - return True - else: - raise InMonthCheckFailure(f"Command can only be used in {human_months}") - - return commands.check(predicate) - - -def in_month(*allowed_months: Month) -> t.Callable: - """ - Universal decorator for season-locking commands and listeners alike. - - This only serves to determine whether the decorated callable is a command, - a listener, or neither. It then delegates to either `in_month_command`, - or `in_month_listener`, or raises TypeError, respectively. - - Please note that in order for this decorator to correctly determine whether - the decorated callable is a cmd or listener, it **has** to first be turned - into one. This means that this decorator should always be placed **above** - the d.py one that registers it as either. - - This will decorate groups as well, as those subclass Command. In order to lock - all subcommands of a group, its `invoke_without_command` param must **not** be - manually set to True - this causes a circumvention of the group's callback - and the seasonal check applied to it. - """ - def decorator(callable_: t.Callable) -> t.Callable: - # Functions decorated as commands are turned into instances of `Command` - if isinstance(callable_, Command): - logging.debug(f"Command {callable_.qualified_name} will be locked to {allowed_months}") - actual_deco = in_month_command(*allowed_months) - - # D.py will assign this attribute when `callable_` is registered as a listener - elif hasattr(callable_, "__cog_listener__"): - logging.debug(f"Listener {callable_.__qualname__} will be locked to {allowed_months}") - actual_deco = in_month_listener(*allowed_months) - - # Otherwise we're unsure exactly what has been decorated - # This happens before the bot starts, so let's just raise - else: - raise TypeError(f"Decorated object {callable_} is neither a command nor a listener") - - return actual_deco(callable_) - return decorator - - -def with_role(*role_ids: int) -> t.Callable: - """Check to see whether the invoking user has any of the roles specified in role_ids.""" - async def predicate(ctx: Context) -> bool: - if not ctx.guild: # Return False in a DM - log.debug( - f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " - "This command is restricted by the with_role decorator. Rejecting request." - ) - return False - - for role in ctx.author.roles: - if role.id in role_ids: - log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") - return True - - log.debug( - f"{ctx.author} does not have the required role to use " - f"the '{ctx.command.name}' command, so the request is rejected." - ) - return False - return commands.check(predicate) - - -def without_role(*role_ids: int) -> t.Callable: - """Check whether the invoking user does not have all of the roles specified in role_ids.""" - async def predicate(ctx: Context) -> bool: - if not ctx.guild: # Return False in a DM - log.debug( - f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " - "This command is restricted by the without_role decorator. Rejecting request." - ) - return False - - author_roles = [role.id for role in ctx.author.roles] - check = all(role not in author_roles for role in role_ids) - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The result of the without_role check was {check}." - ) - return check - return commands.check(predicate) - - -def in_channel_check(*channels: int, bypass_roles: t.Container[int] = None) -> t.Callable[[Context], bool]: - """ - Checks that the message is in a whitelisted channel or optionally has a bypass role. - - If `in_channel_override` is present, check if it contains channels - and use them in place of the global whitelist. - """ - def predicate(ctx: Context) -> bool: - if not ctx.guild: - log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.") - return True - if ctx.channel.id in channels: - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command " - f"and the command was used in a whitelisted channel." - ) - return True - - if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles): - log.debug( - f"{ctx.author} called the '{ctx.command.name}' command and " - f"had a role to bypass the in_channel check." - ) - return True - - if hasattr(ctx.command.callback, "in_channel_override"): - override = ctx.command.callback.in_channel_override - if override is None: - log.debug( - f"{ctx.author} called the '{ctx.command.name}' command " - f"and the command was whitelisted to bypass the in_channel check." - ) - return True - else: - if ctx.channel.id in override: - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command " - f"and the command was used in an overridden whitelisted channel." - ) - return True - - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The overridden in_channel check failed." - ) - channels_str = ', '.join(f"<#{c_id}>" for c_id in override) - raise InChannelCheckFailure( - f"Sorry, but you may only use this command within {channels_str}." - ) - - log.debug( - f"{ctx.author} tried to call the '{ctx.command.name}' command. " - f"The in_channel check failed." - ) - - channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) - raise InChannelCheckFailure( - f"Sorry, but you may only use this command within {channels_str}." - ) - - return predicate - - -in_channel = commands.check(in_channel_check) - - -def override_in_channel(channels: t.Tuple[int] = None) -> t.Callable: - """ - Set command callback attribute for detection in `in_channel_check`. - - Override global whitelist if channels are specified. - - This decorator has to go before (below) below the `command` decorator. - """ - def inner(func: t.Callable) -> t.Callable: - func.in_channel_override = channels - return func - - return inner - - -def locked() -> t.Union[t.Callable, None]: - """ - Allows the user to only run one instance of the decorated command at a time. - - Subsequent calls to the command from the same author are ignored until the command has completed invocation. - - This decorator has to go before (below) the `command` decorator. - """ - def wrap(func: t.Callable) -> t.Union[t.Callable, None]: - func.__locks = WeakValueDictionary() - - @wraps(func) - async def inner(self: t.Callable, ctx: Context, *args, **kwargs) -> t.Union[t.Callable, None]: - lock = func.__locks.setdefault(ctx.author.id, Lock()) - if lock.locked(): - embed = Embed() - embed.colour = Colour.red() - - log.debug(f"User tried to invoke a locked command.") - embed.description = ( - "You're already using this command. Please wait until " - "it is done before you use it again." - ) - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) - return - - async with func.__locks.setdefault(ctx.author.id, Lock()): - return await func(self, ctx, *args, **kwargs) - return inner - return wrap - - -def mock_in_debug(return_value: t.Any) -> t.Callable: - """ - Short-circuit function execution if in debug mode and return `return_value`. - - The original function name, and the incoming args and kwargs are DEBUG level logged - upon each call. This is useful for expensive operations, i.e. media asset uploads - that are prone to rate-limits but need to be tested extensively. - """ - def decorator(func: t.Callable) -> t.Callable: - @functools.wraps(func) - async def wrapped(*args, **kwargs) -> t.Any: - """Short-circuit and log if in debug mode.""" - if Client.debug: - log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}") - return return_value - return await func(*args, **kwargs) - return wrapped - return decorator diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index e6100056..f7590e04 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -14,8 +14,8 @@ from discord.ext import commands from pytz import timezone from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS -from bot.decorators import in_month, override_in_channel from bot.utils import unlocked_role +from bot.utils.decorators import in_month, override_in_channel log = logging.getLogger(__name__) diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/seasons/christmas/hanukkah_embed.py index e73a33ad..62efd04e 100644 --- a/bot/seasons/christmas/hanukkah_embed.py +++ b/bot/seasons/christmas/hanukkah_embed.py @@ -6,7 +6,7 @@ from discord import Embed from discord.ext import commands from bot.constants import Colours, Month -from bot.decorators import in_month +from bot.utils.decorators import in_month log = logging.getLogger(__name__) diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py index d20df3de..99a80b28 100644 --- a/bot/seasons/easter/egg_facts.py +++ b/bot/seasons/easter/egg_facts.py @@ -7,7 +7,7 @@ import discord from discord.ext import commands from bot.constants import Channels, Colours, Month -from bot.decorators import seasonal_task +from bot.utils.decorators import seasonal_task log = logging.getLogger(__name__) diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index d2accbd1..d268dab1 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -7,7 +7,7 @@ from discord import Embed, Message from discord.ext import commands from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES -from bot.decorators import InChannelCheckFailure, InMonthCheckFailure +from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure from bot.utils.exceptions import BrandingError log = logging.getLogger(__name__) diff --git a/bot/seasons/evergreen/game.py b/bot/seasons/evergreen/game.py index ace77e9d..d43b1ad6 100644 --- a/bot/seasons/evergreen/game.py +++ b/bot/seasons/evergreen/game.py @@ -12,7 +12,7 @@ from discord.ext.commands import Cog, Context, group from bot.bot import SeasonalBot from bot.constants import STAFF_ROLES, Tokens -from bot.decorators import with_role +from bot.utils.decorators import with_role from bot.utils.pagination import ImagePaginator, LinePaginator # Base URL of IGDB API diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py index fba5b174..fb18b62a 100644 --- a/bot/seasons/evergreen/issues.py +++ b/bot/seasons/evergreen/issues.py @@ -4,7 +4,7 @@ import discord from discord.ext import commands from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS -from bot.decorators import override_in_channel +from bot.utils.decorators import override_in_channel log = logging.getLogger(__name__) diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py index 09f5e250..e5a03a20 100644 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -18,9 +18,9 @@ from discord import Colour, Embed, File, Member, Message, Reaction from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group from bot.constants import ERROR_REPLIES, Tokens -from bot.decorators import locked from bot.seasons.evergreen.snakes import utils from bot.seasons.evergreen.snakes.converter import Snake +from bot.utils.decorators import locked log = logging.getLogger(__name__) diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py index 967a62aa..3f2b895e 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -9,7 +9,7 @@ import discord from discord.ext import commands from bot.constants import Channels, Month -from bot.decorators import in_month +from bot.utils.decorators import in_month log = logging.getLogger(__name__) diff --git a/bot/seasons/halloween/hacktober-issue-finder.py b/bot/seasons/halloween/hacktober-issue-finder.py index e90796f1..f15a665a 100644 --- a/bot/seasons/halloween/hacktober-issue-finder.py +++ b/bot/seasons/halloween/hacktober-issue-finder.py @@ -8,7 +8,7 @@ import discord from discord.ext import commands from bot.constants import Month -from bot.decorators import in_month +from bot.utils.decorators import in_month log = logging.getLogger(__name__) diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index 3b1444ab..5dfa2f51 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -11,7 +11,7 @@ import discord from discord.ext import commands from bot.constants import Channels, Month, WHITELISTED_CHANNELS -from bot.decorators import in_month, override_in_channel +from bot.utils.decorators import in_month, override_in_channel from bot.utils.persist import make_persistent log = logging.getLogger(__name__) diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py index 37f42a86..16f18019 100644 --- a/bot/seasons/halloween/spookyreact.py +++ b/bot/seasons/halloween/spookyreact.py @@ -5,7 +5,7 @@ import discord from discord.ext.commands import Bot, Cog from bot.constants import Month -from bot.decorators import in_month +from bot.utils.decorators import in_month log = logging.getLogger(__name__) diff --git a/bot/seasons/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py index 1a02eaaa..2db8f5c2 100644 --- a/bot/seasons/pride/pride_facts.py +++ b/bot/seasons/pride/pride_facts.py @@ -10,7 +10,7 @@ import discord from discord.ext import commands from bot.constants import Channels, Colours, Month -from bot.decorators import seasonal_task +from bot.utils.decorators import seasonal_task log = logging.getLogger(__name__) diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py index 67d8796a..1e883d21 100644 --- a/bot/seasons/valentines/be_my_valentine.py +++ b/bot/seasons/valentines/be_my_valentine.py @@ -9,7 +9,7 @@ from discord.ext import commands from discord.ext.commands.cooldowns import BucketType from bot.constants import Channels, Client, Colours, Lovefest, Month -from bot.decorators import in_month +from bot.utils.decorators import in_month log = logging.getLogger(__name__) diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py new file mode 100644 index 00000000..f85996b5 --- /dev/null +++ b/bot/utils/decorators.py @@ -0,0 +1,321 @@ +import asyncio +import functools +import logging +import random +import typing as t +from asyncio import Lock +from datetime import datetime +from functools import wraps +from weakref import WeakValueDictionary + +from discord import Colour, Embed +from discord.ext import commands +from discord.ext.commands import CheckFailure, Command, Context + +from bot.constants import Client, ERROR_REPLIES, Month + +ONE_DAY = 24 * 60 * 60 + +log = logging.getLogger(__name__) + + +class InChannelCheckFailure(CheckFailure): + """Check failure when the user runs a command in a non-whitelisted channel.""" + + pass + + +class InMonthCheckFailure(CheckFailure): + """Check failure for when a command is invoked outside of its allowed month.""" + + pass + + +def seasonal_task(*allowed_months: Month, sleep_time: t.Union[float, int] = ONE_DAY) -> t.Callable: + """ + Perform the decorated method periodically in `allowed_months`. + + This provides a convenience wrapper to avoid code repetition where some task shall + perform an operation repeatedly in a constant interval, but only in specific months. + + The decorated function will be called once every `sleep_time` seconds while + the current UTC month is in `allowed_months`. Sleep time defaults to 24 hours. + + The wrapped task is responsible for waiting for the bot to be ready, if necessary. + """ + def decorator(task_body: t.Callable) -> t.Callable: + @functools.wraps(task_body) + async def decorated_task(*args, **kwargs) -> None: + """Call `task_body` once every `sleep_time` seconds in `allowed_months`.""" + log.info(f"Starting seasonal task {task_body.__qualname__} ({allowed_months})") + + while True: + current_month = Month(datetime.utcnow().month) + + if current_month in allowed_months: + await task_body(*args, **kwargs) + else: + log.debug(f"Seasonal task {task_body.__qualname__} sleeps in {current_month.name}") + + await asyncio.sleep(sleep_time) + return decorated_task + return decorator + + +def in_month_listener(*allowed_months: Month) -> t.Callable: + """ + Shield a listener from being invoked outside of `allowed_months`. + + The check is performed against current UTC month. + """ + def decorator(listener: t.Callable) -> t.Callable: + @functools.wraps(listener) + async def guarded_listener(*args, **kwargs) -> None: + """Wrapped listener will abort if not in allowed month.""" + current_month = Month(datetime.utcnow().month) + + if current_month in allowed_months: + # Propagate return value although it should always be None + return await listener(*args, **kwargs) + else: + log.debug(f"Guarded {listener.__qualname__} from invoking in {current_month.name}") + return guarded_listener + return decorator + + +def in_month_command(*allowed_months: Month) -> t.Callable: + """ + Check whether the command was invoked in one of `enabled_months`. + + Uses the current UTC month at the time of running the predicate. + """ + async def predicate(ctx: Context) -> bool: + current_month = datetime.utcnow().month + can_run = current_month in allowed_months + + human_months = ", ".join(m.name for m in allowed_months) + log.debug( + f"Command '{ctx.command}' is locked to months {human_months}. " + f"Invoking it in month {current_month} is {'allowed' if can_run else 'disallowed'}." + ) + if can_run: + return True + else: + raise InMonthCheckFailure(f"Command can only be used in {human_months}") + + return commands.check(predicate) + + +def in_month(*allowed_months: Month) -> t.Callable: + """ + Universal decorator for season-locking commands and listeners alike. + + This only serves to determine whether the decorated callable is a command, + a listener, or neither. It then delegates to either `in_month_command`, + or `in_month_listener`, or raises TypeError, respectively. + + Please note that in order for this decorator to correctly determine whether + the decorated callable is a cmd or listener, it **has** to first be turned + into one. This means that this decorator should always be placed **above** + the d.py one that registers it as either. + + This will decorate groups as well, as those subclass Command. In order to lock + all subcommands of a group, its `invoke_without_command` param must **not** be + manually set to True - this causes a circumvention of the group's callback + and the seasonal check applied to it. + """ + def decorator(callable_: t.Callable) -> t.Callable: + # Functions decorated as commands are turned into instances of `Command` + if isinstance(callable_, Command): + logging.debug(f"Command {callable_.qualified_name} will be locked to {allowed_months}") + actual_deco = in_month_command(*allowed_months) + + # D.py will assign this attribute when `callable_` is registered as a listener + elif hasattr(callable_, "__cog_listener__"): + logging.debug(f"Listener {callable_.__qualname__} will be locked to {allowed_months}") + actual_deco = in_month_listener(*allowed_months) + + # Otherwise we're unsure exactly what has been decorated + # This happens before the bot starts, so let's just raise + else: + raise TypeError(f"Decorated object {callable_} is neither a command nor a listener") + + return actual_deco(callable_) + return decorator + + +def with_role(*role_ids: int) -> t.Callable: + """Check to see whether the invoking user has any of the roles specified in role_ids.""" + async def predicate(ctx: Context) -> bool: + if not ctx.guild: # Return False in a DM + log.debug( + f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " + "This command is restricted by the with_role decorator. Rejecting request." + ) + return False + + for role in ctx.author.roles: + if role.id in role_ids: + log.debug(f"{ctx.author} has the '{role.name}' role, and passes the check.") + return True + + log.debug( + f"{ctx.author} does not have the required role to use " + f"the '{ctx.command.name}' command, so the request is rejected." + ) + return False + return commands.check(predicate) + + +def without_role(*role_ids: int) -> t.Callable: + """Check whether the invoking user does not have all of the roles specified in role_ids.""" + async def predicate(ctx: Context) -> bool: + if not ctx.guild: # Return False in a DM + log.debug( + f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " + "This command is restricted by the without_role decorator. Rejecting request." + ) + return False + + author_roles = [role.id for role in ctx.author.roles] + check = all(role not in author_roles for role in role_ids) + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The result of the without_role check was {check}." + ) + return check + return commands.check(predicate) + + +def in_channel_check(*channels: int, bypass_roles: t.Container[int] = None) -> t.Callable[[Context], bool]: + """ + Checks that the message is in a whitelisted channel or optionally has a bypass role. + + If `in_channel_override` is present, check if it contains channels + and use them in place of the global whitelist. + """ + def predicate(ctx: Context) -> bool: + if not ctx.guild: + log.debug(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM.") + return True + if ctx.channel.id in channels: + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command " + f"and the command was used in a whitelisted channel." + ) + return True + + if bypass_roles and any(r.id in bypass_roles for r in ctx.author.roles): + log.debug( + f"{ctx.author} called the '{ctx.command.name}' command and " + f"had a role to bypass the in_channel check." + ) + return True + + if hasattr(ctx.command.callback, "in_channel_override"): + override = ctx.command.callback.in_channel_override + if override is None: + log.debug( + f"{ctx.author} called the '{ctx.command.name}' command " + f"and the command was whitelisted to bypass the in_channel check." + ) + return True + else: + if ctx.channel.id in override: + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command " + f"and the command was used in an overridden whitelisted channel." + ) + return True + + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The overridden in_channel check failed." + ) + channels_str = ', '.join(f"<#{c_id}>" for c_id in override) + raise InChannelCheckFailure( + f"Sorry, but you may only use this command within {channels_str}." + ) + + log.debug( + f"{ctx.author} tried to call the '{ctx.command.name}' command. " + f"The in_channel check failed." + ) + + channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) + raise InChannelCheckFailure( + f"Sorry, but you may only use this command within {channels_str}." + ) + + return predicate + + +in_channel = commands.check(in_channel_check) + + +def override_in_channel(channels: t.Tuple[int] = None) -> t.Callable: + """ + Set command callback attribute for detection in `in_channel_check`. + + Override global whitelist if channels are specified. + + This decorator has to go before (below) below the `command` decorator. + """ + def inner(func: t.Callable) -> t.Callable: + func.in_channel_override = channels + return func + + return inner + + +def locked() -> t.Union[t.Callable, None]: + """ + Allows the user to only run one instance of the decorated command at a time. + + Subsequent calls to the command from the same author are ignored until the command has completed invocation. + + This decorator has to go before (below) the `command` decorator. + """ + def wrap(func: t.Callable) -> t.Union[t.Callable, None]: + func.__locks = WeakValueDictionary() + + @wraps(func) + async def inner(self: t.Callable, ctx: Context, *args, **kwargs) -> t.Union[t.Callable, None]: + lock = func.__locks.setdefault(ctx.author.id, Lock()) + if lock.locked(): + embed = Embed() + embed.colour = Colour.red() + + log.debug(f"User tried to invoke a locked command.") + embed.description = ( + "You're already using this command. Please wait until " + "it is done before you use it again." + ) + embed.title = random.choice(ERROR_REPLIES) + await ctx.send(embed=embed) + return + + async with func.__locks.setdefault(ctx.author.id, Lock()): + return await func(self, ctx, *args, **kwargs) + return inner + return wrap + + +def mock_in_debug(return_value: t.Any) -> t.Callable: + """ + Short-circuit function execution if in debug mode and return `return_value`. + + The original function name, and the incoming args and kwargs are DEBUG level logged + upon each call. This is useful for expensive operations, i.e. media asset uploads + that are prone to rate-limits but need to be tested extensively. + """ + def decorator(func: t.Callable) -> t.Callable: + @functools.wraps(func) + async def wrapped(*args, **kwargs) -> t.Any: + """Short-circuit and log if in debug mode.""" + if Client.debug: + log.debug(f"Function {func.__name__} called with args: {args}, kwargs: {kwargs}") + return return_value + return await func(*args, **kwargs) + return wrapped + return decorator -- cgit v1.2.3 From 294915013680c9ad205d6c9fa0c7fa2b79cc1919 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 28 Mar 2020 15:13:26 +0100 Subject: Deseasonify: rename `seasons` pkg to `exts` It is believed that this is now a more logical name for the package, as extensions no longer bind to seasons. Internally, packages are still grouped into seasonal sub-packages. There are quite a few, and it makes sense to group them by a common theme that inspired their functionality. --- bot/__main__.py | 2 +- bot/exts/__init__.py | 201 ++++ bot/exts/christmas/__init__.py | 0 bot/exts/christmas/adventofcode.py | 744 +++++++++++++++ bot/exts/christmas/hanukkah_embed.py | 114 +++ bot/exts/easter/__init__.py | 0 bot/exts/easter/april_fools_vids.py | 39 + bot/exts/easter/avatar_easterifier.py | 129 +++ bot/exts/easter/bunny_name_generator.py | 93 ++ bot/exts/easter/conversationstarters.py | 29 + bot/exts/easter/easter_riddle.py | 101 ++ bot/exts/easter/egg_decorating.py | 119 +++ bot/exts/easter/egg_facts.py | 61 ++ bot/exts/easter/egghead_quiz.py | 120 +++ bot/exts/easter/traditions.py | 31 + bot/exts/evergreen/8bitify.py | 54 ++ bot/exts/evergreen/__init__.py | 0 bot/exts/evergreen/battleship.py | 444 +++++++++ bot/exts/evergreen/bookmark.py | 65 ++ bot/exts/evergreen/branding.py | 504 ++++++++++ bot/exts/evergreen/error_handler.py | 111 +++ bot/exts/evergreen/fun.py | 148 +++ bot/exts/evergreen/game.py | 395 ++++++++ bot/exts/evergreen/help.py | 554 +++++++++++ bot/exts/evergreen/issues.py | 77 ++ bot/exts/evergreen/magic_8ball.py | 32 + bot/exts/evergreen/minesweeper.py | 285 ++++++ bot/exts/evergreen/movie.py | 198 ++++ bot/exts/evergreen/recommend_game.py | 51 + bot/exts/evergreen/reddit.py | 129 +++ bot/exts/evergreen/showprojects.py | 34 + bot/exts/evergreen/snakes/__init__.py | 13 + bot/exts/evergreen/snakes/converter.py | 85 ++ bot/exts/evergreen/snakes/snakes_cog.py | 1149 +++++++++++++++++++++++ bot/exts/evergreen/snakes/utils.py | 716 ++++++++++++++ bot/exts/evergreen/speedrun.py | 28 + bot/exts/evergreen/trivia_quiz.py | 303 ++++++ bot/exts/evergreen/uptime.py | 34 + bot/exts/halloween/8ball.py | 34 + bot/exts/halloween/__init__.py | 0 bot/exts/halloween/candy_collection.py | 225 +++++ bot/exts/halloween/hacktober-issue-finder.py | 111 +++ bot/exts/halloween/hacktoberstats.py | 342 +++++++ bot/exts/halloween/halloween_facts.py | 59 ++ bot/exts/halloween/halloweenify.py | 52 + bot/exts/halloween/monsterbio.py | 56 ++ bot/exts/halloween/monstersurvey.py | 206 ++++ bot/exts/halloween/scarymovie.py | 132 +++ bot/exts/halloween/spookyavatar.py | 53 ++ bot/exts/halloween/spookygif.py | 39 + bot/exts/halloween/spookyrating.py | 67 ++ bot/exts/halloween/spookyreact.py | 76 ++ bot/exts/halloween/spookysound.py | 48 + bot/exts/halloween/timeleft.py | 60 ++ bot/exts/pride/__init__.py | 0 bot/exts/pride/drag_queen_name.py | 33 + bot/exts/pride/pride_anthem.py | 58 ++ bot/exts/pride/pride_avatar.py | 145 +++ bot/exts/pride/pride_facts.py | 107 +++ bot/exts/valentines/__init__.py | 0 bot/exts/valentines/be_my_valentine.py | 237 +++++ bot/exts/valentines/lovecalculator.py | 104 ++ bot/exts/valentines/movie_generator.py | 63 ++ bot/exts/valentines/myvalenstate.py | 87 ++ bot/exts/valentines/pickuplines.py | 45 + bot/exts/valentines/savethedate.py | 42 + bot/exts/valentines/valentine_zodiac.py | 58 ++ bot/exts/valentines/whoisvalentine.py | 53 ++ bot/seasons/__init__.py | 201 ---- bot/seasons/christmas/__init__.py | 0 bot/seasons/christmas/adventofcode.py | 744 --------------- bot/seasons/christmas/hanukkah_embed.py | 114 --- bot/seasons/easter/__init__.py | 0 bot/seasons/easter/april_fools_vids.py | 39 - bot/seasons/easter/avatar_easterifier.py | 129 --- bot/seasons/easter/bunny_name_generator.py | 93 -- bot/seasons/easter/conversationstarters.py | 29 - bot/seasons/easter/easter_riddle.py | 101 -- bot/seasons/easter/egg_decorating.py | 119 --- bot/seasons/easter/egg_facts.py | 61 -- bot/seasons/easter/egghead_quiz.py | 120 --- bot/seasons/easter/traditions.py | 31 - bot/seasons/evergreen/8bitify.py | 54 -- bot/seasons/evergreen/__init__.py | 0 bot/seasons/evergreen/battleship.py | 444 --------- bot/seasons/evergreen/bookmark.py | 65 -- bot/seasons/evergreen/branding.py | 504 ---------- bot/seasons/evergreen/error_handler.py | 111 --- bot/seasons/evergreen/fun.py | 148 --- bot/seasons/evergreen/game.py | 395 -------- bot/seasons/evergreen/help.py | 554 ----------- bot/seasons/evergreen/issues.py | 77 -- bot/seasons/evergreen/magic_8ball.py | 32 - bot/seasons/evergreen/minesweeper.py | 285 ------ bot/seasons/evergreen/movie.py | 198 ---- bot/seasons/evergreen/recommend_game.py | 51 - bot/seasons/evergreen/reddit.py | 129 --- bot/seasons/evergreen/showprojects.py | 34 - bot/seasons/evergreen/snakes/__init__.py | 13 - bot/seasons/evergreen/snakes/converter.py | 85 -- bot/seasons/evergreen/snakes/snakes_cog.py | 1149 ----------------------- bot/seasons/evergreen/snakes/utils.py | 716 -------------- bot/seasons/evergreen/speedrun.py | 28 - bot/seasons/evergreen/trivia_quiz.py | 303 ------ bot/seasons/evergreen/uptime.py | 34 - bot/seasons/halloween/8ball.py | 34 - bot/seasons/halloween/__init__.py | 0 bot/seasons/halloween/candy_collection.py | 225 ----- bot/seasons/halloween/hacktober-issue-finder.py | 111 --- bot/seasons/halloween/hacktoberstats.py | 342 ------- bot/seasons/halloween/halloween_facts.py | 59 -- bot/seasons/halloween/halloweenify.py | 52 - bot/seasons/halloween/monsterbio.py | 56 -- bot/seasons/halloween/monstersurvey.py | 206 ---- bot/seasons/halloween/scarymovie.py | 132 --- bot/seasons/halloween/spookyavatar.py | 53 -- bot/seasons/halloween/spookygif.py | 39 - bot/seasons/halloween/spookyrating.py | 67 -- bot/seasons/halloween/spookyreact.py | 76 -- bot/seasons/halloween/spookysound.py | 48 - bot/seasons/halloween/timeleft.py | 60 -- bot/seasons/pride/__init__.py | 0 bot/seasons/pride/drag_queen_name.py | 33 - bot/seasons/pride/pride_anthem.py | 58 -- bot/seasons/pride/pride_avatar.py | 145 --- bot/seasons/pride/pride_facts.py | 107 --- bot/seasons/valentines/__init__.py | 0 bot/seasons/valentines/be_my_valentine.py | 237 ----- bot/seasons/valentines/lovecalculator.py | 104 -- bot/seasons/valentines/movie_generator.py | 63 -- bot/seasons/valentines/myvalenstate.py | 87 -- bot/seasons/valentines/pickuplines.py | 45 - bot/seasons/valentines/savethedate.py | 42 - bot/seasons/valentines/valentine_zodiac.py | 58 -- bot/seasons/valentines/whoisvalentine.py | 53 -- bot/utils/persist.py | 2 +- 136 files changed, 9784 insertions(+), 9784 deletions(-) create mode 100644 bot/exts/__init__.py create mode 100644 bot/exts/christmas/__init__.py create mode 100644 bot/exts/christmas/adventofcode.py create mode 100644 bot/exts/christmas/hanukkah_embed.py create mode 100644 bot/exts/easter/__init__.py create mode 100644 bot/exts/easter/april_fools_vids.py create mode 100644 bot/exts/easter/avatar_easterifier.py create mode 100644 bot/exts/easter/bunny_name_generator.py create mode 100644 bot/exts/easter/conversationstarters.py create mode 100644 bot/exts/easter/easter_riddle.py create mode 100644 bot/exts/easter/egg_decorating.py create mode 100644 bot/exts/easter/egg_facts.py create mode 100644 bot/exts/easter/egghead_quiz.py create mode 100644 bot/exts/easter/traditions.py create mode 100644 bot/exts/evergreen/8bitify.py create mode 100644 bot/exts/evergreen/__init__.py create mode 100644 bot/exts/evergreen/battleship.py create mode 100644 bot/exts/evergreen/bookmark.py create mode 100644 bot/exts/evergreen/branding.py create mode 100644 bot/exts/evergreen/error_handler.py create mode 100644 bot/exts/evergreen/fun.py create mode 100644 bot/exts/evergreen/game.py create mode 100644 bot/exts/evergreen/help.py create mode 100644 bot/exts/evergreen/issues.py create mode 100644 bot/exts/evergreen/magic_8ball.py create mode 100644 bot/exts/evergreen/minesweeper.py create mode 100644 bot/exts/evergreen/movie.py create mode 100644 bot/exts/evergreen/recommend_game.py create mode 100644 bot/exts/evergreen/reddit.py create mode 100644 bot/exts/evergreen/showprojects.py create mode 100644 bot/exts/evergreen/snakes/__init__.py create mode 100644 bot/exts/evergreen/snakes/converter.py create mode 100644 bot/exts/evergreen/snakes/snakes_cog.py create mode 100644 bot/exts/evergreen/snakes/utils.py create mode 100644 bot/exts/evergreen/speedrun.py create mode 100644 bot/exts/evergreen/trivia_quiz.py create mode 100644 bot/exts/evergreen/uptime.py create mode 100644 bot/exts/halloween/8ball.py create mode 100644 bot/exts/halloween/__init__.py create mode 100644 bot/exts/halloween/candy_collection.py create mode 100644 bot/exts/halloween/hacktober-issue-finder.py create mode 100644 bot/exts/halloween/hacktoberstats.py create mode 100644 bot/exts/halloween/halloween_facts.py create mode 100644 bot/exts/halloween/halloweenify.py create mode 100644 bot/exts/halloween/monsterbio.py create mode 100644 bot/exts/halloween/monstersurvey.py create mode 100644 bot/exts/halloween/scarymovie.py create mode 100644 bot/exts/halloween/spookyavatar.py create mode 100644 bot/exts/halloween/spookygif.py create mode 100644 bot/exts/halloween/spookyrating.py create mode 100644 bot/exts/halloween/spookyreact.py create mode 100644 bot/exts/halloween/spookysound.py create mode 100644 bot/exts/halloween/timeleft.py create mode 100644 bot/exts/pride/__init__.py create mode 100644 bot/exts/pride/drag_queen_name.py create mode 100644 bot/exts/pride/pride_anthem.py create mode 100644 bot/exts/pride/pride_avatar.py create mode 100644 bot/exts/pride/pride_facts.py create mode 100644 bot/exts/valentines/__init__.py create mode 100644 bot/exts/valentines/be_my_valentine.py create mode 100644 bot/exts/valentines/lovecalculator.py create mode 100644 bot/exts/valentines/movie_generator.py create mode 100644 bot/exts/valentines/myvalenstate.py create mode 100644 bot/exts/valentines/pickuplines.py create mode 100644 bot/exts/valentines/savethedate.py create mode 100644 bot/exts/valentines/valentine_zodiac.py create mode 100644 bot/exts/valentines/whoisvalentine.py delete mode 100644 bot/seasons/__init__.py delete mode 100644 bot/seasons/christmas/__init__.py delete mode 100644 bot/seasons/christmas/adventofcode.py delete mode 100644 bot/seasons/christmas/hanukkah_embed.py delete mode 100644 bot/seasons/easter/__init__.py delete mode 100644 bot/seasons/easter/april_fools_vids.py delete mode 100644 bot/seasons/easter/avatar_easterifier.py delete mode 100644 bot/seasons/easter/bunny_name_generator.py delete mode 100644 bot/seasons/easter/conversationstarters.py delete mode 100644 bot/seasons/easter/easter_riddle.py delete mode 100644 bot/seasons/easter/egg_decorating.py delete mode 100644 bot/seasons/easter/egg_facts.py delete mode 100644 bot/seasons/easter/egghead_quiz.py delete mode 100644 bot/seasons/easter/traditions.py delete mode 100644 bot/seasons/evergreen/8bitify.py delete mode 100644 bot/seasons/evergreen/__init__.py delete mode 100644 bot/seasons/evergreen/battleship.py delete mode 100644 bot/seasons/evergreen/bookmark.py delete mode 100644 bot/seasons/evergreen/branding.py delete mode 100644 bot/seasons/evergreen/error_handler.py delete mode 100644 bot/seasons/evergreen/fun.py delete mode 100644 bot/seasons/evergreen/game.py delete mode 100644 bot/seasons/evergreen/help.py delete mode 100644 bot/seasons/evergreen/issues.py delete mode 100644 bot/seasons/evergreen/magic_8ball.py delete mode 100644 bot/seasons/evergreen/minesweeper.py delete mode 100644 bot/seasons/evergreen/movie.py delete mode 100644 bot/seasons/evergreen/recommend_game.py delete mode 100644 bot/seasons/evergreen/reddit.py delete mode 100644 bot/seasons/evergreen/showprojects.py delete mode 100644 bot/seasons/evergreen/snakes/__init__.py delete mode 100644 bot/seasons/evergreen/snakes/converter.py delete mode 100644 bot/seasons/evergreen/snakes/snakes_cog.py delete mode 100644 bot/seasons/evergreen/snakes/utils.py delete mode 100644 bot/seasons/evergreen/speedrun.py delete mode 100644 bot/seasons/evergreen/trivia_quiz.py delete mode 100644 bot/seasons/evergreen/uptime.py delete mode 100644 bot/seasons/halloween/8ball.py delete mode 100644 bot/seasons/halloween/__init__.py delete mode 100644 bot/seasons/halloween/candy_collection.py delete mode 100644 bot/seasons/halloween/hacktober-issue-finder.py delete mode 100644 bot/seasons/halloween/hacktoberstats.py delete mode 100644 bot/seasons/halloween/halloween_facts.py delete mode 100644 bot/seasons/halloween/halloweenify.py delete mode 100644 bot/seasons/halloween/monsterbio.py delete mode 100644 bot/seasons/halloween/monstersurvey.py delete mode 100644 bot/seasons/halloween/scarymovie.py delete mode 100644 bot/seasons/halloween/spookyavatar.py delete mode 100644 bot/seasons/halloween/spookygif.py delete mode 100644 bot/seasons/halloween/spookyrating.py delete mode 100644 bot/seasons/halloween/spookyreact.py delete mode 100644 bot/seasons/halloween/spookysound.py delete mode 100644 bot/seasons/halloween/timeleft.py delete mode 100644 bot/seasons/pride/__init__.py delete mode 100644 bot/seasons/pride/drag_queen_name.py delete mode 100644 bot/seasons/pride/pride_anthem.py delete mode 100644 bot/seasons/pride/pride_avatar.py delete mode 100644 bot/seasons/pride/pride_facts.py delete mode 100644 bot/seasons/valentines/__init__.py delete mode 100644 bot/seasons/valentines/be_my_valentine.py delete mode 100644 bot/seasons/valentines/lovecalculator.py delete mode 100644 bot/seasons/valentines/movie_generator.py delete mode 100644 bot/seasons/valentines/myvalenstate.py delete mode 100644 bot/seasons/valentines/pickuplines.py delete mode 100644 bot/seasons/valentines/savethedate.py delete mode 100644 bot/seasons/valentines/valentine_zodiac.py delete mode 100644 bot/seasons/valentines/whoisvalentine.py (limited to 'bot/seasons/christmas/adventofcode.py') diff --git a/bot/__main__.py b/bot/__main__.py index 47d99648..9a981c5c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -2,7 +2,7 @@ import logging from bot.bot import bot from bot.constants import Client, STAFF_ROLES, WHITELISTED_CHANNELS -from bot.seasons import get_extensions +from bot.exts import get_extensions from bot.utils.decorators import in_channel_check log = logging.getLogger(__name__) diff --git a/bot/exts/__init__.py b/bot/exts/__init__.py new file mode 100644 index 00000000..5ebf34d8 --- /dev/null +++ b/bot/exts/__init__.py @@ -0,0 +1,201 @@ +import logging +import pkgutil +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Set, Type + +from bot.constants import Colours, Month + +__all__ = ( + "SeasonBase", + "Christmas", + "Easter", + "Halloween", + "Pride", + "Valentines", + "Wildcard", + "get_season_names", + "get_extensions", + "get_current_season", + "get_season", +) + +log = logging.getLogger(__name__) + + +class SeasonBase: + """ + Base for Seasonal classes. + + This serves as the off-season fallback for when no specific + seasons are active. + + Seasons are 'registered' simply by inheriting from `SeasonBase`. + We discover them by calling `__subclasses__`. + """ + + season_name: str = "Evergreen" + bot_name: str = "SeasonalBot" + + colour: str = Colours.soft_green + description: str = "The default season!" + + branding_path: str = "seasonal/evergreen" + + months: Set[Month] = set(Month) + + +class Christmas(SeasonBase): + """Branding for december.""" + + season_name = "Festive season" + bot_name = "Merrybot" + + colour = Colours.soft_red + description = ( + "The time is here to get into the festive spirit! No matter who you are, where you are, " + "or what beliefs you may follow, we hope every one of you enjoy this festive season!" + ) + + branding_path = "seasonal/christmas" + + months = {Month.december} + + +class Easter(SeasonBase): + """Branding for april.""" + + season_name = "Easter" + bot_name = "BunnyBot" + + colour = Colours.bright_green + description = ( + "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate " + "our version of Easter during the entire month of April." + ) + + branding_path = "seasonal/easter" + + months = {Month.april} + + +class Halloween(SeasonBase): + """Branding for october.""" + + season_name = "Halloween" + bot_name = "NeonBot" + + colour = Colours.orange + description = "Trick or treat?!" + + branding_path = "seasonal/halloween" + + months = {Month.october} + + +class Pride(SeasonBase): + """Branding for june.""" + + season_name = "Pride" + bot_name = "ProudBot" + + colour = Colours.pink + description = ( + "The month of June is a special month for us at Python Discord. It is very important to us " + "that everyone feels welcome here, no matter their origin, identity or sexuality. During the " + "month of June, while some of you are participating in Pride festivals across the world, " + "we will be celebrating individuality and commemorating the history and challenges " + "of the LGBTQ+ community with a Pride event of our own!" + ) + + branding_path = "seasonal/pride" + + months = {Month.june} + + +class Valentines(SeasonBase): + """Branding for february.""" + + season_name = "Valentines" + bot_name = "TenderBot" + + colour = Colours.pink + description = "Love is in the air!" + + branding_path = "seasonal/valentines" + + months = {Month.february} + + +class Wildcard(SeasonBase): + """Branding for august.""" + + season_name = "Wildcard" + bot_name = "RetroBot" + + colour = Colours.purple + description = "A season full of surprises!" + + months = {Month.august} + + +def get_season_names() -> List[str]: + """Return names of all packages located in /bot/exts/.""" + seasons = [ + package.name + for package in pkgutil.iter_modules(__path__) + if package.ispkg + ] + + return seasons + + +def get_extensions() -> List[str]: + """ + Give a list of dot-separated paths to all extensions. + + The strings are formatted in a way such that the bot's `load_extension` + method can take them. Use this to load all available extensions. + """ + base_path = Path(__path__[0]) + extensions = [] + + for season in get_season_names(): + for module in pkgutil.iter_modules([base_path.joinpath(season)]): + extensions.append(f"bot.exts.{season}.{module.name}") + + return extensions + + +def get_current_season() -> Type[SeasonBase]: + """Give active season, based on current UTC month.""" + current_month = Month(datetime.utcnow().month) + + active_seasons = tuple( + season + for season in SeasonBase.__subclasses__() + if current_month in season.months + ) + + if not active_seasons: + return SeasonBase + + if len(active_seasons) > 1: + log.warning(f"Multiple active season in month {current_month.name}") + + return active_seasons[0] + + +def get_season(name: str) -> Optional[Type[SeasonBase]]: + """ + Give season such that its class name or its `season_name` attr match `name` (caseless). + + If no such season exists, return None. + """ + name = name.casefold() + + for season in [SeasonBase] + SeasonBase.__subclasses__(): + matches = (season.__name__.casefold(), season.season_name.casefold()) + + if name in matches: + return season diff --git a/bot/exts/christmas/__init__.py b/bot/exts/christmas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/christmas/adventofcode.py b/bot/exts/christmas/adventofcode.py new file mode 100644 index 00000000..f7590e04 --- /dev/null +++ b/bot/exts/christmas/adventofcode.py @@ -0,0 +1,744 @@ +import asyncio +import json +import logging +import math +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Tuple + +import aiohttp +import discord +from bs4 import BeautifulSoup +from discord.ext import commands +from pytz import timezone + +from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS +from bot.utils import unlocked_role +from bot.utils.decorators import in_month, override_in_channel + +log = logging.getLogger(__name__) + +AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} +AOC_SESSION_COOKIE = {"session": Tokens.aoc_session_cookie} + +EST = timezone("EST") +COUNTDOWN_STEP = 60 * 5 + +AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) + + +def is_in_advent() -> bool: + """Utility function to check if we are between December 1st and December 25th.""" + # Run the code from the 1st to the 24th + return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 + + +def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: + """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" + # Change all time properties back to 00:00 + todays_midnight = datetime.now(EST).replace(microsecond=0, + second=0, + minute=0, + hour=0) + + # We want tomorrow so add a day on + tomorrow = todays_midnight + timedelta(days=1) + + # Calculate the timedelta between the current time and midnight + return tomorrow, tomorrow - datetime.now(EST) + + +async def countdown_status(bot: commands.Bot) -> None: + """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" + while is_in_advent(): + _, time_left = time_left_to_aoc_midnight() + + aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP + hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 + + if aligned_seconds == 0: + playing = f"right now!" + elif aligned_seconds == COUNTDOWN_STEP: + playing = f"in less than {minutes} minutes" + elif hours == 0: + playing = f"in {minutes} minutes" + elif hours == 23: + playing = f"since {60 - minutes} minutes ago" + else: + playing = f"in {hours} hours and {minutes} minutes" + + # Status will look like "Playing in 5 hours and 30 minutes" + await bot.change_presence(activity=discord.Game(playing)) + + # Sleep until next aligned time or a full step if already aligned + delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP + await asyncio.sleep(delay) + + +async def day_countdown(bot: commands.Bot) -> None: + """ + Calculate the number of seconds left until the next day of Advent. + + Once we have calculated this we should then sleep that number and when the time is reached, ping + the Advent of Code role notifying them that the new challenge is ready. + """ + while is_in_advent(): + tomorrow, time_left = time_left_to_aoc_midnight() + + # Correct `time_left.seconds` for the sleep we have after unlocking the role (-5) and adding + # a second (+1) as the bot is consistently ~0.5 seconds early in announcing the puzzles. + await asyncio.sleep(time_left.seconds - 4) + + channel = bot.get_channel(Channels.advent_of_code) + + if not channel: + log.error("Could not find the AoC channel to send notification in") + break + + aoc_role = channel.guild.get_role(AocConfig.role_id) + if not aoc_role: + log.error("Could not find the AoC role to announce the daily puzzle") + break + + async with unlocked_role(aoc_role, delay=5): + puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" + + # Check if the puzzle is already available to prevent our members from spamming + # the puzzle page before it's available by making a small HEAD request. + for retry in range(1, 5): + log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") + async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: + if resp.status == 200: + log.debug("Puzzle is available; let's send an announcement message.") + break + log.debug(f"The puzzle is not yet available (status={resp.status})") + await asyncio.sleep(10) + else: + log.error("The puzzle does does not appear to be available at this time, canceling announcement") + break + + await channel.send( + f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " + f"View it online now at {puzzle_url}. Good luck!" + ) + + # Wait a couple minutes so that if our sleep didn't sleep enough + # time we don't end up announcing twice. + await asyncio.sleep(120) + + +class AdventOfCode(commands.Cog): + """Advent of Code festivities! Ho Ho Ho!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + self._base_url = f"https://adventofcode.com/{AocConfig.year}" + self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" + self.private_leaderboard_url = f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_id}" + + self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") + self.cached_about_aoc = self._build_about_embed() + + self.cached_global_leaderboard = None + self.cached_private_leaderboard = None + + self.countdown_task = None + self.status_task = None + + countdown_coro = day_countdown(self.bot) + self.countdown_task = self.bot.loop.create_task(countdown_coro) + + status_coro = countdown_status(self.bot) + self.status_task = self.bot.loop.create_task(status_coro) + + @in_month(Month.december) + @commands.group(name="adventofcode", aliases=("aoc",)) + @override_in_channel(AOC_WHITELIST) + async def adventofcode_group(self, ctx: commands.Context) -> None: + """All of the Advent of Code commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @adventofcode_group.command( + name="subscribe", + aliases=("sub", "notifications", "notify", "notifs"), + brief="Notifications for new days" + ) + @override_in_channel(AOC_WHITELIST) + async def aoc_subscribe(self, ctx: commands.Context) -> None: + """Assign the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) + unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" + + if role not in ctx.author.roles: + await ctx.author.add_roles(role) + await ctx.send("Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. " + f"You can run `{unsubscribe_command}` to disable them again for you.") + else: + await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " + f"If you don't want them any more, run `{unsubscribe_command}` instead.") + + @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") + @override_in_channel(AOC_WHITELIST) + async def aoc_unsubscribe(self, ctx: commands.Context) -> None: + """Remove the role for notifications about new days being ready.""" + role = ctx.guild.get_role(AocConfig.role_id) + + if role in ctx.author.roles: + await ctx.author.remove_roles(role) + await ctx.send("Okay! You have been __unsubscribed__ from notifications about new Advent of Code tasks.") + else: + await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") + + @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") + @override_in_channel(AOC_WHITELIST) + async def aoc_countdown(self, ctx: commands.Context) -> None: + """Return time left until next day.""" + if not is_in_advent(): + datetime_now = datetime.now(EST) + + # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past + this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) + next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) + deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) + delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta + + # Add a finer timedelta if there's less than a day left + if delta.days == 0: + delta_str = f"approximately {delta.seconds // 3600} hours" + else: + delta_str = f"{delta.days} days" + + await ctx.send(f"The Advent of Code event is not currently running. " + f"The next event will start in {delta_str}.") + return + + tomorrow, time_left = time_left_to_aoc_midnight() + + hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 + + await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") + + @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") + @override_in_channel(AOC_WHITELIST) + async def about_aoc(self, ctx: commands.Context) -> None: + """Respond with an explanation of all things Advent of Code.""" + await ctx.send("", embed=self.cached_about_aoc) + + @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") + @override_in_channel(AOC_WHITELIST) + async def join_leaderboard(self, ctx: commands.Context) -> None: + """DM the user the information for joining the PyDis AoC private leaderboard.""" + author = ctx.message.author + log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") + + info_str = ( + "Head over to https://adventofcode.com/leaderboard/private " + f"with code `{AocConfig.leaderboard_join_code}` to join the PyDis private leaderboard!" + ) + try: + await author.send(info_str) + except discord.errors.Forbidden: + log.debug(f"{author.name} ({author.id}) has disabled DMs from server members") + await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") + else: + await ctx.message.add_reaction(Emojis.envelope) + + @adventofcode_group.command( + name="leaderboard", + aliases=("board", "lb"), + brief="Get a snapshot of the PyDis private AoC leaderboard", + ) + @override_in_channel(AOC_WHITELIST) + async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: + """ + Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed. + + For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the + Advent of Code section of the bot constants. number_of_people_to_display values greater than this + limit will default to this maximum and provide feedback to the user. + """ + async with ctx.typing(): + await self._check_leaderboard_cache(ctx) + + if not self.cached_private_leaderboard: + # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() + # Short circuit here if there's an issue + return + + number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) + + # Generate leaderboard table for embed + members_to_print = self.cached_private_leaderboard.top_n(number_of_people_to_display) + table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) + + # Build embed + aoc_embed = discord.Embed( + description=f"Total members: {len(self.cached_private_leaderboard.members)}", + colour=Colours.soft_green, + timestamp=self.cached_private_leaderboard.last_updated + ) + aoc_embed.set_author(name="Advent of Code", url=self.private_leaderboard_url) + aoc_embed.set_footer(text="Last Updated") + + await ctx.send( + content=f"Here's the current Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", + embed=aoc_embed, + ) + + @adventofcode_group.command( + name="stats", + aliases=("dailystats", "ds"), + brief="Get daily statistics for the PyDis private leaderboard" + ) + @override_in_channel(AOC_WHITELIST) + async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: + """ + Respond with a table of the daily completion statistics for the PyDis private leaderboard. + + Embed will display the total members and the number of users who have completed each day's puzzle + """ + async with ctx.typing(): + await self._check_leaderboard_cache(ctx) + + if not self.cached_private_leaderboard: + # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() + # Short circuit here if there's an issue + return + + # Build ASCII table + total_members = len(self.cached_private_leaderboard.members) + _star = Emojis.star + header = f"{'Day':4}{_star:^8}{_star*2:^4}{'% ' + _star:^8}{'% ' + _star*2:^4}\n{'='*35}" + table = "" + for day, completions in enumerate(self.cached_private_leaderboard.daily_completion_summary): + per_one_star = f"{(completions[0]/total_members)*100:.2f}" + per_two_star = f"{(completions[1]/total_members)*100:.2f}" + + table += f"{day+1:3}){completions[0]:^8}{completions[1]:^6}{per_one_star:^10}{per_two_star:^6}\n" + + table = f"```\n{header}\n{table}```" + + # Build embed + daily_stats_embed = discord.Embed( + colour=Colours.soft_green, timestamp=self.cached_private_leaderboard.last_updated + ) + daily_stats_embed.set_author(name="Advent of Code", url=self._base_url) + daily_stats_embed.set_footer(text="Last Updated") + + await ctx.send( + content=f"Here's the current daily statistics!\n\n{table}", embed=daily_stats_embed + ) + + @adventofcode_group.command( + name="global", + aliases=("globalboard", "gb"), + brief="Get a snapshot of the global AoC leaderboard", + ) + @override_in_channel(AOC_WHITELIST) + async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: + """ + Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed. + + For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the + Advent of Code section of the bot constants. number_of_people_to_display values greater than this + limit will default to this maximum and provide feedback to the user. + """ + async with ctx.typing(): + await self._check_leaderboard_cache(ctx, global_board=True) + + if not self.cached_global_leaderboard: + # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() + # Short circuit here if there's an issue + return + + number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) + + # Generate leaderboard table for embed + members_to_print = self.cached_global_leaderboard.top_n(number_of_people_to_display) + table = AocGlobalLeaderboard.build_leaderboard_embed(members_to_print) + + # Build embed + aoc_embed = discord.Embed(colour=Colours.soft_green, timestamp=self.cached_global_leaderboard.last_updated) + aoc_embed.set_author(name="Advent of Code", url=self._base_url) + aoc_embed.set_footer(text="Last Updated") + + await ctx.send( + f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", + embed=aoc_embed, + ) + + async def _check_leaderboard_cache(self, ctx: commands.Context, global_board: bool = False) -> None: + """ + Check age of current leaderboard & pull a new one if the board is too old. + + global_board is a boolean to toggle between the global board and the Pydis private board + """ + # Toggle between global & private leaderboards + if global_board: + log.debug("Checking global leaderboard cache") + leaderboard_str = "cached_global_leaderboard" + _shortstr = "global" + else: + log.debug("Checking private leaderboard cache") + leaderboard_str = "cached_private_leaderboard" + _shortstr = "private" + + leaderboard = getattr(self, leaderboard_str) + if not leaderboard: + log.debug(f"No cached {_shortstr} leaderboard found") + await self._boardgetter(global_board) + else: + leaderboard_age = datetime.utcnow() - leaderboard.last_updated + age_seconds = leaderboard_age.total_seconds() + if age_seconds < AocConfig.leaderboard_cache_age_threshold_seconds: + log.debug(f"Cached {_shortstr} leaderboard age less than threshold ({age_seconds} seconds old)") + else: + log.debug(f"Cached {_shortstr} leaderboard age greater than threshold ({age_seconds} seconds old)") + await self._boardgetter(global_board) + + leaderboard = getattr(self, leaderboard_str) + if not leaderboard: + await ctx.send( + "", + embed=_error_embed_helper( + title=f"Something's gone wrong and there's no cached {_shortstr} leaderboard!", + description="Please check in with a staff member.", + ), + ) + + async def _check_n_entries(self, ctx: commands.Context, number_of_people_to_display: int) -> int: + """Check for n > max_entries and n <= 0.""" + max_entries = AocConfig.leaderboard_max_displayed_members + author = ctx.message.author + if not 0 <= number_of_people_to_display <= max_entries: + log.debug( + f"{author.name} ({author.id}) attempted to fetch an invalid number " + f" of entries from the AoC leaderboard ({number_of_people_to_display})" + ) + await ctx.send( + f":x: {author.mention}, number of entries to display must be a positive " + f"integer less than or equal to {max_entries}\n\n" + f"Head to {self.private_leaderboard_url} to view the entire leaderboard" + ) + number_of_people_to_display = max_entries + + return number_of_people_to_display + + def _build_about_embed(self) -> discord.Embed: + """Build and return the informational "About AoC" embed from the resources file.""" + with self.about_aoc_filepath.open("r") as f: + embed_fields = json.load(f) + + about_embed = discord.Embed(title=self._base_url, colour=Colours.soft_green, url=self._base_url) + about_embed.set_author(name="Advent of Code", url=self._base_url) + for field in embed_fields: + about_embed.add_field(**field) + + about_embed.set_footer(text=f"Last Updated (UTC): {datetime.utcnow()}") + + return about_embed + + async def _boardgetter(self, global_board: bool) -> None: + """Invoke the proper leaderboard getter based on the global_board boolean.""" + if global_board: + self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() + else: + self.cached_private_leaderboard = await AocPrivateLeaderboard.from_url() + + def cog_unload(self) -> None: + """Cancel season-related tasks on cog unload.""" + log.debug("Unloading the cog and canceling the background task.") + self.countdown_task.cancel() + self.status_task.cancel() + + +class AocMember: + """Object representing the Advent of Code user.""" + + def __init__(self, name: str, aoc_id: int, stars: int, starboard: list, local_score: int, global_score: int): + self.name = name + self.aoc_id = aoc_id + self.stars = stars + self.starboard = starboard + self.local_score = local_score + self.global_score = global_score + self.completions = self._completions_from_starboard(self.starboard) + + def __repr__(self): + """Generate a user-friendly representation of the AocMember & their score.""" + return f"<{self.name} ({self.aoc_id}): {self.local_score}>" + + @classmethod + def member_from_json(cls, injson: dict) -> "AocMember": + """ + Generate an AocMember from AoC's private leaderboard API JSON. + + injson is expected to be the dict contained in: + + AoC_APIjson['members'][:str] + + Returns an AocMember object + """ + return cls( + name=injson["name"] if injson["name"] else "Anonymous User", + aoc_id=int(injson["id"]), + stars=injson["stars"], + starboard=cls._starboard_from_json(injson["completion_day_level"]), + local_score=injson["local_score"], + global_score=injson["global_score"], + ) + + @staticmethod + def _starboard_from_json(injson: dict) -> list: + """ + Generate starboard from AoC's private leaderboard API JSON. + + injson is expected to be the dict contained in: + + AoC_APIjson['members'][:str]['completion_day_level'] + + Returns a list of 25 lists, where each nested list contains a pair of booleans representing + the code challenge completion status for that day + """ + # Basic input validation + if not isinstance(injson, dict): + raise ValueError + + # Initialize starboard + starboard = [] + for _i in range(25): + starboard.append([False, False]) + + # Iterate over days, which are the keys of injson (as str) + for day in injson: + idx = int(day) - 1 + # If there is a second star, the first star must be completed + if "2" in injson[day].keys(): + starboard[idx] = [True, True] + # If the day exists in injson, then at least the first star is completed + else: + starboard[idx] = [True, False] + + return starboard + + @staticmethod + def _completions_from_starboard(starboard: list) -> tuple: + """Return days completed, as a (1 star, 2 star) tuple, from starboard.""" + completions = [0, 0] + for day in starboard: + if day[0]: + completions[0] += 1 + if day[1]: + completions[1] += 1 + + return tuple(completions) + + +class AocPrivateLeaderboard: + """Object representing the Advent of Code private leaderboard.""" + + def __init__(self, members: list, owner_id: int, event_year: int): + self.members = members + self._owner_id = owner_id + self._event_year = event_year + self.last_updated = datetime.utcnow() + + self.daily_completion_summary = self.calculate_daily_completion() + + def top_n(self, n: int = 10) -> dict: + """ + Return the top n participants on the leaderboard. + + If n is not specified, default to the top 10 + """ + return self.members[:n] + + def calculate_daily_completion(self) -> List[tuple]: + """ + Calculate member completion rates by day. + + Return a list of tuples for each day containing the number of users who completed each part + of the challenge + """ + daily_member_completions = [] + for day in range(25): + one_star_count = 0 + two_star_count = 0 + for member in self.members: + if member.starboard[day][1]: + one_star_count += 1 + two_star_count += 1 + elif member.starboard[day][0]: + one_star_count += 1 + else: + daily_member_completions.append((one_star_count, two_star_count)) + + return(daily_member_completions) + + @staticmethod + async def json_from_url( + leaderboard_id: int = AocConfig.leaderboard_id, year: int = AocConfig.year + ) -> "AocPrivateLeaderboard": + """ + Request the API JSON from Advent of Code for leaderboard_id for the specified year's event. + + If no year is input, year defaults to the current year + """ + api_url = f"https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" + + log.debug("Querying Advent of Code Private Leaderboard API") + async with aiohttp.ClientSession(cookies=AOC_SESSION_COOKIE, headers=AOC_REQUEST_HEADER) as session: + async with session.get(api_url) as resp: + if resp.status == 200: + raw_dict = await resp.json() + else: + log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") + resp.raise_for_status() + + return raw_dict + + @classmethod + def from_json(cls, injson: dict) -> "AocPrivateLeaderboard": + """Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON.""" + return cls( + members=cls._sorted_members(injson["members"]), owner_id=injson["owner_id"], event_year=injson["event"] + ) + + @classmethod + async def from_url(cls) -> "AocPrivateLeaderboard": + """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" + api_json = await cls.json_from_url() + return cls.from_json(api_json) + + @staticmethod + def _sorted_members(injson: dict) -> list: + """ + Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON. + + Output list is sorted based on the AocMember.local_score + """ + members = [AocMember.member_from_json(injson[member]) for member in injson] + members.sort(key=lambda x: x.local_score, reverse=True) + + return members + + @staticmethod + def build_leaderboard_embed(members_to_print: List[AocMember]) -> str: + """ + Build a text table from members_to_print, a list of AocMember objects. + + Returns a string to be used as the content of the bot's leaderboard response + """ + stargroup = f"{Emojis.star}, {Emojis.star*2}" + header = f"{' '*3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-'*44}" + table = "" + for i, member in enumerate(members_to_print): + if member.name == "Anonymous User": + name = f"{member.name} #{member.aoc_id}" + else: + name = member.name + + table += ( + f"{i+1:2}) {member.local_score:4} {name:25.25} " + f"({member.completions[0]:2}, {member.completions[1]:2})\n" + ) + else: + table = f"```{header}\n{table}```" + + return table + + +class AocGlobalLeaderboard: + """Object representing the Advent of Code global leaderboard.""" + + def __init__(self, members: List[tuple]): + self.members = members + self.last_updated = datetime.utcnow() + + def top_n(self, n: int = 10) -> dict: + """ + Return the top n participants on the leaderboard. + + If n is not specified, default to the top 10 + """ + return self.members[:n] + + @classmethod + async def from_url(cls) -> "AocGlobalLeaderboard": + """ + Generate an list of tuples for the entries on AoC's global leaderboard. + + Because there is no API for this, web scraping needs to be used + """ + aoc_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" + + async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER) as session: + async with session.get(aoc_url) as resp: + if resp.status == 200: + raw_html = await resp.text() + else: + log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") + resp.raise_for_status() + + soup = BeautifulSoup(raw_html, "html.parser") + ele = soup.find_all("div", class_="leaderboard-entry") + + exp = r"(?:[ ]{,2}(\d+)\))?[ ]+(\d+)\s+([\w\(\)\#\@\-\d ]+)" + + lb_list = [] + for entry in ele: + # Strip off the AoC++ decorator + raw_str = entry.text.replace("(AoC++)", "").rstrip() + + # Use a regex to extract the info from the string to unify formatting + # Group 1: Rank + # Group 2: Global Score + # Group 3: Member string + r = re.match(exp, raw_str) + + rank = int(r.group(1)) if r.group(1) else None + global_score = int(r.group(2)) + + member = r.group(3) + if member.lower().startswith("(anonymous"): + # Normalize anonymous user string by stripping () and title casing + member = re.sub(r"[\(\)]", "", member).title() + + lb_list.append((rank, global_score, member)) + + return cls(lb_list) + + @staticmethod + def build_leaderboard_embed(members_to_print: List[tuple]) -> str: + """ + Build a text table from members_to_print, a list of tuples. + + Returns a string to be used as the content of the bot's leaderboard response + """ + header = f"{' '*4}{'Score'} {'Name':^25}\n{'-'*36}" + table = "" + for member in members_to_print: + # In the event of a tie, rank is None + if member[0]: + rank = f"{member[0]:3})" + else: + rank = f"{' ':4}" + table += f"{rank} {member[1]:4} {member[2]:25.25}\n" + else: + table = f"```{header}\n{table}```" + + return table + + +def _error_embed_helper(title: str, description: str) -> discord.Embed: + """Return a red-colored Embed with the given title and description.""" + return discord.Embed(title=title, description=description, colour=discord.Colour.red()) + + +def setup(bot: commands.Bot) -> None: + """Advent of Code Cog load.""" + bot.add_cog(AdventOfCode(bot)) + log.info("AdventOfCode cog loaded") diff --git a/bot/exts/christmas/hanukkah_embed.py b/bot/exts/christmas/hanukkah_embed.py new file mode 100644 index 00000000..62efd04e --- /dev/null +++ b/bot/exts/christmas/hanukkah_embed.py @@ -0,0 +1,114 @@ +import datetime +import logging +from typing import List + +from discord import Embed +from discord.ext import commands + +from bot.constants import Colours, Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + + +class HanukkahEmbed(commands.Cog): + """A cog that returns information about Hanukkah festival.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.url = ("https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" + "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on") + self.hanukkah_days = [] + self.hanukkah_months = [] + self.hanukkah_years = [] + + async def get_hanukkah_dates(self) -> List[str]: + """Gets the dates for hanukkah festival.""" + hanukkah_dates = [] + async with self.bot.http_session.get(self.url) as response: + json_data = await response.json() + festivals = json_data['items'] + for festival in festivals: + if festival['title'].startswith('Chanukah'): + date = festival['date'] + hanukkah_dates.append(date) + return hanukkah_dates + + @in_month(Month.december) + @commands.command(name='hanukkah', aliases=['chanukah']) + async def hanukkah_festival(self, ctx: commands.Context) -> None: + """Tells you about the Hanukkah Festivaltime of festival, festival day, etc).""" + hanukkah_dates = await self.get_hanukkah_dates() + self.hanukkah_dates_split(hanukkah_dates) + hanukkah_start_day = int(self.hanukkah_days[0]) + hanukkah_start_month = int(self.hanukkah_months[0]) + hanukkah_start_year = int(self.hanukkah_years[0]) + hanukkah_end_day = int(self.hanukkah_days[8]) + hanukkah_end_month = int(self.hanukkah_months[8]) + hanukkah_end_year = int(self.hanukkah_years[8]) + + hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day) + hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day) + today = datetime.date.today() + # today = datetime.date(2019, 12, 24) (for testing) + day = str(today.day) + month = str(today.month) + year = str(today.year) + embed = Embed() + embed.title = 'Hanukkah' + embed.colour = Colours.blue + if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years: + if int(day) == hanukkah_start_day: + now = datetime.datetime.utcnow() + now = str(now) + hours = int(now[11:13]) + 4 # using only hours + hanukkah_start_hour = 18 + if hours < hanukkah_start_hour: + embed.description = (f"Hanukkah hasnt started yet, " + f"it will start in about {hanukkah_start_hour-hours} hour/s.") + return await ctx.send(embed=embed) + elif hours > hanukkah_start_hour: + embed.description = (f'It is the starting day of Hanukkah ! ' + f'Its been {hours-hanukkah_start_hour} hours hanukkah started !') + return await ctx.send(embed=embed) + festival_day = self.hanukkah_days.index(day) + number_suffixes = ['st', 'nd', 'rd', 'th'] + suffix = '' + if int(festival_day) == 1: + suffix = number_suffixes[0] + if int(festival_day) == 2: + suffix = number_suffixes[1] + if int(festival_day) == 3: + suffix = number_suffixes[2] + if int(festival_day) > 3: + suffix = number_suffixes[3] + message = '' + for _ in range(1, festival_day + 1): + message += ':menorah:' + embed.description = f'It is the {festival_day}{suffix} day of Hanukkah ! \n {message}' + await ctx.send(embed=embed) + else: + if today < hanukkah_start: + festival_starting_month = hanukkah_start.strftime('%B') + embed.description = (f"Hanukkah has not started yet. " + f"Hanukkah will start at sundown on {hanukkah_start_day}th " + f"of {festival_starting_month}.") + else: + festival_end_month = hanukkah_end.strftime('%B') + embed.description = (f"Looks like you missed Hanukkah !" + f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}.") + + await ctx.send(embed=embed) + + def hanukkah_dates_split(self, hanukkah_dates: List[str]) -> None: + """We are splitting the dates for hanukkah into days, months and years.""" + for date in hanukkah_dates: + self.hanukkah_days.append(date[8:10]) + self.hanukkah_months.append(date[5:7]) + self.hanukkah_years.append(date[0:4]) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(HanukkahEmbed(bot)) + log.info("Hanukkah embed cog loaded") diff --git a/bot/exts/easter/__init__.py b/bot/exts/easter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py new file mode 100644 index 00000000..4869f510 --- /dev/null +++ b/bot/exts/easter/april_fools_vids.py @@ -0,0 +1,39 @@ +import logging +import random +from json import load +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class AprilFoolVideos(commands.Cog): + """A cog for April Fools' that gets a random April Fools' video from Youtube.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.yt_vids = self.load_json() + self.youtubers = ['google'] # will add more in future + + @staticmethod + def load_json() -> dict: + """A function to load JSON data.""" + p = Path('bot/resources/easter/april_fools_vids.json') + with p.open() as json_file: + all_vids = load(json_file) + return all_vids + + @commands.command(name='fool') + async def april_fools(self, ctx: commands.Context) -> None: + """Get a random April Fools' video from Youtube.""" + random_youtuber = random.choice(self.youtubers) + category = self.yt_vids[random_youtuber] + random_vid = random.choice(category) + await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}") + + +def setup(bot: commands.Bot) -> None: + """April Fools' Cog load.""" + bot.add_cog(AprilFoolVideos(bot)) + log.info('April Fools videos cog loaded!') diff --git a/bot/exts/easter/avatar_easterifier.py b/bot/exts/easter/avatar_easterifier.py new file mode 100644 index 00000000..e21e35fc --- /dev/null +++ b/bot/exts/easter/avatar_easterifier.py @@ -0,0 +1,129 @@ +import asyncio +import logging +from io import BytesIO +from pathlib import Path +from typing import Tuple, Union + +import discord +from PIL import Image +from PIL.ImageOps import posterize +from discord.ext import commands + +log = logging.getLogger(__name__) + +COLOURS = [ + (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), + (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), + (135, 206, 235), (0, 204, 204), (64, 224, 208) +] # Pastel colours - Easter-like + + +class AvatarEasterifier(commands.Cog): + """Put an Easter spin on your avatar or image!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @staticmethod + def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]: + """ + Finds the closest easter colour to a given pixel. + + Returns a merge between the original colour and the closest colour + """ + r1, g1, b1 = x + + def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]: + """Finds the difference between a pastel colour and the original pixel colour.""" + r2, g2, b2 = point + return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2) + + closest_colours = sorted(COLOURS, key=lambda point: distance(point)) + r2, g2, b2 = closest_colours[0] + r = (r1 + r2) // 2 + g = (g1 + g2) // 2 + b = (b1 + b2) // 2 + + return (r, g, b) + + @commands.command(pass_context=True, aliases=["easterify"]) + async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None: + """ + This "Easterifies" the user's avatar. + + Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. + If colours are not given, a nice little chocolate bunny will sit in the corner. + Colours are split by spaces, unless you wrap the colour name in double quotes. + Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. + """ + async def send(*args, **kwargs) -> str: + """ + This replaces the original ctx.send. + + When invoking the egg decorating command, the egg itself doesn't print to to the channel. + Returns the message content so that if any errors occur, the error message can be output. + """ + if args: + return args[0] + + async with ctx.typing(): + + # Grabs image of avatar + image_bytes = await ctx.author.avatar_url_as(size=256).read() + + old = Image.open(BytesIO(image_bytes)) + old = old.convert("RGBA") + + # Grabs alpha channel since posterize can't be used with an RGBA image. + alpha = old.getchannel("A").getdata() + old = old.convert("RGB") + old = posterize(old, 6) + + data = old.getdata() + setted_data = set(data) + new_d = {} + + for x in setted_data: + new_d[x] = self.closest(x) + await asyncio.sleep(0) # Ensures discord doesn't break in the background. + new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] + + im = Image.new("RGBA", old.size) + im.putdata(new_data) + + if colours: + send_message = ctx.send + ctx.send = send # Assigns ctx.send to a fake send + egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) + if isinstance(egg, str): # When an error message occurs in eggdecorate. + return await send_message(egg) + + ratio = 64 / egg.height + egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) + egg = egg.convert("RGBA") + im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre. + ctx.send = send_message # Reassigns ctx.send + else: + bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) + im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre. + + bufferedio = BytesIO() + im.save(bufferedio, format="PNG") + + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed + embed = discord.Embed( + name="Your Lovely Easterified Avatar", + description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" + ) + embed.set_image(url="attachment://easterified_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Avatar Easterifier Cog load.""" + bot.add_cog(AvatarEasterifier(bot)) + log.info("AvatarEasterifier cog loaded") diff --git a/bot/exts/easter/bunny_name_generator.py b/bot/exts/easter/bunny_name_generator.py new file mode 100644 index 00000000..97c467e1 --- /dev/null +++ b/bot/exts/easter/bunny_name_generator.py @@ -0,0 +1,93 @@ +import json +import logging +import random +import re +from pathlib import Path +from typing import List, Union + +from discord.ext import commands + +log = logging.getLogger(__name__) + +with Path("bot/resources/easter/bunny_names.json").open("r", encoding="utf8") as f: + BUNNY_NAMES = json.load(f) + + +class BunnyNameGenerator(commands.Cog): + """Generate a random bunny name, or bunnify your Discord username!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + def find_separators(self, displayname: str) -> Union[List[str], None]: + """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" + new_name = re.split(r'[_.\s]', displayname) + if displayname not in new_name: + return new_name + + def find_vowels(self, displayname: str) -> str: + """ + Finds vowels in the user's display name. + + If the Discord name contains a vowel and the letter y, it will match one or more of these patterns. + + Only the most recently matched pattern will apply the changes. + """ + expressions = [ + (r'a.+y', 'patchy'), + (r'e.+y', 'ears'), + (r'i.+y', 'ditsy'), + (r'o.+y', 'oofy'), + (r'u.+y', 'uffy'), + ] + + for exp, vowel_sub in expressions: + new_name = re.sub(exp, vowel_sub, displayname) + if new_name != displayname: + return new_name + + def append_name(self, displayname: str) -> str: + """Adds a suffix to the end of the Discord name.""" + extensions = ['foot', 'ear', 'nose', 'tail'] + suffix = random.choice(extensions) + appended_name = displayname + suffix + + return appended_name + + @commands.command() + async def bunnyname(self, ctx: commands.Context) -> None: + """Picks a random bunny name from a JSON file.""" + await ctx.send(random.choice(BUNNY_NAMES["names"])) + + @commands.command() + async def bunnifyme(self, ctx: commands.Context) -> None: + """Gets your Discord username and bunnifies it.""" + username = ctx.message.author.display_name + + # If name contains spaces or other separators, get the individual words to randomly bunnify + spaces_in_name = self.find_separators(username) + + # If name contains vowels, see if it matches any of the patterns in this function + # If there are matches, the bunnified name is returned. + vowels_in_name = self.find_vowels(username) + + # Default if the checks above return None + unmatched_name = self.append_name(username) + + if spaces_in_name is not None: + replacements = ['Cotton', 'Fluff', 'Floof' 'Bounce', 'Snuffle', 'Nibble', 'Cuddle', 'Velvetpaw', 'Carrot'] + word_to_replace = random.choice(spaces_in_name) + substitute = random.choice(replacements) + bunnified_name = username.replace(word_to_replace, substitute) + elif vowels_in_name is not None: + bunnified_name = vowels_in_name + elif unmatched_name: + bunnified_name = unmatched_name + + await ctx.send(bunnified_name) + + +def setup(bot: commands.Bot) -> None: + """Bunny Name Generator Cog load.""" + bot.add_cog(BunnyNameGenerator(bot)) + log.info("BunnyNameGenerator cog loaded.") diff --git a/bot/exts/easter/conversationstarters.py b/bot/exts/easter/conversationstarters.py new file mode 100644 index 00000000..3f38ae82 --- /dev/null +++ b/bot/exts/easter/conversationstarters.py @@ -0,0 +1,29 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/easter/starter.json"), "r", encoding="utf8") as f: + starters = json.load(f) + + +class ConvoStarters(commands.Cog): + """Easter conversation topics.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command() + async def topic(self, ctx: commands.Context) -> None: + """Responds with a random topic to start a conversation.""" + await ctx.send(random.choice(starters['starters'])) + + +def setup(bot: commands.Bot) -> None: + """Conversation starters Cog load.""" + bot.add_cog(ConvoStarters(bot)) + log.info("ConvoStarters cog loaded") diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py new file mode 100644 index 00000000..f5b1aac7 --- /dev/null +++ b/bot/exts/easter/easter_riddle.py @@ -0,0 +1,101 @@ +import asyncio +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with Path("bot/resources/easter/easter_riddle.json").open("r", encoding="utf8") as f: + RIDDLE_QUESTIONS = load(f) + +TIMELIMIT = 10 + + +class EasterRiddle(commands.Cog): + """This cog contains the command for the Easter quiz!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.winners = [] + self.correct = "" + self.current_channel = None + + @commands.command(aliases=["riddlemethis", "riddleme"]) + async def riddle(self, ctx: commands.Context) -> None: + """ + Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer. + + The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file. + """ + if self.current_channel: + return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") + + self.current_channel = ctx.message.channel + + random_question = random.choice(RIDDLE_QUESTIONS) + question = random_question["question"] + hints = random_question["riddles"] + self.correct = random_question["correct_answer"] + + description = f"You have {TIMELIMIT} seconds before the first hint." + + riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink) + + await ctx.send(embed=riddle_embed) + await asyncio.sleep(TIMELIMIT) + + hint_embed = discord.Embed( + title=f"Here's a hint: {hints[0]}!", + colour=Colours.pink + ) + + await ctx.send(embed=hint_embed) + await asyncio.sleep(TIMELIMIT) + + hint_embed = discord.Embed( + title=f"Here's a hint: {hints[1]}!", + colour=Colours.pink + ) + + await ctx.send(embed=hint_embed) + await asyncio.sleep(TIMELIMIT) + + if self.winners: + win_list = " ".join(self.winners) + content = f"Well done {win_list} for getting it right!" + else: + content = "Nobody got it right..." + + answer_embed = discord.Embed( + title=f"The answer is: {self.correct}!", + colour=Colours.pink + ) + + await ctx.send(content, embed=answer_embed) + + self.winners = [] + self.current_channel = None + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """If a non-bot user enters a correct answer, their username gets added to self.winners.""" + if self.current_channel != message.channel: + return + + if self.bot.user == message.author: + return + + if message.content.lower() == self.correct.lower(): + self.winners.append(message.author.mention) + + +def setup(bot: commands.Bot) -> None: + """Easter Riddle Cog load.""" + bot.add_cog(EasterRiddle(bot)) + log.info("Easter Riddle bot loaded") diff --git a/bot/exts/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py new file mode 100644 index 00000000..23df95f1 --- /dev/null +++ b/bot/exts/easter/egg_decorating.py @@ -0,0 +1,119 @@ +import json +import logging +import random +from contextlib import suppress +from io import BytesIO +from pathlib import Path +from typing import Union + +import discord +from PIL import Image +from discord.ext import commands + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/evergreen/html_colours.json")) as f: + HTML_COLOURS = json.load(f) + +with open(Path("bot/resources/evergreen/xkcd_colours.json")) as f: + XKCD_COLOURS = json.load(f) + +COLOURS = [ + (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255), + (0, 255, 255, 255), (0, 0, 255, 255), (255, 0, 255, 255), (128, 0, 128, 255) +] # Colours to be replaced - Red, Orange, Yellow, Green, Light Blue, Dark Blue, Pink, Purple + +IRREPLACEABLE = [ + (0, 0, 0, 0), (0, 0, 0, 255) +] # Colours that are meant to stay the same - Transparent and Black + + +class EggDecorating(commands.Cog): + """Decorate some easter eggs!""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @staticmethod + def replace_invalid(colour: str) -> Union[int, None]: + """Attempts to match with HTML or XKCD colour names, returning the int value.""" + with suppress(KeyError): + return int(HTML_COLOURS[colour], 16) + with suppress(KeyError): + return int(XKCD_COLOURS[colour], 16) + return None + + @commands.command(aliases=["decorateegg"]) + async def eggdecorate( + self, ctx: commands.Context, *colours: Union[discord.Colour, str] + ) -> Union[Image.Image, discord.Message]: + """ + Picks a random egg design and decorates it using the given colours. + + Colours are split by spaces, unless you wrap the colour name in double quotes. + Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. + """ + if len(colours) < 2: + return await ctx.send("You must include at least 2 colours!") + + invalid = [] + colours = list(colours) + for idx, colour in enumerate(colours): + if isinstance(colour, discord.Colour): + continue + value = self.replace_invalid(colour) + if value: + colours[idx] = discord.Colour(value) + else: + invalid.append(colour) + + if len(invalid) > 1: + return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") + elif len(invalid) == 1: + return await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") + + async with ctx.typing(): + # Expand list to 8 colours + colours_n = len(colours) + if colours_n < 8: + q, r = divmod(8, colours_n) + colours = colours * q + colours[:r] + num = random.randint(1, 6) + im = Image.open(Path(f"bot/resources/easter/easter_eggs/design{num}.png")) + data = list(im.getdata()) + + replaceable = {x for x in data if x not in IRREPLACEABLE} + replaceable = sorted(replaceable, key=COLOURS.index) + + replacing_colours = {colour: colours[i] for i, colour in enumerate(replaceable)} + new_data = [] + for x in data: + if x in replacing_colours: + new_data.append((*replacing_colours[x].to_rgb(), 255)) + # Also ensures that the alpha channel has a value + else: + new_data.append(x) + new_im = Image.new(im.mode, im.size) + new_im.putdata(new_data) + + bufferedio = BytesIO() + new_im.save(bufferedio, format="PNG") + + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="egg.png") # Creates file to be used in embed + embed = discord.Embed( + title="Your Colourful Easter Egg", + description="Here is your pretty little egg. Hope you like it!" + ) + embed.set_image(url="attachment://egg.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + return new_im + + +def setup(bot: commands.bot) -> None: + """Egg decorating Cog load.""" + bot.add_cog(EggDecorating(bot)) + log.info("EggDecorating cog loaded.") diff --git a/bot/exts/easter/egg_facts.py b/bot/exts/easter/egg_facts.py new file mode 100644 index 00000000..99a80b28 --- /dev/null +++ b/bot/exts/easter/egg_facts.py @@ -0,0 +1,61 @@ +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Channels, Colours, Month +from bot.utils.decorators import seasonal_task + +log = logging.getLogger(__name__) + + +class EasterFacts(commands.Cog): + """ + A cog contains a command that will return an easter egg fact when called. + + It also contains a background task which sends an easter egg fact in the event channel everyday. + """ + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.facts = self.load_json() + + self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily()) + + @staticmethod + def load_json() -> dict: + """Load a list of easter egg facts from the resource JSON file.""" + p = Path("bot/resources/easter/easter_egg_facts.json") + with p.open(encoding="utf8") as f: + return load(f) + + @seasonal_task(Month.april) + async def send_egg_fact_daily(self) -> None: + """A background task that sends an easter egg fact in the event channel everyday.""" + await self.bot.wait_until_ready() + + channel = self.bot.get_channel(Channels.seasonalbot_commands) + await channel.send(embed=self.make_embed()) + + @commands.command(name='eggfact', aliases=['fact']) + async def easter_facts(self, ctx: commands.Context) -> None: + """Get easter egg facts.""" + embed = self.make_embed() + await ctx.send(embed=embed) + + def make_embed(self) -> discord.Embed: + """Makes a nice embed for the message to be sent.""" + return discord.Embed( + colour=Colours.soft_red, + title="Easter Egg Fact", + description=random.choice(self.facts) + ) + + +def setup(bot: commands.Bot) -> None: + """Easter Egg facts cog load.""" + bot.add_cog(EasterFacts(bot)) + log.info("EasterFacts cog loaded") diff --git a/bot/exts/easter/egghead_quiz.py b/bot/exts/easter/egghead_quiz.py new file mode 100644 index 00000000..bd179fe2 --- /dev/null +++ b/bot/exts/easter/egghead_quiz.py @@ -0,0 +1,120 @@ +import asyncio +import logging +import random +from json import load +from pathlib import Path +from typing import Union + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/easter/egghead_questions.json"), "r", encoding="utf8") as f: + EGGHEAD_QUESTIONS = load(f) + + +EMOJIS = [ + '\U0001f1e6', '\U0001f1e7', '\U0001f1e8', '\U0001f1e9', '\U0001f1ea', + '\U0001f1eb', '\U0001f1ec', '\U0001f1ed', '\U0001f1ee', '\U0001f1ef', + '\U0001f1f0', '\U0001f1f1', '\U0001f1f2', '\U0001f1f3', '\U0001f1f4', + '\U0001f1f5', '\U0001f1f6', '\U0001f1f7', '\U0001f1f8', '\U0001f1f9', + '\U0001f1fa', '\U0001f1fb', '\U0001f1fc', '\U0001f1fd', '\U0001f1fe', + '\U0001f1ff' +] # Regional Indicators A-Z (used for voting) + +TIMELIMIT = 30 + + +class EggheadQuiz(commands.Cog): + """This cog contains the command for the Easter quiz!""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.quiz_messages = {} + + @commands.command(aliases=["eggheadquiz", "easterquiz"]) + async def eggquiz(self, ctx: commands.Context) -> None: + """ + Gives a random quiz question, waits 30 seconds and then outputs the answer. + + Also informs of the percentages and votes of each option + """ + random_question = random.choice(EGGHEAD_QUESTIONS) + question, answers = random_question["question"], random_question["answers"] + answers = [(EMOJIS[i], a) for i, a in enumerate(answers)] + correct = EMOJIS[random_question["correct_answer"]] + + valid_emojis = [emoji for emoji, _ in answers] + + description = f"You have {TIMELIMIT} seconds to vote.\n\n" + description += "\n".join([f"{emoji} -> **{answer}**" for emoji, answer in answers]) + + q_embed = discord.Embed(title=question, description=description, colour=Colours.pink) + + msg = await ctx.send(embed=q_embed) + for emoji in valid_emojis: + await msg.add_reaction(emoji) + + self.quiz_messages[msg.id] = valid_emojis + + await asyncio.sleep(TIMELIMIT) + + del self.quiz_messages[msg.id] + + msg = await ctx.channel.fetch_message(msg.id) # Refreshes message + + total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions + + if total_no == 0: + return await msg.delete() # To avoid ZeroDivisionError if nobody reacts + + results = ["**VOTES:**"] + for emoji, _ in answers: + num = [len(await r.users().flatten()) for r in msg.reactions if str(r.emoji) == emoji][0] - 1 + percent = round(100 * num / total_no) + s = "" if num == 1 else "s" + string = f"{emoji} - {num} vote{s} ({percent}%)" + results.append(string) + + mentions = " ".join([ + u.mention for u in [ + await r.users().flatten() for r in msg.reactions if str(r.emoji) == correct + ][0] if not u.bot + ]) + + content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..." + + a_embed = discord.Embed( + title=f"The correct answer was {correct}!", + description="\n".join(results), + colour=Colours.pink + ) + + await ctx.send(content, embed=a_embed) + + @staticmethod + async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool: + """Returns whether a given user has reacted more than once to a given message.""" + users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction] + return users.count(user.id) > 1 # Old reaction plus new reaction + + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None: + """Listener to listen specifically for reactions of quiz messages.""" + if user.bot: + return + if reaction.message.id not in self.quiz_messages: + return + if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]: + return await reaction.message.remove_reaction(reaction, user) + if await self.already_reacted(reaction.message, user): + return await reaction.message.remove_reaction(reaction, user) + + +def setup(bot: commands.Bot) -> None: + """Egghead Quiz Cog load.""" + bot.add_cog(EggheadQuiz(bot)) + log.info("EggheadQuiz bot loaded") diff --git a/bot/exts/easter/traditions.py b/bot/exts/easter/traditions.py new file mode 100644 index 00000000..9529823f --- /dev/null +++ b/bot/exts/easter/traditions.py @@ -0,0 +1,31 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/easter/traditions.json"), "r", encoding="utf8") as f: + traditions = json.load(f) + + +class Traditions(commands.Cog): + """A cog which allows users to get a random easter tradition or custom from a random country.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(aliases=('eastercustoms',)) + async def easter_tradition(self, ctx: commands.Context) -> None: + """Responds with a random tradition or custom.""" + random_country = random.choice(list(traditions)) + + await ctx.send(f"{random_country}:\n{traditions[random_country]}") + + +def setup(bot: commands.Bot) -> None: + """Traditions Cog load.""" + bot.add_cog(Traditions(bot)) + log.info("Traditions cog loaded") diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py new file mode 100644 index 00000000..60062fc1 --- /dev/null +++ b/bot/exts/evergreen/8bitify.py @@ -0,0 +1,54 @@ +from io import BytesIO + +import discord +from PIL import Image +from discord.ext import commands + + +class EightBitify(commands.Cog): + """Make your avatar 8bit!""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + @staticmethod + def pixelate(image: Image) -> Image: + """Takes an image and pixelates it.""" + return image.resize((32, 32)).resize((1024, 1024)) + + @staticmethod + def quantize(image: Image) -> Image: + """Reduces colour palette to 256 colours.""" + return image.quantize(colors=32) + + @commands.command(name="8bitify") + async def eightbit_command(self, ctx: commands.Context) -> None: + """Pixelates your avatar and changes the palette to an 8bit one.""" + async with ctx.typing(): + image_bytes = await ctx.author.avatar_url.read() + avatar = Image.open(BytesIO(image_bytes)) + avatar = avatar.convert("RGBA").resize((1024, 1024)) + + eightbit = self.pixelate(avatar) + eightbit = self.quantize(eightbit) + + bufferedio = BytesIO() + eightbit.save(bufferedio, format="PNG") + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="8bitavatar.png") + + embed = discord.Embed( + title="Your 8-bit avatar", + description='Here is your avatar. I think it looks all cool and "retro"' + ) + + embed.set_image(url="attachment://8bitavatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(EightBitify(bot)) diff --git a/bot/exts/evergreen/__init__.py b/bot/exts/evergreen/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py new file mode 100644 index 00000000..9b8aaa48 --- /dev/null +++ b/bot/exts/evergreen/battleship.py @@ -0,0 +1,444 @@ +import asyncio +import logging +import random +import re +import typing +from dataclasses import dataclass +from functools import partial + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + + +@dataclass +class Square: + """Each square on the battleship grid - if they contain a boat and if they've been aimed at.""" + + boat: typing.Optional[str] + aimed: bool + + +Grid = typing.List[typing.List[Square]] +EmojiSet = typing.Dict[typing.Tuple[bool, bool], str] + + +@dataclass +class Player: + """Each player in the game - their messages for the boards and their current grid.""" + + user: discord.Member + board: discord.Message + opponent_board: discord.Message + grid: Grid + + +# The name of the ship and its size +SHIPS = { + "Carrier": 5, + "Battleship": 4, + "Cruiser": 3, + "Submarine": 3, + "Destroyer": 2, +} + + +# For these two variables, the first boolean is whether the square is a ship (True) or not (False). +# The second boolean is whether the player has aimed for that square (True) or not (False) + +# This is for the player's own board which shows the location of their own ships. +SHIP_EMOJIS = { + (True, True): ":fire:", + (True, False): ":ship:", + (False, True): ":anger:", + (False, False): ":ocean:", +} + +# This is for the opposing player's board which only shows aimed locations. +HIDDEN_EMOJIS = { + (True, True): ":red_circle:", + (True, False): ":black_circle:", + (False, True): ":white_circle:", + (False, False): ":black_circle:", +} + +# For the top row of the board +LETTERS = ( + ":stop_button::regional_indicator_a::regional_indicator_b::regional_indicator_c::regional_indicator_d:" + ":regional_indicator_e::regional_indicator_f::regional_indicator_g::regional_indicator_h:" + ":regional_indicator_i::regional_indicator_j:" +) + +# For the first column of the board +NUMBERS = [ + ":one:", + ":two:", + ":three:", + ":four:", + ":five:", + ":six:", + ":seven:", + ":eight:", + ":nine:", + ":keycap_ten:", +] + +CROSS_EMOJI = "\u274e" +HAND_RAISED_EMOJI = "\U0001f64b" + + +class Game: + """A Battleship Game.""" + + def __init__( + self, + bot: commands.Bot, + channel: discord.TextChannel, + player1: discord.Member, + player2: discord.Member + ) -> None: + + self.bot = bot + self.public_channel = channel + + self.p1 = Player(player1, None, None, self.generate_grid()) + self.p2 = Player(player2, None, None, self.generate_grid()) + + self.gameover: bool = False + + self.turn: typing.Optional[discord.Member] = None + self.next: typing.Optional[discord.Member] = None + + self.match: typing.Optional[typing.Match] = None + self.surrender: bool = False + + self.setup_grids() + + @staticmethod + def generate_grid() -> Grid: + """Generates a grid by instantiating the Squares.""" + return [[Square(None, False) for _ in range(10)] for _ in range(10)] + + @staticmethod + def format_grid(player: Player, emojiset: EmojiSet) -> str: + """ + Gets and formats the grid as a list into a string to be output to the DM. + + Also adds the Letter and Number indexes. + """ + grid = [ + [emojiset[bool(square.boat), square.aimed] for square in row] + for row in player.grid + ] + + rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid)] + return "\n".join([LETTERS] + rows) + + @staticmethod + def get_square(grid: Grid, square: str) -> Square: + """Grabs a square from a grid with an inputted key.""" + index = ord(square[0]) - ord("A") + number = int(square[1:]) + + return grid[number-1][index] # -1 since lists are indexed from 0 + + async def game_over( + self, + *, + winner: discord.Member, + loser: discord.Member + ) -> None: + """Removes games from list of current games and announces to public chat.""" + await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") + + for player in (self.p1, self.p2): + grid = self.format_grid(player, SHIP_EMOJIS) + await self.public_channel.send(f"{player.user}'s Board:\n{grid}") + + @staticmethod + def check_sink(grid: Grid, boat: str) -> bool: + """Checks if all squares containing a given boat have sunk.""" + return all(square.aimed for row in grid for square in row if square.boat == boat) + + @staticmethod + def check_gameover(grid: Grid) -> bool: + """Checks if all boats have been sunk.""" + return all(square.aimed for row in grid for square in row if square.boat) + + def setup_grids(self) -> None: + """Places the boats on the grids to initialise the game.""" + for player in (self.p1, self.p2): + for name, size in SHIPS.items(): + while True: # Repeats if about to overwrite another boat + ship_collision = False + coords = [] + + coord1 = random.randint(0, 9) + coord2 = random.randint(0, 10 - size) + + if random.choice((True, False)): # Vertical or Horizontal + x, y = coord1, coord2 + xincr, yincr = 0, 1 + else: + x, y = coord2, coord1 + xincr, yincr = 1, 0 + + for i in range(size): + new_x = x + (xincr * i) + new_y = y + (yincr * i) + if player.grid[new_x][new_y].boat: # Check if there's already a boat + ship_collision = True + break + coords.append((new_x, new_y)) + if not ship_collision: # If not overwriting any other boat spaces, break loop + break + + for x, y in coords: + player.grid[x][y].boat = name + + async def print_grids(self) -> None: + """Prints grids to the DM channels.""" + # Convert squares into Emoji + + boards = [ + self.format_grid(player, emojiset) + for emojiset in (HIDDEN_EMOJIS, SHIP_EMOJIS) + for player in (self.p1, self.p2) + ] + + locations = ( + (self.p2, "opponent_board"), (self.p1, "opponent_board"), + (self.p1, "board"), (self.p2, "board") + ) + + for board, location in zip(boards, locations): + player, attr = location + if getattr(player, attr): + await getattr(player, attr).edit(content=board) + else: + setattr(player, attr, await player.user.send(board)) + + def predicate(self, message: discord.Message) -> bool: + """Predicate checking the message typed for each turn.""" + if message.author == self.turn.user and message.channel == self.turn.user.dm_channel: + if message.content.lower() == "surrender": + self.surrender = True + return True + self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip()) + if not self.match: + self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI)) + return bool(self.match) + + async def take_turn(self) -> typing.Optional[Square]: + """Lets the player who's turn it is choose a square.""" + square = None + turn_message = await self.turn.user.send( + "It's your turn! Type the square you want to fire at. Format it like this: A1\n" + "Type `surrender` to give up" + ) + await self.next.user.send("Their turn", delete_after=3.0) + while True: + try: + await self.bot.wait_for("message", check=self.predicate, timeout=60.0) + except asyncio.TimeoutError: + await self.turn.user.send("You took too long. Game over!") + await self.next.user.send(f"{self.turn.user} took too long. Game over!") + await self.public_channel.send( + f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!" + ) + self.gameover = True + break + else: + if self.surrender: + await self.next.user.send(f"{self.turn.user} surrendered. Game over!") + await self.public_channel.send( + f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!" + ) + self.gameover = True + break + square = self.get_square(self.next.grid, self.match.string) + if square.aimed: + await self.turn.user.send("You've already aimed at this square!", delete_after=3.0) + else: + break + await turn_message.delete() + return square + + async def hit(self, square: Square, alert_messages: typing.List[discord.Message]) -> None: + """Occurs when a player successfully aims for a ship.""" + await self.turn.user.send("Hit!", delete_after=3.0) + alert_messages.append(await self.next.user.send("Hit!")) + if self.check_sink(self.next.grid, square.boat): + await self.turn.user.send(f"You've sunk their {square.boat} ship!", delete_after=3.0) + alert_messages.append(await self.next.user.send(f"Oh no! Your {square.boat} ship sunk!")) + if self.check_gameover(self.next.grid): + await self.turn.user.send("You win!") + await self.next.user.send("You lose!") + self.gameover = True + await self.game_over(winner=self.turn.user, loser=self.next.user) + + async def start_game(self) -> None: + """Begins the game.""" + await self.p1.user.send(f"You're playing battleship with {self.p2.user}.") + await self.p2.user.send(f"You're playing battleship with {self.p1.user}.") + + alert_messages = [] + + self.turn = self.p1 + self.next = self.p2 + + while True: + await self.print_grids() + + if self.gameover: + return + + square = await self.take_turn() + if not square: + return + square.aimed = True + + for message in alert_messages: + await message.delete() + + alert_messages = [] + alert_messages.append(await self.next.user.send(f"{self.turn.user} aimed at {self.match.string}!")) + + if square.boat: + await self.hit(square, alert_messages) + if self.gameover: + return + else: + await self.turn.user.send("Miss!", delete_after=3.0) + alert_messages.append(await self.next.user.send("Miss!")) + + self.turn, self.next = self.next, self.turn + + +class Battleship(commands.Cog): + """Play the classic game Battleship!""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.games: typing.List[Game] = [] + self.waiting: typing.List[discord.Member] = [] + + def predicate( + self, + ctx: commands.Context, + announcement: discord.Message, + reaction: discord.Reaction, + user: discord.Member + ) -> bool: + """Predicate checking the criteria for the announcement message.""" + if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2 + return True # Is dealt with later on + if ( + user.id not in (ctx.me.id, ctx.author.id) + and str(reaction.emoji) == HAND_RAISED_EMOJI + and reaction.message.id == announcement.id + ): + if self.already_playing(user): + self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) + self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) + return False + + if user in self.waiting: + self.bot.loop.create_task(ctx.send( + f"{user.mention} Please cancel your game first before joining another one." + )) + self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) + return False + + return True + + if ( + user.id == ctx.author.id + and str(reaction.emoji) == CROSS_EMOJI + and reaction.message.id == announcement.id + ): + return True + return False + + def already_playing(self, player: discord.Member) -> bool: + """Check if someone is already in a game.""" + return any(player in (game.p1.user, game.p2.user) for game in self.games) + + @commands.group(invoke_without_command=True) + @commands.guild_only() + async def battleship(self, ctx: commands.Context) -> None: + """ + Play a game of Battleship with someone else! + + This will set up a message waiting for someone else to react and play along. + The game takes place entirely in DMs. + Make sure you have your DMs open so that the bot can message you. + """ + if self.already_playing(ctx.author): + return await ctx.send("You're already playing a game!") + + if ctx.author in self.waiting: + return await ctx.send("You've already sent out a request for a player 2") + + announcement = await ctx.send( + "**Battleship**: A new game is about to start!\n" + f"Press {HAND_RAISED_EMOJI} to play against {ctx.author.mention}!\n" + f"(Cancel the game with {CROSS_EMOJI}.)" + ) + self.waiting.append(ctx.author) + await announcement.add_reaction(HAND_RAISED_EMOJI) + await announcement.add_reaction(CROSS_EMOJI) + + try: + reaction, user = await self.bot.wait_for( + "reaction_add", + check=partial(self.predicate, ctx, announcement), + timeout=60.0 + ) + except asyncio.TimeoutError: + self.waiting.remove(ctx.author) + await announcement.delete() + return await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") + + if str(reaction.emoji) == CROSS_EMOJI: + self.waiting.remove(ctx.author) + await announcement.delete() + return await ctx.send(f"{ctx.author.mention} Game cancelled.") + + await announcement.delete() + self.waiting.remove(ctx.author) + if self.already_playing(ctx.author): + return + try: + game = Game(self.bot, ctx.channel, ctx.author, user) + self.games.append(game) + await game.start_game() + self.games.remove(game) + except discord.Forbidden: + await ctx.send( + f"{ctx.author.mention} {user.mention} " + "Game failed. This is likely due to you not having your DMs open. Check and try again." + ) + self.games.remove(game) + except Exception: + # End the game in the event of an unforseen error so the players aren't stuck in a game + await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed") + self.games.remove(game) + raise + + @battleship.command(name="ships", aliases=["boats"]) + async def battleship_ships(self, ctx: commands.Context) -> None: + """Lists the ships that are found on the battleship grid.""" + embed = discord.Embed(colour=Colours.blue) + embed.add_field(name="Name", value="\n".join(SHIPS)) + embed.add_field(name="Size", value="\n".join(str(size) for size in SHIPS.values())) + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(Battleship(bot)) + log.info("Battleship cog loaded") diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py new file mode 100644 index 00000000..e703e07b --- /dev/null +++ b/bot/exts/evergreen/bookmark.py @@ -0,0 +1,65 @@ +import logging +import random + +import discord +from discord.ext import commands + +from bot.constants import Colours, ERROR_REPLIES, Emojis, Icons + +log = logging.getLogger(__name__) + + +class Bookmark(commands.Cog): + """Creates personal bookmarks by relaying a message link to the user's DMs.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(name="bookmark", aliases=("bm", "pin")) + async def bookmark( + self, + ctx: commands.Context, + target_message: discord.Message, + *, + title: str = "Bookmark" + ) -> None: + """Send the author a link to `target_message` via DMs.""" + # Prevent users from bookmarking a message in a channel they don't have access to + permissions = ctx.author.permissions_in(target_message.channel) + if not permissions.read_messages: + log.info(f"{ctx.author} tried to bookmark a message in #{target_message.channel} but has no permissions") + embed = discord.Embed( + title=random.choice(ERROR_REPLIES), + color=Colours.soft_red, + description="You don't have permission to view this channel." + ) + await ctx.send(embed=embed) + return + + embed = discord.Embed( + title=title, + colour=Colours.soft_green, + description=target_message.content + ) + embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})") + embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url) + embed.set_thumbnail(url=Icons.bookmark) + + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + error_embed = discord.Embed( + title=random.choice(ERROR_REPLIES), + description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark", + colour=Colours.soft_red + ) + await ctx.send(embed=error_embed) + else: + log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'") + await ctx.message.add_reaction(Emojis.envelope) + + +def setup(bot: commands.Bot) -> None: + """Load the Bookmark cog.""" + bot.add_cog(Bookmark(bot)) + log.info("Bookmark cog loaded") diff --git a/bot/exts/evergreen/branding.py b/bot/exts/evergreen/branding.py new file mode 100644 index 00000000..6cbe3bd8 --- /dev/null +++ b/bot/exts/evergreen/branding.py @@ -0,0 +1,504 @@ +import asyncio +import itertools +import logging +import random +import typing as t +from datetime import datetime, time, timedelta + +import arrow +import discord +from discord.embeds import EmptyEmbed +from discord.ext import commands + +from bot.bot import SeasonalBot +from bot.constants import Branding, Colours, Emojis, MODERATION_ROLES, Tokens +from bot.exts import SeasonBase, get_current_season, get_season +from bot.utils.decorators import with_role +from bot.utils.exceptions import BrandingError + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + +STATUS_OK = 200 # HTTP status code + +FILE_BANNER = "banner.png" +FILE_AVATAR = "avatar.png" +SERVER_ICONS = "server_icons" + +BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" + +PARAMS = {"ref": "seasonal-structure"} # Target branch +HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3 + +# A Github token is not necessary for the cog to operate, +# unauthorized requests are however limited to 60 per hour +if Tokens.github: + HEADERS["Authorization"] = f"token {Tokens.github}" + + +class GithubFile(t.NamedTuple): + """ + Represents a remote file on Github. + + The sha hash is kept so that we can determine that a file has changed, + despite its filename remaining unchanged. + """ + + download_url: str + path: str + sha: str + + +async def pretty_files(files: t.Iterable[GithubFile]) -> str: + """Provide a human-friendly representation of `files`.""" + return "\n".join(file.path for file in files) + + +async def time_until_midnight() -> timedelta: + """ + Determine amount of time until the next-up UTC midnight. + + The exact `midnight` moment is actually delayed to 5 seconds after, in order + to avoid potential problems due to imprecise sleep. + """ + now = datetime.utcnow() + tomorrow = now + timedelta(days=1) + midnight = datetime.combine(tomorrow, time(second=5)) + + return midnight - now + + +class BrandingManager(commands.Cog): + """ + Manages the guild's branding. + + The purpose of this cog is to help automate the synchronization of the branding + repository with the guild. It is capable of discovering assets in the repository + via Github's API, resolving download urls for them, and delegating + to the `bot` instance to upload them to the guild. + + The cog is designed to be entirely autonomous. The `daemon` background task awakens once + a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single + season. If the `Branding.autostart` constant is True, the `daemon` will launch on start-up, + otherwise it can be controlled via the `daemon` cmd group. + + All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can + also be invoked manually, via the following API: + + branding set + - Set the cog's internal state to represent `season_name`, if it exists. + - If no `season_name` is given, set chronologically current season. + - This will not automatically apply the season's branding to the guild, + the cog's state can be detached from the guild. + - Seasons can therefore be 'previewed' using this command. + + branding info + - View detailed information about resolved assets for current season. + + branding refresh + - Refresh internal state, i.e. synchronize with branding repository. + + branding apply + - Apply the current internal state to the guild, i.e. upload the assets. + + branding cycle + - If there are multiple available icons for current season, randomly pick + and apply the next one. + + The daemon calls these methods autonomously as appropriate. The use of this cog + is locked to moderation roles. As it performs media asset uploads, it is prone to + rate-limits - the `apply` command should be used with caution. The `set` command can, + however, be used freely to 'preview' seasonal branding and check whether paths have been + resolved as appropriate. + + While the bot is in debug mode, it will 'mock' asset uploads by logging the passed + download urls and pretending that the upload was successful. Make use of this + to test this cog's behaviour. + """ + + current_season: t.Type[SeasonBase] + + banner: t.Optional[GithubFile] + avatar: t.Optional[GithubFile] + + available_icons: t.List[GithubFile] + remaining_icons: t.List[GithubFile] + + should_cycle: t.Iterator + + daemon: t.Optional[asyncio.Task] + + def __init__(self, bot: SeasonalBot) -> None: + """ + Assign safe default values on init. + + At this point, we don't have information about currently available branding. + Most of these attributes will be overwritten once the daemon connects, or once + the `refresh` command is used. + """ + self.bot = bot + self.current_season = get_current_season() + + self.banner = None + self.avatar = None + + self.should_cycle = itertools.cycle([False]) + + self.available_icons = [] + self.remaining_icons = [] + + if Branding.autostart: + self.daemon = self.bot.loop.create_task(self._daemon_func()) + else: + self.daemon = None + + @property + def _daemon_running(self) -> bool: + """True if the daemon is currently active, False otherwise.""" + return self.daemon is not None and not self.daemon.done() + + async def _daemon_func(self) -> None: + """ + Manage all automated behaviour of the BrandingManager cog. + + Once a day, the daemon will perform the following tasks: + - Update `current_season` + - Poll Github API to see if the available branding for `current_season` has changed + - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname) + - Check whether it's time to cycle guild icons + + The internal loop runs once when activated, then periodically at the time + given by `time_until_midnight`. + + All method calls in the internal loop are considered safe, i.e. no errors propagate + to the daemon's loop. The daemon itself does not perform any error handling on its own. + """ + await self.bot.wait_until_ready() + + while True: + self.current_season = get_current_season() + branding_changed = await self.refresh() + + if branding_changed: + await self.apply() + + elif next(self.should_cycle): + await self.cycle() + + until_midnight = await time_until_midnight() + await asyncio.sleep(until_midnight.total_seconds()) + + async def _info_embed(self) -> discord.Embed: + """Make an informative embed representing current season.""" + info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour) + + # If we're in a non-evergreen season, also show active months + if self.current_season is not SeasonBase: + active_months = ", ".join(m.name for m in self.current_season.months) + title = f"{self.current_season.season_name} ({active_months})" + else: + title = self.current_season.season_name + + # Use the author field to show the season's name and avatar if available + info_embed.set_author(name=title, icon_url=self.avatar.download_url if self.avatar else EmptyEmbed) + + banner = self.banner.path if self.banner is not None else "Unavailable" + info_embed.add_field(name="Banner", value=banner, inline=False) + + avatar = self.avatar.path if self.avatar is not None else "Unavailable" + info_embed.add_field(name="Avatar", value=avatar, inline=False) + + icons = await pretty_files(self.available_icons) or "Unavailable" + info_embed.add_field(name="Available icons", value=icons, inline=False) + + # Only display cycle frequency if we're actually cycling + if len(self.available_icons) > 1 and Branding.cycle_frequency: + info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}") + + return info_embed + + async def _reset_remaining_icons(self) -> None: + """Set `remaining_icons` to a shuffled copy of `available_icons`.""" + self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons)) + + async def _reset_should_cycle(self) -> None: + """ + Reset the `should_cycle` counter based on configured frequency. + + Counter will always yield False if either holds: + - Branding.cycle_frequency is falsey + - There are fewer than 2 available icons for current season + + Cycling can be easily turned off, and we prevent re-uploading the same icon repeatedly. + """ + if len(self.available_icons) > 1 and Branding.cycle_frequency: + wait_period = [False] * (Branding.cycle_frequency - 1) + counter = itertools.cycle(wait_period + [True]) + else: + counter = itertools.cycle([False]) + + self.should_cycle = counter + + async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GithubFile]: + """ + Poll `path` in branding repo for information about present files. + + If `include_dirs` is False (default), only returns files at `path`. + Otherwise, will return both files and directories. Never returns symlinks. + + Return dict mapping from filename to corresponding `GithubFile` instance. + This may return an empty dict if the response status is non-200, + or if the target directory is empty. + """ + url = f"{BRANDING_URL}/{path}" + async with self.bot.http_session.get(url, headers=HEADERS, params=PARAMS) as resp: + # Short-circuit if we get non-200 response + if resp.status != STATUS_OK: + log.error(f"Github API returned non-200 response: {resp}") + return {} + directory = await resp.json() # Directory at `path` + + allowed_types = {"file", "dir"} if include_dirs else {"file"} + return { + file["name"]: GithubFile(file["download_url"], file["path"], file["sha"]) + for file in directory + if file["type"] in allowed_types + } + + async def refresh(self) -> bool: + """ + Poll Github API to refresh currently available icons. + + If the current season is not the evergreen, and lacks at least one asset, + we also poll the evergreen seasonal dir as fallback for missing assets. + + Finally, if neither the seasonal nor fallback branding directories contain + an asset, it will simply be ignored. + + Return True if the branding has changed. This will be the case when we enter + a new season, or when something changes in the current seasons's directory + in the branding repository. + """ + old_branding = (self.banner, self.avatar, self.available_icons) + seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True) + + # Only make a call to the fallback directory if there is something to be gained + branding_incomplete = any( + asset not in seasonal_dir + for asset in (FILE_BANNER, FILE_AVATAR, SERVER_ICONS) + ) + if branding_incomplete and self.current_season is not SeasonBase: + fallback_dir = await self._get_files(SeasonBase.branding_path, include_dirs=True) + else: + fallback_dir = {} + + # Resolve assets in this directory, None is a safe value + self.banner = seasonal_dir.get(FILE_BANNER) or fallback_dir.get(FILE_BANNER) + self.avatar = seasonal_dir.get(FILE_AVATAR) or fallback_dir.get(FILE_AVATAR) + + # Now resolve server icons by making a call to the proper sub-directory + if SERVER_ICONS in seasonal_dir: + icons_dir = await self._get_files(f"{self.current_season.branding_path}/{SERVER_ICONS}") + self.available_icons = list(icons_dir.values()) + + elif SERVER_ICONS in fallback_dir: + icons_dir = await self._get_files(f"{SeasonBase.branding_path}/{SERVER_ICONS}") + self.available_icons = list(icons_dir.values()) + + else: + self.available_icons = [] # This should never be the case, but an empty list is a safe value + + # GithubFile instances carry a `sha` attr so this will pick up if a file changes + branding_changed = old_branding != (self.banner, self.avatar, self.available_icons) + + if branding_changed: + log.info(f"New branding detected (season: {self.current_season.season_name})") + await self._reset_remaining_icons() + await self._reset_should_cycle() + + return branding_changed + + async def cycle(self) -> bool: + """ + Apply the next-up server icon. + + Returns True if an icon is available and successfully gets applied, False otherwise. + """ + if not self.available_icons: + log.info("Cannot cycle: no icons for this season") + return False + + if not self.remaining_icons: + await self._reset_remaining_icons() + log.info(f"Set remaining icons: {await pretty_files(self.remaining_icons)}") + + next_up, *self.remaining_icons = self.remaining_icons + success = await self.bot.set_icon(next_up.download_url) + + return success + + async def apply(self) -> t.List[str]: + """ + Apply current branding to the guild and bot. + + This delegates to the bot instance to do all the work. We only provide download urls + for available assets. Assets unavailable in the branding repo will be ignored. + + Returns a list of names of all failed assets. An asset is considered failed + if it isn't found in the branding repo, or if something goes wrong while the + bot is trying to apply it. + + An empty list denotes that all assets have been applied successfully. + """ + report = {asset: False for asset in ("banner", "avatar", "nickname", "icon")} + + if self.banner is not None: + report["banner"] = await self.bot.set_banner(self.banner.download_url) + + if self.avatar is not None: + report["avatar"] = await self.bot.set_avatar(self.avatar.download_url) + + if self.current_season.bot_name: + report["nickname"] = await self.bot.set_nickname(self.current_season.bot_name) + + report["icon"] = await self.cycle() + + failed_assets = [asset for asset, succeeded in report.items() if not succeeded] + return failed_assets + + @with_role(*MODERATION_ROLES) + @commands.group(name="branding") + async def branding_cmds(self, ctx: commands.Context) -> None: + """Manual branding control.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @branding_cmds.command(name="info", aliases=["status"]) + async def branding_info(self, ctx: commands.Context) -> None: + """ + Show assets for current season. + + This can be used to confirm that assets have been resolved properly. + When `apply` is used, it attempts to upload exactly the assets listed here. + """ + await ctx.send(embed=await self._info_embed()) + + @branding_cmds.command(name="refresh") + async def branding_refresh(self, ctx: commands.Context) -> None: + """ + Refresh current season from branding repository. + + Polls Github API to refresh assets available for current season. + """ + async with ctx.typing(): + await self.refresh() + await self.branding_info(ctx) + + @branding_cmds.command(name="cycle") + async def branding_cycle(self, ctx: commands.Context) -> None: + """ + Apply the next-up guild icon, if multiple are available. + + The order is random. + """ + async with ctx.typing(): + success = await self.cycle() + if not success: + raise BrandingError("Failed to cycle icon") + + response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @branding_cmds.command(name="apply") + async def branding_apply(self, ctx: commands.Context) -> None: + """ + Apply current season's branding to the guild. + + Use `info` to check which assets will be applied. Shows which assets have + failed to be applied, if any. + """ + async with ctx.typing(): + failed_assets = await self.apply() + if failed_assets: + raise BrandingError(f"Failed to apply following assets: {', '.join(failed_assets)}") + + response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @branding_cmds.command(name="set") + async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None: + """ + Manually set season, or reset to current if none given. + + Season search is a case-less comparison against both seasonal class name, + and its `season_name` attr. + + This only pre-loads the cog's internal state to the chosen season, but does not + automatically apply the branding. As that is an expensive operation, the `apply` + command must be called explicitly after this command finishes. + + This means that this command can be used to 'preview' a season gathering info + about its available assets, without applying them to the guild. + + If the daemon is running, it will automatically reset the season to current when + it wakes up. The season set via this command can therefore remain 'detached' from + what it should be - the daemon will make sure that it's set back properly. + """ + if season_name is None: + new_season = get_current_season() + else: + new_season = get_season(season_name) + if new_season is None: + raise BrandingError("No such season exists") + + if self.current_season is new_season: + raise BrandingError(f"Season {self.current_season.season_name} already active") + + self.current_season = new_season + async with ctx.typing(): + await self.refresh() + await self.branding_info(ctx) + + @branding_cmds.group(name="daemon", aliases=["d", "task"]) + async def daemon_group(self, ctx: commands.Context) -> None: + """Control the background daemon.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @daemon_group.command(name="status") + async def daemon_status(self, ctx: commands.Context) -> None: + """Check whether daemon is currently active.""" + if self._daemon_running: + remaining_time = (arrow.utcnow() + await time_until_midnight()).humanize() + response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green) + response.set_footer(text=f"Next refresh {remaining_time}") + else: + response = discord.Embed(description="Daemon not running", colour=Colours.soft_red) + + await ctx.send(embed=response) + + @daemon_group.command(name="start") + async def daemon_start(self, ctx: commands.Context) -> None: + """If the daemon isn't running, start it.""" + if self._daemon_running: + raise BrandingError("Daemon already running!") + + self.daemon = self.bot.loop.create_task(self._daemon_func()) + response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + @daemon_group.command(name="stop") + async def daemon_stop(self, ctx: commands.Context) -> None: + """If the daemon is running, stop it.""" + if not self._daemon_running: + raise BrandingError("Daemon not running!") + + self.daemon.cancel() + response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green) + await ctx.send(embed=response) + + +def setup(bot: SeasonalBot) -> None: + """Load BrandingManager cog.""" + bot.add_cog(BrandingManager(bot)) + log.info("BrandingManager cog loaded") diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py new file mode 100644 index 00000000..d268dab1 --- /dev/null +++ b/bot/exts/evergreen/error_handler.py @@ -0,0 +1,111 @@ +import logging +import math +import random +from typing import Iterable, Union + +from discord import Embed, Message +from discord.ext import commands + +from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES +from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure +from bot.utils.exceptions import BrandingError + +log = logging.getLogger(__name__) + + +class CommandErrorHandler(commands.Cog): + """A error handler for the PythonDiscord server.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @staticmethod + def revert_cooldown_counter(command: commands.Command, message: Message) -> None: + """Undoes the last cooldown counter for user-error cases.""" + if command._buckets.valid: + bucket = command._buckets.get_bucket(message) + bucket._tokens = min(bucket.rate, bucket._tokens + 1) + logging.debug("Cooldown counter reverted as the command was not used correctly.") + + @staticmethod + def error_embed(message: str, title: Union[Iterable, str] = ERROR_REPLIES) -> Embed: + """Build a basic embed with red colour and either a random error title or a title provided.""" + embed = Embed(colour=Colours.soft_red) + if isinstance(title, str): + embed.title = title + else: + embed.title = random.choice(title) + embed.description = message + return embed + + @commands.Cog.listener() + async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: + """Activates when a command opens an error.""" + if hasattr(ctx.command, 'on_error'): + logging.debug("A command error occured but the command had it's own error handler.") + return + + error = getattr(error, 'original', error) + logging.debug( + f"Error Encountered: {type(error).__name__} - {str(error)}, " + f"Command: {ctx.command}, " + f"Author: {ctx.author}, " + f"Channel: {ctx.channel}" + ) + + if isinstance(error, commands.CommandNotFound): + return + + if isinstance(error, BrandingError): + await ctx.send(embed=self.error_embed(str(error))) + return + + if isinstance(error, (InChannelCheckFailure, InMonthCheckFailure)): + await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5) + return + + if isinstance(error, commands.UserInputError): + self.revert_cooldown_counter(ctx.command, ctx.message) + embed = self.error_embed( + f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```" + ) + await ctx.send(embed=embed) + return + + if isinstance(error, commands.CommandOnCooldown): + mins, secs = divmod(math.ceil(error.retry_after), 60) + embed = self.error_embed( + f"This command is on cooldown:\nPlease retry in {mins} minutes {secs} seconds.", + NEGATIVE_REPLIES + ) + await ctx.send(embed=embed, delete_after=7.5) + return + + if isinstance(error, commands.DisabledCommand): + await ctx.send(embed=self.error_embed("This command has been disabled.", NEGATIVE_REPLIES)) + return + + if isinstance(error, commands.NoPrivateMessage): + await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES)) + return + + if isinstance(error, commands.BadArgument): + self.revert_cooldown_counter(ctx.command, ctx.message) + embed = self.error_embed( + "The argument you provided was invalid: " + f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```" + ) + await ctx.send(embed=embed) + return + + if isinstance(error, commands.CheckFailure): + await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES)) + return + + log.exception(f"Unhandled command error: {str(error)}", exc_info=error) + + +def setup(bot: commands.Bot) -> None: + """Error handler Cog load.""" + bot.add_cog(CommandErrorHandler(bot)) + log.info("CommandErrorHandler cog loaded") diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py new file mode 100644 index 00000000..889ae079 --- /dev/null +++ b/bot/exts/evergreen/fun.py @@ -0,0 +1,148 @@ +import functools +import logging +import random +from typing import Callable, Tuple, Union + +from discord import Embed, Message +from discord.ext import commands +from discord.ext.commands import Bot, Cog, Context, MessageConverter + +from bot import utils +from bot.constants import Emojis + +log = logging.getLogger(__name__) + +UWU_WORDS = { + "fi": "fwi", + "l": "w", + "r": "w", + "some": "sum", + "th": "d", + "thing": "fing", + "tho": "fo", + "you're": "yuw'we", + "your": "yur", + "you": "yuw", +} + + +class Fun(Cog): + """A collection of general commands for fun.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @commands.command() + async def roll(self, ctx: Context, num_rolls: int = 1) -> None: + """Outputs a number of random dice emotes (up to 6).""" + output = "" + if num_rolls > 6: + num_rolls = 6 + elif num_rolls < 1: + output = ":no_entry: You must roll at least once." + for _ in range(num_rolls): + terning = f"terning{random.randint(1, 6)}" + output += getattr(Emojis, terning, '') + await ctx.send(output) + + @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) + async def uwu_command(self, ctx: Context, *, text: str) -> None: + """ + Converts a given `text` into it's uwu equivalent. + + Also accepts a valid discord Message ID or link. + """ + conversion_func = functools.partial( + utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True + ) + text, embed = await Fun._get_text_and_embed(ctx, text) + # Convert embed if it exists + if embed is not None: + embed = Fun._convert_embed(conversion_func, embed) + converted_text = conversion_func(text) + # Don't put >>> if only embed present + if converted_text: + converted_text = f">>> {converted_text.lstrip('> ')}" + await ctx.send(content=converted_text, embed=embed) + + @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",)) + async def randomcase_command(self, ctx: Context, *, text: str) -> None: + """ + Randomly converts the casing of a given `text`. + + Also accepts a valid discord Message ID or link. + """ + def conversion_func(text: str) -> str: + """Randomly converts the casing of a given string.""" + return "".join( + char.upper() if round(random.random()) else char.lower() for char in text + ) + text, embed = await Fun._get_text_and_embed(ctx, text) + # Convert embed if it exists + if embed is not None: + embed = Fun._convert_embed(conversion_func, embed) + converted_text = conversion_func(text) + # Don't put >>> if only embed present + if converted_text: + converted_text = f">>> {converted_text.lstrip('> ')}" + await ctx.send(content=converted_text, embed=embed) + + @staticmethod + async def _get_text_and_embed(ctx: Context, text: str) -> Tuple[str, Union[Embed, None]]: + """ + Attempts to extract the text and embed from a possible link to a discord Message. + + Returns a tuple of: + str: If `text` is a valid discord Message, the contents of the message, else `text`. + Union[Embed, None]: The embed if found in the valid Message, else None + """ + embed = None + message = await Fun._get_discord_message(ctx, text) + if isinstance(message, Message): + text = message.content + # Take first embed because we can't send multiple embeds + if message.embeds: + embed = message.embeds[0] + return (text, embed) + + @staticmethod + async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]: + """ + Attempts to convert a given `text` to a discord Message object and return it. + + Conversion will succeed if given a discord Message ID or link. + Returns `text` if the conversion fails. + """ + try: + text = await MessageConverter().convert(ctx, text) + except commands.BadArgument: + log.debug(f"Input '{text:.20}...' is not a valid Discord Message") + return text + + @staticmethod + def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: + """ + Converts the text in an embed using a given conversion function, then return the embed. + + Only modifies the following fields: title, description, footer, fields + """ + embed_dict = embed.to_dict() + + embed_dict["title"] = func(embed_dict.get("title", "")) + embed_dict["description"] = func(embed_dict.get("description", "")) + + if "footer" in embed_dict: + embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) + + if "fields" in embed_dict: + for field in embed_dict["fields"]: + field["name"] = func(field.get("name", "")) + field["value"] = func(field.get("value", "")) + + return Embed.from_dict(embed_dict) + + +def setup(bot: commands.Bot) -> None: + """Fun Cog load.""" + bot.add_cog(Fun(bot)) + log.info("Fun cog loaded") diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py new file mode 100644 index 00000000..d43b1ad6 --- /dev/null +++ b/bot/exts/evergreen/game.py @@ -0,0 +1,395 @@ +import difflib +import logging +import random +from datetime import datetime as dt +from enum import IntEnum +from typing import Any, Dict, List, Optional, Tuple + +from aiohttp import ClientSession +from discord import Embed +from discord.ext import tasks +from discord.ext.commands import Cog, Context, group + +from bot.bot import SeasonalBot +from bot.constants import STAFF_ROLES, Tokens +from bot.utils.decorators import with_role +from bot.utils.pagination import ImagePaginator, LinePaginator + +# Base URL of IGDB API +BASE_URL = "https://api-v3.igdb.com" + +HEADERS = { + "user-key": Tokens.igdb, + "Accept": "application/json" +} + +logger = logging.getLogger(__name__) + +# --------- +# TEMPLATES +# --------- + +# Body templates +# Request body template for get_games_list +GAMES_LIST_BODY = ( + "fields cover.image_id, first_release_date, total_rating, name, storyline, url, platforms.name, status," + "involved_companies.company.name, summary, age_ratings.category, age_ratings.rating, total_rating_count;" + "{sort} {limit} {offset} {genre} {additional}" +) + +# Request body template for get_companies_list +COMPANIES_LIST_BODY = ( + "fields name, url, start_date, logo.image_id, developed.name, published.name, description;" + "offset {offset}; limit {limit};" +) + +# Request body template for games search +SEARCH_BODY = 'fields name, url, storyline, total_rating, total_rating_count; limit 50; search "{term}";' + +# Pages templates +# Game embed layout +GAME_PAGE = ( + "**[{name}]({url})**\n" + "{description}" + "**Release Date:** {release_date}\n" + "**Rating:** {rating}/100 :star: (based on {rating_count} ratings)\n" + "**Platforms:** {platforms}\n" + "**Status:** {status}\n" + "**Age Ratings:** {age_ratings}\n" + "**Made by:** {made_by}\n\n" + "{storyline}" +) + +# .games company command page layout +COMPANY_PAGE = ( + "**[{name}]({url})**\n" + "{description}" + "**Founded:** {founded}\n" + "**Developed:** {developed}\n" + "**Published:** {published}" +) + +# For .games search command line layout +GAME_SEARCH_LINE = ( + "**[{name}]({url})**\n" + "{rating}/100 :star: (based on {rating_count} ratings)\n" +) + +# URL templates +COVER_URL = "https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg" +LOGO_URL = "https://images.igdb.com/igdb/image/upload/t_logo_med/{image_id}.png" + +# Create aliases for complex genre names +ALIASES = { + "Role-playing (rpg)": ["Role playing", "Rpg"], + "Turn-based strategy (tbs)": ["Turn based strategy", "Tbs"], + "Real time strategy (rts)": ["Real time strategy", "Rts"], + "Hack and slash/beat 'em up": ["Hack and slash"] +} + + +class GameStatus(IntEnum): + """Game statuses in IGDB API.""" + + Released = 0 + Alpha = 2 + Beta = 3 + Early = 4 + Offline = 5 + Cancelled = 6 + Rumored = 7 + + +class AgeRatingCategories(IntEnum): + """IGDB API Age Rating categories IDs.""" + + ESRB = 1 + PEGI = 2 + + +class AgeRatings(IntEnum): + """PEGI/ESRB ratings IGDB API IDs.""" + + Three = 1 + Seven = 2 + Twelve = 3 + Sixteen = 4 + Eighteen = 5 + RP = 6 + EC = 7 + E = 8 + E10 = 9 + T = 10 + M = 11 + AO = 12 + + +class Games(Cog): + """Games Cog contains commands that collect data from IGDB.""" + + def __init__(self, bot: SeasonalBot): + self.bot = bot + self.http_session: ClientSession = bot.http_session + + self.genres: Dict[str, int] = {} + + self.refresh_genres_task.start() + + @tasks.loop(hours=1.0) + async def refresh_genres_task(self) -> None: + """Refresh genres in every hour.""" + try: + await self._get_genres() + except Exception as e: + logger.warning(f"There was error while refreshing genres: {e}") + return + logger.info("Successfully refreshed genres.") + + def cog_unload(self) -> None: + """Cancel genres refreshing start when unloading Cog.""" + self.refresh_genres_task.cancel() + logger.info("Successfully stopped Genres Refreshing task.") + + async def _get_genres(self) -> None: + """Create genres variable for games command.""" + body = "fields name; limit 100;" + async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=HEADERS) as resp: + result = await resp.json() + + genres = {genre["name"].capitalize(): genre["id"] for genre in result} + + # Replace complex names with names from ALIASES + for genre_name, genre in genres.items(): + if genre_name in ALIASES: + for alias in ALIASES[genre_name]: + self.genres[alias] = genre + else: + self.genres[genre_name] = genre + + @group(name="games", aliases=["game"], invoke_without_command=True) + async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str] = None) -> None: + """ + Get random game(s) by genre from IGDB. Use .games genres command to get all available genres. + + Also support amount parameter, what max is 25 and min 1, default 5. Supported formats: + - .games + - .games + """ + # When user didn't specified genre, send help message + if genre is None: + await ctx.send_help("games") + return + + # Capitalize genre for check + genre = "".join(genre).capitalize() + + # Check for amounts, max is 25 and min 1 + if not 1 <= amount <= 25: + await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") + return + + # Get games listing, if genre don't exist, show error message with possibilities. + # Offset must be random, due otherwise we will get always same result (offset show in which position should + # API start returning result) + try: + games = await self.get_games_list(amount, self.genres[genre], offset=random.randint(0, 150)) + except KeyError: + possibilities = "`, `".join(difflib.get_close_matches(genre, self.genres)) + await ctx.send(f"Invalid genre `{genre}`. {f'Maybe you meant `{possibilities}`?' if possibilities else ''}") + return + + # Create pages and paginate + pages = [await self.create_page(game) for game in games] + + await ImagePaginator.paginate(pages, ctx, Embed(title=f"Random {genre.title()} Games")) + + @games.command(name="top", aliases=["t"]) + async def top(self, ctx: Context, amount: int = 10) -> None: + """ + Get current Top games in IGDB. + + Support amount parameter. Max is 25, min is 1. + """ + if not 1 <= amount <= 25: + await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") + return + + games = await self.get_games_list(amount, sort="total_rating desc", + additional_body="where total_rating >= 90; sort total_rating_count desc;") + + pages = [await self.create_page(game) for game in games] + await ImagePaginator.paginate(pages, ctx, Embed(title=f"Top {amount} Games")) + + @games.command(name="genres", aliases=["genre", "g"]) + async def genres(self, ctx: Context) -> None: + """Get all available genres.""" + await ctx.send(f"Currently available genres: {', '.join(f'`{genre}`' for genre in self.genres)}") + + @games.command(name="search", aliases=["s"]) + async def search(self, ctx: Context, *, search_term: str) -> None: + """Find games by name.""" + lines = await self.search_games(search_term) + + await LinePaginator.paginate(lines, ctx, Embed(title=f"Game Search Results: {search_term}"), empty=False) + + @games.command(name="company", aliases=["companies"]) + async def company(self, ctx: Context, amount: int = 5) -> None: + """ + Get random Game Companies companies from IGDB API. + + Support amount parameter. Max is 25, min is 1. + """ + if not 1 <= amount <= 25: + await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") + return + + # Get companies listing. Provide limit for limiting how much companies will be returned. Get random offset to + # get (almost) every time different companies (offset show in which position should API start returning result) + companies = await self.get_companies_list(limit=amount, offset=random.randint(0, 150)) + pages = [await self.create_company_page(co) for co in companies] + + await ImagePaginator.paginate(pages, ctx, Embed(title="Random Game Companies")) + + @with_role(*STAFF_ROLES) + @games.command(name="refresh", aliases=["r"]) + async def refresh_genres_command(self, ctx: Context) -> None: + """Refresh .games command genres.""" + try: + await self._get_genres() + except Exception as e: + await ctx.send(f"There was error while refreshing genres: `{e}`") + return + await ctx.send("Successfully refreshed genres.") + + async def get_games_list(self, + amount: int, + genre: Optional[str] = None, + sort: Optional[str] = None, + additional_body: str = "", + offset: int = 0 + ) -> List[Dict[str, Any]]: + """ + Get list of games from IGDB API by parameters that is provided. + + Amount param show how much games this get, genre is genre ID and at least one genre in game must this when + provided. Sort is sorting by specific field and direction, ex. total_rating desc/asc (total_rating is field, + desc/asc is direction). Additional_body is field where you can pass extra search parameters. Offset show start + position in API. + """ + # Create body of IGDB API request, define fields, sorting, offset, limit and genre + params = { + "sort": f"sort {sort};" if sort else "", + "limit": f"limit {amount};", + "offset": f"offset {offset};" if offset else "", + "genre": f"where genres = ({genre});" if genre else "", + "additional": additional_body + } + body = GAMES_LIST_BODY.format(**params) + + # Do request to IGDB API, create headers, URL, define body, return result + async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: + return await resp.json() + + async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]: + """Create content of Game Page.""" + # Create cover image URL from template + url = COVER_URL.format(**{"image_id": data["cover"]["image_id"] if "cover" in data else ""}) + + # Get release date separately with checking + release_date = dt.utcfromtimestamp(data["first_release_date"]).date() if "first_release_date" in data else "?" + + # Create Age Ratings value + rating = ", ".join(f"{AgeRatingCategories(age['category']).name} {AgeRatings(age['rating']).name}" + for age in data["age_ratings"]) if "age_ratings" in data else "?" + + companies = [c["company"]["name"] for c in data["involved_companies"]] if "involved_companies" in data else "?" + + # Create formatting for template page + formatting = { + "name": data["name"], + "url": data["url"], + "description": f"{data['summary']}\n\n" if "summary" in data else "\n", + "release_date": release_date, + "rating": round(data["total_rating"] if "total_rating" in data else 0, 2), + "rating_count": data["total_rating_count"] if "total_rating_count" in data else "?", + "platforms": ", ".join(platform["name"] for platform in data["platforms"]) if "platforms" in data else "?", + "status": GameStatus(data["status"]).name if "status" in data else "?", + "age_ratings": rating, + "made_by": ", ".join(companies), + "storyline": data["storyline"] if "storyline" in data else "" + } + page = GAME_PAGE.format(**formatting) + + return page, url + + async def search_games(self, search_term: str) -> List[str]: + """Search game from IGDB API by string, return listing of pages.""" + lines = [] + + # Define request body of IGDB API request and do request + body = SEARCH_BODY.format(**{"term": search_term}) + + async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: + data = await resp.json() + + # Loop over games, format them to good format, make line and append this to total lines + for game in data: + formatting = { + "name": game["name"], + "url": game["url"], + "rating": round(game["total_rating"] if "total_rating" in game else 0, 2), + "rating_count": game["total_rating_count"] if "total_rating" in game else "?" + } + line = GAME_SEARCH_LINE.format(**formatting) + lines.append(line) + + return lines + + async def get_companies_list(self, limit: int, offset: int = 0) -> List[Dict[str, Any]]: + """ + Get random Game Companies from IGDB API. + + Limit is parameter, that show how much movies this should return, offset show in which position should API start + returning results. + """ + # Create request body from template + body = COMPANIES_LIST_BODY.format(**{ + "limit": limit, + "offset": offset + }) + + async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=HEADERS) as resp: + return await resp.json() + + async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]: + """Create good formatted Game Company page.""" + # Generate URL of company logo + url = LOGO_URL.format(**{"image_id": data["logo"]["image_id"] if "logo" in data else ""}) + + # Try to get found date of company + founded = dt.utcfromtimestamp(data["start_date"]).date() if "start_date" in data else "?" + + # Generate list of games, that company have developed or published + developed = ", ".join(game["name"] for game in data["developed"]) if "developed" in data else "?" + published = ", ".join(game["name"] for game in data["published"]) if "published" in data else "?" + + formatting = { + "name": data["name"], + "url": data["url"], + "description": f"{data['description']}\n\n" if "description" in data else "\n", + "founded": founded, + "developed": developed, + "published": published + } + page = COMPANY_PAGE.format(**formatting) + + return page, url + + +def setup(bot: SeasonalBot) -> None: + """Add/Load Games cog.""" + # Check does IGDB API key exist, if not, log warning and don't load cog + if not Tokens.igdb: + logger.warning("No IGDB API key. Not loading Games cog.") + return + bot.add_cog(Games(bot)) diff --git a/bot/exts/evergreen/help.py b/bot/exts/evergreen/help.py new file mode 100644 index 00000000..f4d76402 --- /dev/null +++ b/bot/exts/evergreen/help.py @@ -0,0 +1,554 @@ +# Help command from Python bot. All commands that will be added to there in futures should be added to here too. +import asyncio +import itertools +import logging +from collections import namedtuple +from contextlib import suppress +from typing import Union + +from discord import Colour, Embed, HTTPException, Message, Reaction, User +from discord.ext import commands +from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context +from fuzzywuzzy import fuzz, process + +from bot import constants +from bot.bot import SeasonalBot +from bot.constants import Emojis +from bot.utils.pagination import ( + FIRST_EMOJI, LAST_EMOJI, + LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, +) + +DELETE_EMOJI = Emojis.trashcan + +REACTIONS = { + FIRST_EMOJI: 'first', + LEFT_EMOJI: 'back', + RIGHT_EMOJI: 'next', + LAST_EMOJI: 'end', + DELETE_EMOJI: 'stop', +} + +Cog = namedtuple('Cog', ['name', 'description', 'commands']) + +log = logging.getLogger(__name__) + + +class HelpQueryNotFound(ValueError): + """ + Raised when a HelpSession Query doesn't match a command or cog. + + Contains the custom attribute of ``possible_matches``. + Instances of this object contain a dictionary of any command(s) that were close to matching the + query, where keys are the possible matched command names and values are the likeness match scores. + """ + + def __init__(self, arg: str, possible_matches: dict = None): + super().__init__(arg) + self.possible_matches = possible_matches + + +class HelpSession: + """ + An interactive session for bot and command help output. + + Expected attributes include: + * title: str + The title of the help message. + * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command] + * description: str + The description of the query. + * pages: list[str] + A list of the help content split into manageable pages. + * message: `discord.Message` + The message object that's showing the help contents. + * destination: `discord.abc.Messageable` + Where the help message is to be sent to. + Cogs can be grouped into custom categories. All cogs with the same category will be displayed + under a single category name in the help output. Custom categories are defined inside the cogs + as a class attribute named `category`. A description can also be specified with the attribute + `category_description`. If a description is not found in at least one cog, the default will be + the regular description (class docstring) of the first cog found in the category. + """ + + def __init__( + self, + ctx: Context, + *command, + cleanup: bool = False, + only_can_run: bool = True, + show_hidden: bool = False, + max_lines: int = 15 + ): + """Creates an instance of the HelpSession class.""" + self._ctx = ctx + self._bot = ctx.bot + self.title = "Command Help" + + # set the query details for the session + if command: + query_str = ' '.join(command) + self.query = self._get_query(query_str) + self.description = self.query.description or self.query.help + else: + self.query = ctx.bot + self.description = self.query.description + self.author = ctx.author + self.destination = ctx.channel + + # set the config for the session + self._cleanup = cleanup + self._only_can_run = only_can_run + self._show_hidden = show_hidden + self._max_lines = max_lines + + # init session states + self._pages = None + self._current_page = 0 + self.message = None + self._timeout_task = None + self.reset_timeout() + + def _get_query(self, query: str) -> Union[Command, Cog]: + """Attempts to match the provided query with a valid command or cog.""" + command = self._bot.get_command(query) + if command: + return command + + # Find all cog categories that match. + cog_matches = [] + description = None + for cog in self._bot.cogs.values(): + if hasattr(cog, "category") and cog.category == query: + cog_matches.append(cog) + if hasattr(cog, "category_description"): + description = cog.category_description + + # Try to search by cog name if no categories match. + if not cog_matches: + cog = self._bot.cogs.get(query) + + # Don't consider it a match if the cog has a category. + if cog and not hasattr(cog, "category"): + cog_matches = [cog] + + if cog_matches: + cog = cog_matches[0] + cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs + + return Cog( + name=cog.category if hasattr(cog, "category") else cog.qualified_name, + description=description or cog.description, + commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list + ) + + self._handle_not_found(query) + + def _handle_not_found(self, query: str) -> None: + """ + Handles when a query does not match a valid command or cog. + + Will pass on possible close matches along with the `HelpQueryNotFound` exception. + """ + # Combine command and cog names + choices = list(self._bot.all_commands) + list(self._bot.cogs) + + result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) + + raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) + + async def timeout(self, seconds: int = 30) -> None: + """Waits for a set number of seconds, then stops the help session.""" + await asyncio.sleep(seconds) + await self.stop() + + def reset_timeout(self) -> None: + """Cancels the original timeout task and sets it again from the start.""" + # cancel original if it exists + if self._timeout_task: + if not self._timeout_task.cancelled(): + self._timeout_task.cancel() + + # recreate the timeout task + self._timeout_task = self._bot.loop.create_task(self.timeout()) + + async def on_reaction_add(self, reaction: Reaction, user: User) -> None: + """Event handler for when reactions are added on the help message.""" + # ensure it was the relevant session message + if reaction.message.id != self.message.id: + return + + # ensure it was the session author who reacted + if user.id != self.author.id: + return + + emoji = str(reaction.emoji) + + # check if valid action + if emoji not in REACTIONS: + return + + self.reset_timeout() + + # Run relevant action method + action = getattr(self, f'do_{REACTIONS[emoji]}', None) + if action: + await action() + + # remove the added reaction to prep for re-use + with suppress(HTTPException): + await self.message.remove_reaction(reaction, user) + + async def on_message_delete(self, message: Message) -> None: + """Closes the help session when the help message is deleted.""" + if message.id == self.message.id: + await self.stop() + + async def prepare(self) -> None: + """Sets up the help session pages, events, message and reactions.""" + await self.build_pages() + + self._bot.add_listener(self.on_reaction_add) + self._bot.add_listener(self.on_message_delete) + + await self.update_page() + self.add_reactions() + + def add_reactions(self) -> None: + """Adds the relevant reactions to the help message based on if pagination is required.""" + # if paginating + if len(self._pages) > 1: + for reaction in REACTIONS: + self._bot.loop.create_task(self.message.add_reaction(reaction)) + + # if single-page + else: + self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI)) + + def _category_key(self, cmd: Command) -> str: + """ + Returns a cog name of a given command for use as a key for `sorted` and `groupby`. + + A zero width space is used as a prefix for results with no cogs to force them last in ordering. + """ + if cmd.cog: + try: + if cmd.cog.category: + return f'**{cmd.cog.category}**' + except AttributeError: + pass + + return f'**{cmd.cog_name}**' + else: + return "**\u200bNo Category:**" + + def _get_command_params(self, cmd: Command) -> str: + """ + Returns the command usage signature. + + This is a custom implementation of `command.signature` in order to format the command + signature without aliases. + """ + results = [] + for name, param in cmd.clean_params.items(): + + # if argument has a default value + if param.default is not param.empty: + + if isinstance(param.default, str): + show_default = param.default + else: + show_default = param.default is not None + + # if default is not an empty string or None + if show_default: + results.append(f'[{name}={param.default}]') + else: + results.append(f'[{name}]') + + # if variable length argument + elif param.kind == param.VAR_POSITIONAL: + results.append(f'[{name}...]') + + # if required + else: + results.append(f'<{name}>') + + return f"{cmd.name} {' '.join(results)}" + + async def build_pages(self) -> None: + """Builds the list of content pages to be paginated through in the help message, as a list of str.""" + # Use LinePaginator to restrict embed line height + paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) + + prefix = constants.Client.prefix + + # show signature if query is a command + if isinstance(self.query, commands.Command): + signature = self._get_command_params(self.query) + parent = self.query.full_parent_name + ' ' if self.query.parent else '' + paginator.add_line(f'**```{prefix}{parent}{signature}```**') + + aliases = ', '.join(f'`{a}`' for a in self.query.aliases) + if aliases: + paginator.add_line(f'**Can also use:** {aliases}\n') + + if not await self.query.can_run(self._ctx): + paginator.add_line('***You cannot run this command.***\n') + + if isinstance(self.query, Cog): + paginator.add_line(f'**{self.query.name}**') + + if self.description: + paginator.add_line(f'*{self.description}*') + + # list all children commands of the queried object + if isinstance(self.query, (commands.GroupMixin, Cog)): + + # remove hidden commands if session is not wanting hiddens + if not self._show_hidden: + filtered = [c for c in self.query.commands if not c.hidden] + else: + filtered = self.query.commands + + # if after filter there are no commands, finish up + if not filtered: + self._pages = paginator.pages + return + + if isinstance(self.query, Cog): + grouped = (('**Commands:**', self.query.commands),) + + elif isinstance(self.query, commands.Command): + grouped = (('**Subcommands:**', self.query.commands),) + + # don't show prefix for subcommands + prefix = '' + + # otherwise sort and organise all commands into categories + else: + cat_sort = sorted(filtered, key=self._category_key) + grouped = itertools.groupby(cat_sort, key=self._category_key) + + for category, cmds in grouped: + cmds = sorted(cmds, key=lambda c: c.name) + + if len(cmds) == 0: + continue + + cat_cmds = [] + + for command in cmds: + + # skip if hidden and hide if session is set to + if command.hidden and not self._show_hidden: + continue + + # see if the user can run the command + strikeout = '' + + # Patch to make the !help command work outside of #bot-commands again + # This probably needs a proper rewrite, but this will make it work in + # the mean time. + try: + can_run = await command.can_run(self._ctx) + except CheckFailure: + can_run = False + + if not can_run: + # skip if we don't show commands they can't run + if self._only_can_run: + continue + strikeout = '~~' + + signature = self._get_command_params(command) + info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" + + # handle if the command has no docstring + if command.short_doc: + cat_cmds.append(f'{info}\n*{command.short_doc}*') + else: + cat_cmds.append(f'{info}\n*No details provided.*') + + # state var for if the category should be added next + print_cat = 1 + new_page = True + + for details in cat_cmds: + + # keep details together, paginating early if it won't fit + lines_adding = len(details.split('\n')) + print_cat + if paginator._linecount + lines_adding > self._max_lines: + paginator._linecount = 0 + new_page = True + paginator.close_page() + + # new page so print category title again + print_cat = 1 + + if print_cat: + if new_page: + paginator.add_line('') + paginator.add_line(category) + print_cat = 0 + + paginator.add_line(details) + + self._pages = paginator.pages + + def embed_page(self, page_number: int = 0) -> Embed: + """Returns an Embed with the requested page formatted within.""" + embed = Embed() + + if isinstance(self.query, (commands.Command, Cog)) and page_number > 0: + title = f'Command Help | "{self.query.name}"' + else: + title = self.title + + embed.set_author(name=title, icon_url=constants.Icons.questionmark) + embed.description = self._pages[page_number] + + page_count = len(self._pages) + if page_count > 1: + embed.set_footer(text=f'Page {self._current_page+1} / {page_count}') + + return embed + + async def update_page(self, page_number: int = 0) -> None: + """Sends the intial message, or changes the existing one to the given page number.""" + self._current_page = page_number + embed_page = self.embed_page(page_number) + + if not self.message: + self.message = await self.destination.send(embed=embed_page) + else: + await self.message.edit(embed=embed_page) + + @classmethod + async def start(cls, ctx: Context, *command, **options) -> "HelpSession": + """ + Create and begin a help session based on the given command context. + + Available options kwargs: + * cleanup: Optional[bool] + Set to `True` to have the message deleted on session end. Defaults to `False`. + * only_can_run: Optional[bool] + Set to `True` to hide commands the user can't run. Defaults to `False`. + * show_hidden: Optional[bool] + Set to `True` to include hidden commands. Defaults to `False`. + * max_lines: Optional[int] + Sets the max number of lines the paginator will add to a single page. Defaults to 20. + """ + session = cls(ctx, *command, **options) + await session.prepare() + + return session + + async def stop(self) -> None: + """Stops the help session, removes event listeners and attempts to delete the help message.""" + self._bot.remove_listener(self.on_reaction_add) + self._bot.remove_listener(self.on_message_delete) + + # ignore if permission issue, or the message doesn't exist + with suppress(HTTPException, AttributeError): + if self._cleanup: + await self.message.delete() + else: + await self.message.clear_reactions() + + @property + def is_first_page(self) -> bool: + """Check if session is currently showing the first page.""" + return self._current_page == 0 + + @property + def is_last_page(self) -> bool: + """Check if the session is currently showing the last page.""" + return self._current_page == (len(self._pages)-1) + + async def do_first(self) -> None: + """Event that is called when the user requests the first page.""" + if not self.is_first_page: + await self.update_page(0) + + async def do_back(self) -> None: + """Event that is called when the user requests the previous page.""" + if not self.is_first_page: + await self.update_page(self._current_page-1) + + async def do_next(self) -> None: + """Event that is called when the user requests the next page.""" + if not self.is_last_page: + await self.update_page(self._current_page+1) + + async def do_end(self) -> None: + """Event that is called when the user requests the last page.""" + if not self.is_last_page: + await self.update_page(len(self._pages)-1) + + async def do_stop(self) -> None: + """Event that is called when the user requests to stop the help session.""" + await self.message.delete() + + +class Help(DiscordCog): + """Custom Embed Pagination Help feature.""" + + @commands.command('help') + async def new_help(self, ctx: Context, *commands) -> None: + """Shows Command Help.""" + try: + await HelpSession.start(ctx, *commands) + except HelpQueryNotFound as error: + embed = Embed() + embed.colour = Colour.red() + embed.title = str(error) + + if error.possible_matches: + matches = '\n'.join(error.possible_matches.keys()) + embed.description = f'**Did you mean:**\n`{matches}`' + + await ctx.send(embed=embed) + + +def unload(bot: SeasonalBot) -> None: + """ + Reinstates the original help command. + + This is run if the cog raises an exception on load, or if the extension is unloaded. + """ + bot.remove_command('help') + bot.add_command(bot._old_help) + + +def setup(bot: SeasonalBot) -> None: + """ + The setup for the help extension. + + This is called automatically on `bot.load_extension` being run. + Stores the original help command instance on the `bot._old_help` attribute for later + reinstatement, before removing it from the command registry so the new help command can be + loaded successfully. + If an exception is raised during the loading of the cog, `unload` will be called in order to + reinstate the original help command. + """ + bot._old_help = bot.get_command('help') + bot.remove_command('help') + + try: + bot.add_cog(Help()) + except Exception: + unload(bot) + raise + else: + log.info("Help cog loaded") + + +def teardown(bot: SeasonalBot) -> None: + """ + The teardown for the help extension. + + This is called automatically on `bot.unload_extension` being run. + Calls `unload` in order to reinstate the original help command. + """ + unload(bot) diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py new file mode 100644 index 00000000..fb18b62a --- /dev/null +++ b/bot/exts/evergreen/issues.py @@ -0,0 +1,77 @@ +import logging + +import discord +from discord.ext import commands + +from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS +from bot.utils.decorators import override_in_channel + +log = logging.getLogger(__name__) + +BAD_RESPONSE = { + 404: "Issue/pull request not located! Please enter a valid number!", + 403: "Rate limit has been hit! Please try again later!" +} + + +class Issues(commands.Cog): + """Cog that allows users to retrieve issues from GitHub.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(aliases=("pr",)) + @override_in_channel(WHITELISTED_CHANNELS) + async def issue( + self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord" + ) -> None: + """Command to retrieve issues from a GitHub repository.""" + url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" + merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" + + log.trace(f"Querying GH issues API: {url}") + async with self.bot.http_session.get(url) as r: + json_data = await r.json() + + if r.status in BAD_RESPONSE: + log.warning(f"Received response {r.status} from: {url}") + return await ctx.send(f"[{str(r.status)}] {BAD_RESPONSE.get(r.status)}") + + # The initial API request is made to the issues API endpoint, which will return information + # if the issue or PR is present. However, the scope of information returned for PRs differs + # from issues: if the 'issues' key is present in the response then we can pull the data we + # need from the initial API call. + if "issues" in json_data.get("html_url"): + if json_data.get("state") == "open": + icon_url = Emojis.issue + else: + icon_url = Emojis.issue_closed + + # If the 'issues' key is not contained in the API response and there is no error code, then + # we know that a PR has been requested and a call to the pulls API endpoint is necessary + # to get the desired information for the PR. + else: + log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}") + async with self.bot.http_session.get(merge_url) as m: + if json_data.get("state") == "open": + icon_url = Emojis.pull_request + # When the status is 204 this means that the state of the PR is merged + elif m.status == 204: + icon_url = Emojis.merge + else: + icon_url = Emojis.pull_request_closed + + issue_url = json_data.get("html_url") + description_text = f"[{repository}] #{number} {json_data.get('title')}" + resp = discord.Embed( + colour=Colours.bright_green, + description=f"{icon_url} [{description_text}]({issue_url})" + ) + resp.set_author(name="GitHub", url=issue_url) + await ctx.send(embed=resp) + + +def setup(bot: commands.Bot) -> None: + """Cog Retrieves Issues From Github.""" + bot.add_cog(Issues(bot)) + log.info("Issues cog loaded") diff --git a/bot/exts/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py new file mode 100644 index 00000000..e47ef454 --- /dev/null +++ b/bot/exts/evergreen/magic_8ball.py @@ -0,0 +1,32 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class Magic8ball(commands.Cog): + """A Magic 8ball command to respond to a user's question.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + with open(Path("bot/resources/evergreen/magic8ball.json"), "r") as file: + self.answers = json.load(file) + + @commands.command(name="8ball") + async def output_answer(self, ctx: commands.Context, *, question: str) -> None: + """Return a Magic 8ball answer from answers list.""" + if len(question.split()) >= 3: + answer = random.choice(self.answers) + await ctx.send(answer) + else: + await ctx.send("Usage: .8ball (minimum length of 3 eg: `will I win?`)") + + +def setup(bot: commands.Bot) -> None: + """Magic 8ball Cog load.""" + bot.add_cog(Magic8ball(bot)) + log.info("Magic8ball cog loaded") diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py new file mode 100644 index 00000000..b0ba8145 --- /dev/null +++ b/bot/exts/evergreen/minesweeper.py @@ -0,0 +1,285 @@ +import logging +import typing +from dataclasses import dataclass +from random import randint, random + +import discord +from discord.ext import commands + +from bot.constants import Client + +MESSAGE_MAPPING = { + 0: ":stop_button:", + 1: ":one:", + 2: ":two:", + 3: ":three:", + 4: ":four:", + 5: ":five:", + 6: ":six:", + 7: ":seven:", + 8: ":eight:", + 9: ":nine:", + 10: ":keycap_ten:", + "bomb": ":bomb:", + "hidden": ":grey_question:", + "flag": ":flag_black:", + "x": ":x:" +} + +log = logging.getLogger(__name__) + + +class CoordinateConverter(commands.Converter): + """Converter for Coordinates.""" + + async def convert(self, ctx: commands.Context, coordinate: str) -> typing.Tuple[int, int]: + """Take in a coordinate string and turn it into an (x, y) tuple.""" + if not 2 <= len(coordinate) <= 3: + raise commands.BadArgument('Invalid co-ordinate provided') + + coordinate = coordinate.lower() + if coordinate[0].isalpha(): + digit = coordinate[1:] + letter = coordinate[0] + else: + digit = coordinate[:-1] + letter = coordinate[-1] + + if not digit.isdigit(): + raise commands.BadArgument + + x = ord(letter) - ord('a') + y = int(digit) - 1 + + if (not 0 <= x <= 9) or (not 0 <= y <= 9): + raise commands.BadArgument + return x, y + + +GameBoard = typing.List[typing.List[typing.Union[str, int]]] + + +@dataclass +class Game: + """The data for a game.""" + + board: GameBoard + revealed: GameBoard + dm_msg: discord.Message + chat_msg: discord.Message + activated_on_server: bool + + +GamesDict = typing.Dict[int, Game] + + +class Minesweeper(commands.Cog): + """Play a game of Minesweeper.""" + + def __init__(self, bot: commands.Bot) -> None: + self.games: GamesDict = {} # Store the currently running games + + @commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True) + async def minesweeper_group(self, ctx: commands.Context) -> None: + """Commands for Playing Minesweeper.""" + await ctx.send_help(ctx.command) + + @staticmethod + def get_neighbours(x: int, y: int) -> typing.Generator[typing.Tuple[int, int], None, None]: + """Get all the neighbouring x and y including it self.""" + for x_ in [x - 1, x, x + 1]: + for y_ in [y - 1, y, y + 1]: + if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10: + yield x_, y_ + + def generate_board(self, bomb_chance: float) -> GameBoard: + """Generate a 2d array for the board.""" + board: GameBoard = [ + [ + "bomb" if random() <= bomb_chance else "number" + for _ in range(10) + ] for _ in range(10) + ] + + # make sure there is always a free cell + board[randint(0, 9)][randint(0, 9)] = "number" + + for y, row in enumerate(board): + for x, cell in enumerate(row): + if cell == "number": + # calculate bombs near it + bombs = 0 + for x_, y_ in self.get_neighbours(x, y): + if board[y_][x_] == "bomb": + bombs += 1 + board[y][x] = bombs + return board + + @staticmethod + def format_for_discord(board: GameBoard) -> str: + """Format the board as a string for Discord.""" + discord_msg = ( + ":stop_button: :regional_indicator_a::regional_indicator_b::regional_indicator_c:" + ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:" + ":regional_indicator_h::regional_indicator_i::regional_indicator_j:\n\n" + ) + rows = [] + for row_number, row in enumerate(board): + new_row = f"{MESSAGE_MAPPING[row_number + 1]} " + new_row += "".join(MESSAGE_MAPPING[cell] for cell in row) + rows.append(new_row) + + discord_msg += "\n".join(rows) + return discord_msg + + @minesweeper_group.command(name="start") + async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None: + """Start a game of Minesweeper.""" + if ctx.author.id in self.games: # Player is already playing + await ctx.send(f"{ctx.author.mention} you already have a game running!", delete_after=2) + await ctx.message.delete(delay=2) + return + + # Add game to list + board: GameBoard = self.generate_board(bomb_chance) + revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] + + if ctx.guild: + await ctx.send(f"{ctx.author.mention} is playing Minesweeper") + chat_msg = await ctx.send(f"Here's there board!\n{self.format_for_discord(revealed_board)}") + else: + chat_msg = None + + await ctx.author.send( + f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n" + f"Close the game with `{Client.prefix}ms end`\n" + ) + dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") + + self.games[ctx.author.id] = Game( + board=board, + revealed=revealed_board, + dm_msg=dm_msg, + chat_msg=chat_msg, + activated_on_server=ctx.guild is not None + ) + + async def update_boards(self, ctx: commands.Context) -> None: + """Update both playing boards.""" + game = self.games[ctx.author.id] + await game.dm_msg.delete() + game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}") + if game.activated_on_server: + await game.chat_msg.edit(content=f"Here's there board!\n{self.format_for_discord(game.revealed)}") + + @commands.dm_only() + @minesweeper_group.command(name="flag") + async def flag_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: + """Place multiple flags on the board.""" + board: GameBoard = self.games[ctx.author.id].revealed + for x, y in coordinates: + if board[y][x] == "hidden": + board[y][x] = "flag" + + await self.update_boards(ctx) + + @staticmethod + def reveal_bombs(revealed: GameBoard, board: GameBoard) -> None: + """Reveals all the bombs.""" + for y, row in enumerate(board): + for x, cell in enumerate(row): + if cell == "bomb": + revealed[y][x] = cell + + async def lost(self, ctx: commands.Context) -> None: + """The player lost the game.""" + game = self.games[ctx.author.id] + self.reveal_bombs(game.revealed, game.board) + await ctx.author.send(":fire: You lost! :fire:") + if game.activated_on_server: + await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost Minesweeper! :fire:") + + async def won(self, ctx: commands.Context) -> None: + """The player won the game.""" + game = self.games[ctx.author.id] + await ctx.author.send(":tada: You won! :tada:") + if game.activated_on_server: + await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:") + + def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None: + """Recursively reveal adjacent cells when a 0 cell is encountered.""" + for x_, y_ in self.get_neighbours(x, y): + if revealed[y_][x_] != "hidden": + continue + revealed[y_][x_] = board[y_][x_] + if board[y_][x_] == 0: + self.reveal_zeros(revealed, board, x_, y_) + + async def check_if_won(self, ctx: commands.Context, revealed: GameBoard, board: GameBoard) -> bool: + """Checks if a player has won.""" + if any( + revealed[y][x] in ["hidden", "flag"] and board[y][x] != "bomb" + for x in range(10) + for y in range(10) + ): + return False + else: + await self.won(ctx) + return True + + async def reveal_one( + self, + ctx: commands.Context, + revealed: GameBoard, + board: GameBoard, + x: int, + y: int + ) -> bool: + """ + Reveal one square. + + return is True if the game ended, breaking the loop in `reveal_command` and deleting the game + """ + revealed[y][x] = board[y][x] + if board[y][x] == "bomb": + await self.lost(ctx) + revealed[y][x] = "x" # mark bomb that made you lose with a x + return True + elif board[y][x] == 0: + self.reveal_zeros(revealed, board, x, y) + return await self.check_if_won(ctx, revealed, board) + + @commands.dm_only() + @minesweeper_group.command(name="reveal") + async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: + """Reveal multiple cells.""" + game = self.games[ctx.author.id] + revealed: GameBoard = game.revealed + board: GameBoard = game.board + + for x, y in coordinates: + # reveal_one returns True if the revealed cell is a bomb or the player won, ending the game + if await self.reveal_one(ctx, revealed, board, x, y): + await self.update_boards(ctx) + del self.games[ctx.author.id] + break + else: + await self.update_boards(ctx) + + @minesweeper_group.command(name="end") + async def end_command(self, ctx: commands.Context) -> None: + """End your current game.""" + game = self.games[ctx.author.id] + game.revealed = game.board + await self.update_boards(ctx) + new_msg = f":no_entry: Game canceled :no_entry:\n{game.dm_msg.content}" + await game.dm_msg.edit(content=new_msg) + if game.activated_on_server: + await game.chat_msg.edit(content=new_msg) + del self.games[ctx.author.id] + + +def setup(bot: commands.Bot) -> None: + """Load the Minesweeper cog.""" + bot.add_cog(Minesweeper(bot)) + log.info("Minesweeper cog loaded") diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py new file mode 100644 index 00000000..93aeef30 --- /dev/null +++ b/bot/exts/evergreen/movie.py @@ -0,0 +1,198 @@ +import logging +import random +from enum import Enum +from typing import Any, Dict, List, Tuple +from urllib.parse import urlencode + +from aiohttp import ClientSession +from discord import Embed +from discord.ext.commands import Bot, Cog, Context, group + +from bot.constants import Tokens +from bot.utils.pagination import ImagePaginator + +# Define base URL of TMDB +BASE_URL = "https://api.themoviedb.org/3/" + +logger = logging.getLogger(__name__) + +# Define movie params, that will be used for every movie request +MOVIE_PARAMS = { + "api_key": Tokens.tmdb, + "language": "en-US" +} + + +class MovieGenres(Enum): + """Movies Genre names and IDs.""" + + Action = "28" + Adventure = "12" + Animation = "16" + Comedy = "35" + Crime = "80" + Documentary = "99" + Drama = "18" + Family = "10751" + Fantasy = "14" + History = "36" + Horror = "27" + Music = "10402" + Mystery = "9648" + Romance = "10749" + Science = "878" + Thriller = "53" + Western = "37" + + +class Movie(Cog): + """Movie Cog contains movies command that grab random movies from TMDB.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.http_session: ClientSession = bot.http_session + + @group(name='movies', aliases=['movie'], invoke_without_command=True) + async def movies(self, ctx: Context, genre: str = "", amount: int = 5) -> None: + """ + Get random movies by specifying genre. Also support amount parameter, that define how much movies will be shown. + + Default 5. Use .movies genres to get all available genres. + """ + # Check is there more than 20 movies specified, due TMDB return 20 movies + # per page, so this is max. Also you can't get less movies than 1, just logic + if amount > 20: + await ctx.send("You can't get more than 20 movies at once. (TMDB limits)") + return + elif amount < 1: + await ctx.send("You can't get less than 1 movie.") + return + + # Capitalize genre for getting data from Enum, get random page, send help when genre don't exist. + genre = genre.capitalize() + try: + result = await self.get_movies_list(self.http_session, MovieGenres[genre].value, 1) + except KeyError: + await ctx.send_help('movies') + return + + # Check if "results" is in result. If not, throw error. + if "results" not in result.keys(): + err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \ + f"{result['status_message']}." + await ctx.send(err_msg) + logger.warning(err_msg) + + # Get random page. Max page is last page where is movies with this genre. + page = random.randint(1, result["total_pages"]) + + # Get movies list from TMDB, check if results key in result. When not, raise error. + movies = await self.get_movies_list(self.http_session, MovieGenres[genre].value, page) + if 'results' not in movies.keys(): + err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \ + f"{result['status_message']}." + await ctx.send(err_msg) + logger.warning(err_msg) + + # Get all pages and embed + pages = await self.get_pages(self.http_session, movies, amount) + embed = await self.get_embed(genre) + + await ImagePaginator.paginate(pages, ctx, embed) + + @movies.command(name='genres', aliases=['genre', 'g']) + async def genres(self, ctx: Context) -> None: + """Show all currently available genres for .movies command.""" + await ctx.send(f"Current available genres: {', '.join('`' + genre.name + '`' for genre in MovieGenres)}") + + async def get_movies_list(self, client: ClientSession, genre_id: str, page: int) -> Dict[str, Any]: + """Return JSON of TMDB discover request.""" + # Define params of request + params = { + "api_key": Tokens.tmdb, + "language": "en-US", + "sort_by": "popularity.desc", + "include_adult": "false", + "include_video": "false", + "page": page, + "with_genres": genre_id + } + + url = BASE_URL + "discover/movie?" + urlencode(params) + + # Make discover request to TMDB, return result + async with client.get(url) as resp: + return await resp.json() + + async def get_pages(self, client: ClientSession, movies: Dict[str, Any], amount: int) -> List[Tuple[str, str]]: + """Fetch all movie pages from movies dictionary. Return list of pages.""" + pages = [] + + for i in range(amount): + movie_id = movies['results'][i]['id'] + movie = await self.get_movie(client, movie_id) + + page, img = await self.create_page(movie) + pages.append((page, img)) + + return pages + + async def get_movie(self, client: ClientSession, movie: int) -> Dict: + """Get Movie by movie ID from TMDB. Return result dictionary.""" + url = BASE_URL + f"movie/{movie}?" + urlencode(MOVIE_PARAMS) + + async with client.get(url) as resp: + return await resp.json() + + async def create_page(self, movie: Dict[str, Any]) -> Tuple[str, str]: + """Create page from TMDB movie request result. Return formatted page + image.""" + text = "" + + # Add title + tagline (if not empty) + text += f"**{movie['title']}**\n" + if movie['tagline']: + text += f"{movie['tagline']}\n\n" + else: + text += "\n" + + # Add other information + text += f"**Rating:** {movie['vote_average']}/10 :star:\n" + text += f"**Release Date:** {movie['release_date']}\n\n" + + text += "__**Production Information**__\n" + + companies = movie['production_companies'] + countries = movie['production_countries'] + + text += f"**Made by:** {', '.join(company['name'] for company in companies)}\n" + text += f"**Made in:** {', '.join(country['name'] for country in countries)}\n\n" + + text += "__**Some Numbers**__\n" + + budget = f"{movie['budget']:,d}" if movie['budget'] else "?" + revenue = f"{movie['revenue']:,d}" if movie['revenue'] else "?" + + if movie['runtime'] is not None: + duration = divmod(movie['runtime'], 60) + else: + duration = ("?", "?") + + text += f"**Budget:** ${budget}\n" + text += f"**Revenue:** ${revenue}\n" + text += f"**Duration:** {f'{duration[0]} hour(s) {duration[1]} minute(s)'}\n\n" + + text += movie['overview'] + + img = f"http://image.tmdb.org/t/p/w200{movie['poster_path']}" + + # Return page content and image + return text, img + + async def get_embed(self, name: str) -> Embed: + """Return embed of random movies. Uses name in title.""" + return Embed(title=f'Random {name} Movies').set_footer(text='Powered by TMDB (themoviedb.org)') + + +def setup(bot: Bot) -> None: + """Load Movie Cog.""" + bot.add_cog(Movie(bot)) diff --git a/bot/exts/evergreen/recommend_game.py b/bot/exts/evergreen/recommend_game.py new file mode 100644 index 00000000..835a4e53 --- /dev/null +++ b/bot/exts/evergreen/recommend_game.py @@ -0,0 +1,51 @@ +import json +import logging +from pathlib import Path +from random import shuffle + +import discord +from discord.ext import commands + +log = logging.getLogger(__name__) +game_recs = [] + +# Populate the list `game_recs` with resource files +for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"): + with rec_path.open(encoding='utf-8') as file: + data = json.load(file) + game_recs.append(data) +shuffle(game_recs) + + +class RecommendGame(commands.Cog): + """Commands related to recommending games.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.index = 0 + + @commands.command(name="recommendgame", aliases=['gamerec']) + async def recommend_game(self, ctx: commands.Context) -> None: + """Sends an Embed of a random game recommendation.""" + if self.index >= len(game_recs): + self.index = 0 + shuffle(game_recs) + game = game_recs[self.index] + self.index += 1 + + author = self.bot.get_user(int(game['author'])) + + # Creating and formatting Embed + embed = discord.Embed(color=discord.Colour.blue()) + if author is not None: + embed.set_author(name=author.name, icon_url=author.avatar_url) + embed.set_image(url=game['image']) + embed.add_field(name='Recommendation: ' + game['title'] + '\n' + game['link'], value=game['description']) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Loads the RecommendGame cog.""" + bot.add_cog(RecommendGame(bot)) + log.info("RecommendGame cog loaded") diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py new file mode 100644 index 00000000..a07e591f --- /dev/null +++ b/bot/exts/evergreen/reddit.py @@ -0,0 +1,129 @@ +import logging +import random + +import discord +from discord.ext import commands +from discord.ext.commands.cooldowns import BucketType + +from bot.utils.pagination import ImagePaginator + +log = logging.getLogger(__name__) + + +class Reddit(commands.Cog): + """Fetches reddit posts.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + async def fetch(self, url: str) -> dict: + """Send a get request to the reddit API and get json response.""" + session = self.bot.http_session + params = { + 'limit': 50 + } + headers = { + 'User-Agent': 'Iceman' + } + + async with session.get(url=url, params=params, headers=headers) as response: + return await response.json() + + @commands.command(name='reddit') + @commands.cooldown(1, 10, BucketType.user) + async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None: + """ + Fetch reddit posts by using this command. + + Gets a post from r/python by default. + Usage: + --> .reddit [subreddit_name] [hot/top/new] + """ + pages = [] + sort_list = ["hot", "new", "top", "rising"] + if sort.lower() not in sort_list: + await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`") + sort = "hot" + + data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json') + + try: + posts = data["data"]["children"] + except KeyError: + return await ctx.send('Subreddit not found!') + if not posts: + return await ctx.send('No posts available!') + + if posts[1]["data"]["over_18"] is True: + return await ctx.send( + "You cannot access this Subreddit as it is ment for those who " + "are 18 years or older." + ) + + embed_titles = "" + + # Chooses k unique random elements from a population sequence or set. + random_posts = random.sample(posts, k=5) + + # ----------------------------------------------------------- + # This code below is bound of change when the emojis are added. + + upvote_emoji = self.bot.get_emoji(638729835245731840) + comment_emoji = self.bot.get_emoji(638729835073765387) + user_emoji = self.bot.get_emoji(638729835442602003) + text_emoji = self.bot.get_emoji(676030265910493204) + video_emoji = self.bot.get_emoji(676030265839190047) + image_emoji = self.bot.get_emoji(676030265734201344) + reddit_emoji = self.bot.get_emoji(676030265734332427) + + # ------------------------------------------------------------ + + for i, post in enumerate(random_posts, start=1): + post_title = post["data"]["title"][0:50] + post_url = post['data']['url'] + if post_title == "": + post_title = "No Title." + elif post_title == post_url: + post_title = "Title is itself a link." + + # ------------------------------------------------------------------ + # Embed building. + + embed_titles += f"**{i}.[{post_title}]({post_url})**\n" + image_url = " " + post_stats = f"{text_emoji}" # Set default content type to text. + + if post["data"]["is_video"] is True or "youtube" in post_url.split("."): + # This means the content type in the post is a video. + post_stats = f"{video_emoji} " + + elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"): + # This means the content type in the post is an image. + post_stats = f"{image_emoji} " + image_url = post_url + + votes = f'{upvote_emoji}{post["data"]["ups"]}' + comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}' + post_stats += ( + f"\u2002{votes}\u2003" + f"{comments}" + f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n' + ) + embed_titles += f"{post_stats}\n" + page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}" + + embed = discord.Embed() + page_tuple = (page_text, image_url) + pages.append(page_tuple) + + # ------------------------------------------------------------------ + + pages.insert(0, (embed_titles, " ")) + embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url) + await ImagePaginator.paginate(pages, ctx, embed) + + +def setup(bot: commands.Bot) -> None: + """Load the Cog.""" + bot.add_cog(Reddit(bot)) + log.debug('Loaded') diff --git a/bot/exts/evergreen/showprojects.py b/bot/exts/evergreen/showprojects.py new file mode 100644 index 00000000..a943e548 --- /dev/null +++ b/bot/exts/evergreen/showprojects.py @@ -0,0 +1,34 @@ +import logging + +from discord import Message +from discord.ext import commands + +from bot.constants import Channels + +log = logging.getLogger(__name__) + + +class ShowProjects(commands.Cog): + """Cog that reacts to posts in the #show-your-projects.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.lastPoster = 0 # Given 0 as the default last poster ID as no user can actually have 0 assigned to them + + @commands.Cog.listener() + async def on_message(self, message: Message) -> None: + """Adds reactions to posts in #show-your-projects.""" + reactions = ["\U0001f44d", "\U00002764", "\U0001f440", "\U0001f389", "\U0001f680", "\U00002b50", "\U0001f6a9"] + if (message.channel.id == Channels.show_your_projects + and message.author.bot is False + and message.author.id != self.lastPoster): + for reaction in reactions: + await message.add_reaction(reaction) + + self.lastPoster = message.author.id + + +def setup(bot: commands.Bot) -> None: + """Show Projects Reaction Cog.""" + bot.add_cog(ShowProjects(bot)) + log.info("ShowProjects cog loaded") diff --git a/bot/exts/evergreen/snakes/__init__.py b/bot/exts/evergreen/snakes/__init__.py new file mode 100644 index 00000000..06ebfb47 --- /dev/null +++ b/bot/exts/evergreen/snakes/__init__.py @@ -0,0 +1,13 @@ +import logging + +from discord.ext import commands + +from bot.exts.evergreen.snakes.snakes_cog import Snakes + +log = logging.getLogger(__name__) + + +def setup(bot: commands.Bot) -> None: + """Snakes Cog load.""" + bot.add_cog(Snakes(bot)) + log.info("Snakes cog loaded") diff --git a/bot/exts/evergreen/snakes/converter.py b/bot/exts/evergreen/snakes/converter.py new file mode 100644 index 00000000..d4e93b56 --- /dev/null +++ b/bot/exts/evergreen/snakes/converter.py @@ -0,0 +1,85 @@ +import json +import logging +import random +from typing import Iterable, List + +import discord +from discord.ext.commands import Context, Converter +from fuzzywuzzy import fuzz + +from bot.exts.evergreen.snakes.utils import SNAKE_RESOURCES +from bot.utils import disambiguate + +log = logging.getLogger(__name__) + + +class Snake(Converter): + """Snake converter for the Snakes Cog.""" + + snakes = None + special_cases = None + + async def convert(self, ctx: Context, name: str) -> str: + """Convert the input snake name to the closest matching Snake object.""" + await self.build_list() + name = name.lower() + + if name == 'python': + return 'Python (programming language)' + + def get_potential(iterable: Iterable, *, threshold: int = 80) -> List[str]: + nonlocal name + potential = [] + + for item in iterable: + original, item = item, item.lower() + + if name == item: + return [original] + + a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item) + if a >= threshold or b >= threshold: + potential.append(original) + + return potential + + # Handle special cases + if name.lower() in self.special_cases: + return self.special_cases.get(name.lower(), name.lower()) + + names = {snake['name']: snake['scientific'] for snake in self.snakes} + all_names = names.keys() | names.values() + timeout = len(all_names) * (3 / 4) + + embed = discord.Embed( + title='Found multiple choices. Please choose the correct one.', colour=0x59982F) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url) + + name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed) + return names.get(name, name) + + @classmethod + async def build_list(cls) -> None: + """Build list of snakes from the static snake resources.""" + # Get all the snakes + if cls.snakes is None: + with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: + cls.snakes = json.load(snakefile) + + # Get the special cases + if cls.special_cases is None: + with (SNAKE_RESOURCES / "special_snakes.json").open() as snakefile: + special_cases = json.load(snakefile) + cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} + + @classmethod + async def random(cls) -> str: + """ + Get a random Snake from the loaded resources. + + This is stupid. We should find a way to somehow get the global session into a global context, + so I can get it from here. + """ + await cls.build_list() + names = [snake['scientific'] for snake in cls.snakes] + return random.choice(names) diff --git a/bot/exts/evergreen/snakes/snakes_cog.py b/bot/exts/evergreen/snakes/snakes_cog.py new file mode 100644 index 00000000..36c176ce --- /dev/null +++ b/bot/exts/evergreen/snakes/snakes_cog.py @@ -0,0 +1,1149 @@ +import asyncio +import colorsys +import logging +import os +import random +import re +import string +import textwrap +import urllib +from functools import partial +from io import BytesIO +from typing import Any, Dict, List + +import aiohttp +import async_timeout +from PIL import Image, ImageDraw, ImageFont +from discord import Colour, Embed, File, Member, Message, Reaction +from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group + +from bot.constants import ERROR_REPLIES, Tokens +from bot.exts.evergreen.snakes import utils +from bot.exts.evergreen.snakes.converter import Snake +from bot.utils.decorators import locked + +log = logging.getLogger(__name__) + + +# region: Constants +# Color +SNAKE_COLOR = 0x399600 + +# Antidote constants +SYRINGE_EMOJI = "\U0001F489" # :syringe: +PILL_EMOJI = "\U0001F48A" # :pill: +HOURGLASS_EMOJI = "\u231B" # :hourglass: +CROSSBONES_EMOJI = "\u2620" # :skull_crossbones: +ALEMBIC_EMOJI = "\u2697" # :alembic: +TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole +CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole +BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole +HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses +EMPTY_UNICODE = "\u200b" # literally just an empty space + +ANTIDOTE_EMOJI = ( + SYRINGE_EMOJI, + PILL_EMOJI, + HOURGLASS_EMOJI, + CROSSBONES_EMOJI, + ALEMBIC_EMOJI, +) + +# Quiz constants +ANSWERS_EMOJI = { + "a": "\U0001F1E6", # :regional_indicator_a: 🇦 + "b": "\U0001F1E7", # :regional_indicator_b: 🇧 + "c": "\U0001F1E8", # :regional_indicator_c: 🇨 + "d": "\U0001F1E9", # :regional_indicator_d: 🇩 +} + +ANSWERS_EMOJI_REVERSE = { + "\U0001F1E6": "A", # :regional_indicator_a: 🇦 + "\U0001F1E7": "B", # :regional_indicator_b: 🇧 + "\U0001F1E8": "C", # :regional_indicator_c: 🇨 + "\U0001F1E9": "D", # :regional_indicator_d: 🇩 +} + +# Zzzen of pythhhon constant +ZEN = """ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +""" + +# Max messages to train snake_chat on +MSG_MAX = 100 + +# get_snek constants +URL = "https://en.wikipedia.org/w/api.php?" + +# snake guess responses +INCORRECT_GUESS = ( + "Nope, that's not what it is.", + "Not quite.", + "Not even close.", + "Terrible guess.", + "Nnnno.", + "Dude. No.", + "I thought everyone knew this one.", + "Guess you suck at snakes.", + "Bet you feel stupid now.", + "Hahahaha, no.", + "Did you hit the wrong key?" +) + +CORRECT_GUESS = ( + "**WRONG**. Wait, no, actually you're right.", + "Yeah, you got it!", + "Yep, that's exactly what it is.", + "Uh-huh. Yep yep yep.", + "Yeah that's right.", + "Yup. How did you know that?", + "Are you a herpetologist?", + "Sure, okay, but I bet you can't pronounce it.", + "Are you cheating?" +) + +# snake card consts +CARD = { + "top": Image.open("bot/resources/snakes/snake_cards/card_top.png"), + "frame": Image.open("bot/resources/snakes/snake_cards/card_frame.png"), + "bottom": Image.open("bot/resources/snakes/snake_cards/card_bottom.png"), + "backs": [ + Image.open(f"bot/resources/snakes/snake_cards/backs/{file}") + for file in os.listdir("bot/resources/snakes/snake_cards/backs") + ], + "font": ImageFont.truetype("bot/resources/snakes/snake_cards/expressway.ttf", 20) +} +# endregion + + +class Snakes(Cog): + """ + Commands related to snakes, created by our community during the first code jam. + + More information can be found in the code-jam-1 repo. + + https://github.com/python-discord/code-jam-1 + """ + + wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) + valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp') + + def __init__(self, bot: Bot): + self.active_sal = {} + self.bot = bot + self.snake_names = utils.get_resource("snake_names") + self.snake_idioms = utils.get_resource("snake_idioms") + self.snake_quizzes = utils.get_resource("snake_quiz") + self.snake_facts = utils.get_resource("snake_facts") + + # region: Helper methods + @staticmethod + def _beautiful_pastel(hue: float) -> int: + """Returns random bright pastels.""" + light = random.uniform(0.7, 0.85) + saturation = 1 + + rgb = colorsys.hls_to_rgb(hue, light, saturation) + hex_rgb = "" + + for part in rgb: + value = int(part * 0xFF) + hex_rgb += f"{value:02x}" + + return int(hex_rgb, 16) + + @staticmethod + def _generate_card(buffer: BytesIO, content: dict) -> BytesIO: + """ + Generate a card from snake information. + + Written by juan and Someone during the first code jam. + """ + snake = Image.open(buffer) + + # Get the size of the snake icon, configure the height of the image box (yes, it changes) + icon_width = 347 # Hardcoded, not much i can do about that + icon_height = int((icon_width / snake.width) * snake.height) + frame_copies = icon_height // CARD['frame'].height + 1 + snake.thumbnail((icon_width, icon_height)) + + # Get the dimensions of the final image + main_height = icon_height + CARD['top'].height + CARD['bottom'].height + main_width = CARD['frame'].width + + # Start creating the foreground + foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + foreground.paste(CARD['top'], (0, 0)) + + # Generate the frame borders to the correct height + for offset in range(frame_copies): + position = (0, CARD['top'].height + offset * CARD['frame'].height) + foreground.paste(CARD['frame'], position) + + # Add the image and bottom part of the image + foreground.paste(snake, (36, CARD['top'].height)) # Also hardcoded :( + foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height)) + + # Setup the background + back = random.choice(CARD['backs']) + back_copies = main_height // back.height + 1 + full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + + # Generate the tiled background + for offset in range(back_copies): + full_image.paste(back, (16, 16 + offset * back.height)) + + # Place the foreground onto the final image + full_image.paste(foreground, (0, 0), foreground) + + # Get the first two sentences of the info + description = '.'.join(content['info'].split(".")[:2]) + '.' + + # Setup positioning variables + margin = 36 + offset = CARD['top'].height + icon_height + margin + + # Create blank rectangle image which will be behind the text + rectangle = Image.new( + "RGBA", + (main_width, main_height), + (0, 0, 0, 0) + ) + + # Draw a semi-transparent rectangle on it + rect = ImageDraw.Draw(rectangle) + rect.rectangle( + (margin, offset, main_width - margin, main_height - margin), + fill=(63, 63, 63, 128) + ) + + # Paste it onto the final image + full_image.paste(rectangle, (0, 0), mask=rectangle) + + # Draw the text onto the final image + draw = ImageDraw.Draw(full_image) + for line in textwrap.wrap(description, 36): + draw.text([margin + 4, offset], line, font=CARD['font']) + offset += CARD['font'].getsize(line)[1] + + # Get the image contents as a BufferIO object + buffer = BytesIO() + full_image.save(buffer, 'PNG') + buffer.seek(0) + + return buffer + + @staticmethod + def _snakify(message: str) -> str: + """Sssnakifffiesss a sstring.""" + # Replace fricatives with exaggerated snake fricatives. + simple_fricatives = [ + "f", "s", "z", "h", + "F", "S", "Z", "H", + ] + complex_fricatives = [ + "th", "sh", "Th", "Sh" + ] + + for letter in simple_fricatives: + if letter.islower(): + message = message.replace(letter, letter * random.randint(2, 4)) + else: + message = message.replace(letter, (letter * random.randint(2, 4)).title()) + + for fricative in complex_fricatives: + message = message.replace(fricative, fricative[0] + fricative[1] * random.randint(2, 4)) + + return message + + async def _fetch(self, session: aiohttp.ClientSession, url: str, params: dict = None) -> dict: + """Asynchronous web request helper method.""" + if params is None: + params = {} + + async with async_timeout.timeout(10): + async with session.get(url, params=params) as response: + return await response.json() + + def _get_random_long_message(self, messages: List[str], retries: int = 10) -> str: + """ + Fetch a message that's at least 3 words long, if possible to do so in retries attempts. + + Else, just return whatever the last message is. + """ + long_message = random.choice(messages) + if len(long_message.split()) < 3 and retries > 0: + return self._get_random_long_message( + messages, + retries=retries - 1 + ) + + return long_message + + async def _get_snek(self, name: str) -> Dict[str, Any]: + """ + Fetches all the data from a wikipedia article about a snake. + + Builds a dict that the .get() method can use. + + Created by Ava and eivl. + """ + snake_info = {} + + async with aiohttp.ClientSession() as session: + params = { + 'format': 'json', + 'action': 'query', + 'list': 'search', + 'srsearch': name, + 'utf8': '', + 'srlimit': '1', + } + + json = await self._fetch(session, URL, params=params) + + # Wikipedia does have a error page + try: + pageid = json["query"]["search"][0]["pageid"] + except KeyError: + # Wikipedia error page ID(?) + pageid = 41118 + except IndexError: + return None + + params = { + 'format': 'json', + 'action': 'query', + 'prop': 'extracts|images|info', + 'exlimit': 'max', + 'explaintext': '', + 'inprop': 'url', + 'pageids': pageid + } + + json = await self._fetch(session, URL, params=params) + + # Constructing dict - handle exceptions later + try: + snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] + snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] + snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"] + snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"] + snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"] + except KeyError: + snake_info["error"] = True + + if snake_info["images"]: + i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/' + image_list = [] + map_list = [] + thumb_list = [] + + # Wikipedia has arbitrary images that are not snakes + banned = [ + 'Commons-logo.svg', + 'Red%20Pencil%20Icon.png', + 'distribution', + 'The%20Death%20of%20Cleopatra%20arthur.jpg', + 'Head%20of%20holotype', + 'locator', + 'Woma.png', + '-map.', + '.svg', + 'ange.', + 'Adder%20(PSF).png' + ] + + for image in snake_info["images"]: + # Images come in the format of `File:filename.extension` + file, sep, filename = image["title"].partition(':') + filename = filename.replace(" ", "%20") # Wikipedia returns good data! + + if not filename.startswith('Map'): + if any(ban in filename for ban in banned): + pass + else: + image_list.append(f"{i_url}{filename}") + thumb_list.append(f"{i_url}{filename}?width=100") + else: + map_list.append(f"{i_url}{filename}") + + snake_info["image_list"] = image_list + snake_info["map_list"] = map_list + snake_info["thumb_list"] = thumb_list + snake_info["name"] = name + + match = self.wiki_brief.match(snake_info['extract']) + info = match.group(1) if match else None + + if info: + info = info.replace("\n", "\n\n") # Give us some proper paragraphs. + + snake_info["info"] = info + + return snake_info + + async def _get_snake_name(self) -> Dict[str, str]: + """Gets a random snake name.""" + return random.choice(self.snake_names) + + async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list) -> None: + """Validate the answer using a reaction event loop.""" + def predicate(reaction: Reaction, user: Member) -> bool: + """Test if the the answer is valid and can be evaluated.""" + return ( + reaction.message.id == message.id # The reaction is attached to the question we asked. + and user == ctx.author # It's the user who triggered the quiz. + and str(reaction.emoji) in ANSWERS_EMOJI.values() # The reaction is one of the options. + ) + + for emoji in ANSWERS_EMOJI.values(): + await message.add_reaction(emoji) + + # Validate the answer + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate) + except asyncio.TimeoutError: + await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.") + await message.clear_reactions() + return + + if str(reaction.emoji) == ANSWERS_EMOJI[answer]: + await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") + else: + await ctx.send( + f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**." + ) + + await message.clear_reactions() + # endregion + + # region: Commands + @group(name='snakes', aliases=('snake',), invoke_without_command=True) + async def snakes_group(self, ctx: Context) -> None: + """Commands from our first code jam.""" + await ctx.send_help(ctx.command) + + @bot_has_permissions(manage_messages=True) + @snakes_group.command(name='antidote') + @locked() + async def antidote_command(self, ctx: Context) -> None: + """ + Antidote! Can you create the antivenom before the patient dies? + + Rules: You have 4 ingredients for each antidote, you only have 10 attempts + Once you synthesize the antidote, you will be presented with 4 markers + Tick: This means you have a CORRECT ingredient in the CORRECT position + Circle: This means you have a CORRECT ingredient in the WRONG position + Cross: This means you have a WRONG ingredient in the WRONG position + + Info: The game automatically ends after 5 minutes inactivity. + You should only use each ingredient once. + + This game was created by Lord Bisk and Runew0lf. + """ + def predicate(reaction_: Reaction, user_: Member) -> bool: + """Make sure that this reaction is what we want to operate on.""" + return ( + all(( + # Reaction is on this message + reaction_.message.id == board_id.id, + # Reaction is one of the pagination emotes + reaction_.emoji in ANTIDOTE_EMOJI, + # Reaction was not made by the Bot + user_.id != self.bot.user.id, + # Reaction was made by author + user_.id == ctx.author.id + )) + ) + + # Initialize variables + antidote_tries = 0 + antidote_guess_count = 0 + antidote_guess_list = [] + guess_result = [] + board = [] + page_guess_list = [] + page_result_list = [] + win = False + + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + + # Generate answer + antidote_answer = list(ANTIDOTE_EMOJI) # Duplicate list, not reference it + random.shuffle(antidote_answer) + antidote_answer.pop() + + # Begin initial board building + for i in range(0, 10): + page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}") + page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}") + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board)) + board_id = await ctx.send(embed=antidote_embed) # Display board + + # Add our player reactions + for emoji in ANTIDOTE_EMOJI: + await board_id.add_reaction(emoji) + + # Begin main game loop + while not win and antidote_tries < 10: + try: + reaction, user = await ctx.bot.wait_for( + "reaction_add", timeout=300, check=predicate) + except asyncio.TimeoutError: + log.debug("Antidote timed out waiting for a reaction") + break # We're done, no reactions for the last 5 minutes + + if antidote_tries < 10: + if antidote_guess_count < 4: + if reaction.emoji in ANTIDOTE_EMOJI: + antidote_guess_list.append(reaction.emoji) + antidote_guess_count += 1 + + if antidote_guess_count == 4: # Guesses complete + antidote_guess_count = 0 + page_guess_list[antidote_tries] = " ".join(antidote_guess_list) + + # Now check guess + for i in range(0, len(antidote_answer)): + if antidote_guess_list[i] == antidote_answer[i]: + guess_result.append(TICK_EMOJI) + elif antidote_guess_list[i] in antidote_answer: + guess_result.append(BLANK_EMOJI) + else: + guess_result.append(CROSS_EMOJI) + guess_result.sort() + page_result_list[antidote_tries] = " ".join(guess_result) + + # Rebuild the board + board = [] + for i in range(0, 10): + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + + # Remove Reactions + for emoji in antidote_guess_list: + await board_id.remove_reaction(emoji, user) + + if antidote_guess_list == antidote_answer: + win = True + + antidote_tries += 1 + guess_result = [] + antidote_guess_list = [] + + antidote_embed.clear_fields() + antidote_embed.add_field(name=f"{10 - antidote_tries} " + f"guesses remaining", + value="\n".join(board)) + # Redisplay the board + await board_id.edit(embed=antidote_embed) + + # Winning / Ending Screen + if win is True: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") + antidote_embed.add_field(name=f"You have created the snake antidote!", + value=f"The solution was: {' '.join(antidote_answer)}\n" + f"You had {10 - antidote_tries} tries remaining.") + await board_id.edit(embed=antidote_embed) + else: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif") + antidote_embed.add_field(name=EMPTY_UNICODE, + value=f"Sorry you didnt make the antidote in time.\n" + f"The formula was {' '.join(antidote_answer)}") + await board_id.edit(embed=antidote_embed) + + log.debug("Ending pagination and removing all reactions...") + await board_id.clear_reactions() + + @snakes_group.command(name='draw') + async def draw_command(self, ctx: Context) -> None: + """ + Draws a random snek using Perlin noise. + + Written by Momo and kel. + Modified by juan and lemon. + """ + with ctx.typing(): + + # Generate random snake attributes + width = random.randint(6, 10) + length = random.randint(15, 22) + random_hue = random.random() + snek_color = self._beautiful_pastel(random_hue) + text_color = self._beautiful_pastel((random_hue + 0.5) % 1) + bg_color = ( + random.randint(32, 50), + random.randint(32, 50), + random.randint(50, 70), + ) + + # Build and send the snek + text = random.choice(self.snake_idioms)["idiom"] + factory = utils.PerlinNoiseFactory(dimension=1, octaves=2) + image_frame = utils.create_snek_frame( + factory, + snake_width=width, + snake_length=length, + snake_color=snek_color, + text=text, + text_color=text_color, + bg_color=bg_color + ) + png_bytes = utils.frame_to_png_bytes(image_frame) + file = File(png_bytes, filename='snek.png') + await ctx.send(file=file) + + @snakes_group.command(name='get') + @bot_has_permissions(manage_messages=True) + @locked() + async def get_command(self, ctx: Context, *, name: Snake = None) -> None: + """ + Fetches information about a snake from Wikipedia. + + Created by Ava and eivl. + """ + with ctx.typing(): + if name is None: + name = await Snake.random() + + if isinstance(name, dict): + data = name + else: + data = await self._get_snek(name) + + if data.get('error'): + return await ctx.send('Could not fetch data from Wikipedia.') + + description = data["info"] + + # Shorten the description if needed + if len(description) > 1000: + description = description[:1000] + last_newline = description.rfind("\n") + if last_newline > 0: + description = description[:last_newline] + + # Strip and add the Wiki link. + if "fullurl" in data: + description = description.strip("\n") + description += f"\n\nRead more on [Wikipedia]({data['fullurl']})" + + # Build and send the embed. + embed = Embed( + title=data.get("title", data.get('name')), + description=description, + colour=0x59982F, + ) + + emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png' + image = next((url for url in data['image_list'] + if url.endswith(self.valid_image_extensions)), emoji) + embed.set_image(url=image) + + await ctx.send(embed=embed) + + @snakes_group.command(name='guess', aliases=('identify',)) + @locked() + async def guess_command(self, ctx: Context) -> None: + """ + Snake identifying game. + + Made by Ava and eivl. + Modified by lemon. + """ + with ctx.typing(): + + image = None + + while image is None: + snakes = [await Snake.random() for _ in range(4)] + snake = random.choice(snakes) + answer = "abcd"[snakes.index(snake)] + + data = await self._get_snek(snake) + + image = next((url for url in data['image_list'] + if url.endswith(self.valid_image_extensions)), None) + + embed = Embed( + title='Which of the following is the snake in the image?', + description="\n".join( + f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes), + colour=SNAKE_COLOR + ) + embed.set_image(url=image) + + guess = await ctx.send(embed=embed) + options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes} + await self._validate_answer(ctx, guess, answer, options) + + @snakes_group.command(name='hatch') + async def hatch_command(self, ctx: Context) -> None: + """ + Hatches your personal snake. + + Written by Momo and kel. + """ + # Pick a random snake to hatch. + snake_name = random.choice(list(utils.snakes.keys())) + snake_image = utils.snakes[snake_name] + + # Hatch the snake + message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:...")) + await asyncio.sleep(1) + + for stage in utils.stages: + hatch_embed = Embed(description=stage) + await message.edit(embed=hatch_embed) + await asyncio.sleep(1) + await asyncio.sleep(1) + await message.delete() + + # Build and send the embed. + my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name)) + my_snake_embed.set_thumbnail(url=snake_image) + my_snake_embed.set_footer( + text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator) + ) + + await ctx.channel.send(embed=my_snake_embed) + + @snakes_group.command(name='movie') + async def movie_command(self, ctx: Context) -> None: + """ + Gets a random snake-related movie from OMDB. + + Written by Samuel. + Modified by gdude. + """ + url = "http://www.omdbapi.com/" + page = random.randint(1, 27) + + response = await self.bot.http_session.get( + url, + params={ + "s": "snake", + "page": page, + "type": "movie", + "apikey": Tokens.omdb + } + ) + data = await response.json() + movie = random.choice(data["Search"])["imdbID"] + + response = await self.bot.http_session.get( + url, + params={ + "i": movie, + "apikey": Tokens.omdb + } + ) + data = await response.json() + + embed = Embed( + title=data["Title"], + color=SNAKE_COLOR + ) + + del data["Response"], data["imdbID"], data["Title"] + + for key, value in data.items(): + if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"): + continue + + if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}] + rating = random.choice(value) + + if rating["Source"] != "Internet Movie Database": + embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"]) + + continue + + if key == "Poster": + embed.set_image(url=value) + continue + + elif key == "imdbRating": + key = "IMDB Rating" + + elif key == "imdbVotes": + key = "IMDB Votes" + + embed.add_field(name=key, value=value, inline=True) + + embed.set_footer(text="Data provided by the OMDB API") + + await ctx.channel.send( + embed=embed + ) + + @snakes_group.command(name='quiz') + @locked() + async def quiz_command(self, ctx: Context) -> None: + """ + Asks a snake-related question in the chat and validates the user's guess. + + This was created by Mushy and Cardium, + and modified by Urthas and lemon. + """ + # Prepare a question. + question = random.choice(self.snake_quizzes) + answer = question["answerkey"] + options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()} + + # Build and send the embed. + embed = Embed( + color=SNAKE_COLOR, + title=question["question"], + description="\n".join( + [f"**{key.upper()}**: {answer}" for key, answer in options.items()] + ) + ) + + quiz = await ctx.channel.send("", embed=embed) + await self._validate_answer(ctx, quiz, answer, options) + + @snakes_group.command(name='name', aliases=('name_gen',)) + async def name_command(self, ctx: Context, *, name: str = None) -> None: + """ + Snakifies a username. + + Slices the users name at the last vowel (or second last if the name + ends with a vowel), and then combines it with a random snake name, + which is sliced at the first vowel (or second if the name starts with + a vowel). + + If the name contains no vowels, it just appends the snakename + to the end of the name. + + Examples: + lemon + anaconda = lemoconda + krzsn + anaconda = krzsnconda + gdude + anaconda = gduconda + aperture + anaconda = apertuconda + lucy + python = luthon + joseph + taipan = joseipan + + This was written by Iceman, and modified for inclusion into the bot by lemon. + """ + snake_name = await self._get_snake_name() + snake_name = snake_name['name'] + snake_prefix = "" + + # Set aside every word in the snake name except the last. + if " " in snake_name: + snake_prefix = " ".join(snake_name.split()[:-1]) + snake_name = snake_name.split()[-1] + + # If no name is provided, use whoever called the command. + if name: + user_name = name + else: + user_name = ctx.author.display_name + + # Get the index of the vowel to slice the username at + user_slice_index = len(user_name) + for index, char in enumerate(reversed(user_name)): + if index == 0: + continue + if char.lower() in "aeiouy": + user_slice_index -= index + break + + # Now, get the index of the vowel to slice the snake_name at + snake_slice_index = 0 + for index, char in enumerate(snake_name): + if index == 0: + continue + if char.lower() in "aeiouy": + snake_slice_index = index + 1 + break + + # Combine! + snake_name = snake_name[snake_slice_index:] + user_name = user_name[:user_slice_index] + result = f"{snake_prefix} {user_name}{snake_name}" + result = string.capwords(result) + + # Embed and send + embed = Embed( + title="Snake name", + description=f"Your snake-name is **{result}**", + color=SNAKE_COLOR + ) + + return await ctx.send(embed=embed) + + @snakes_group.command(name='sal') + @locked() + async def sal_command(self, ctx: Context) -> None: + """ + Play a game of Snakes and Ladders. + + Written by Momo and kel. + Modified by lemon. + """ + # Check if there is already a game in this channel + if ctx.channel in self.active_sal: + await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") + return + + game = utils.SnakeAndLaddersGame(snakes=self, context=ctx) + self.active_sal[ctx.channel] = game + + await game.open_game() + + @snakes_group.command(name='about') + async def about_command(self, ctx: Context) -> None: + """Show an embed with information about the event, its participants, and its winners.""" + contributors = [ + "<@!245270749919576066>", + "<@!396290259907903491>", + "<@!172395097705414656>", + "<@!361708843425726474>", + "<@!300302216663793665>", + "<@!210248051430916096>", + "<@!174588005745557505>", + "<@!87793066227822592>", + "<@!211619754039967744>", + "<@!97347867923976192>", + "<@!136081839474343936>", + "<@!263560579770220554>", + "<@!104749643715387392>", + "<@!303940835005825024>", + ] + + embed = Embed( + title="About the snake cog", + description=( + "The features in this cog were created by members of the community " + "during our first ever [code jam event](https://gitlab.com/discord-python/code-jams/code-jam-1). \n\n" + "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over " + "48 hours. The staff then selected the best features from all the best teams, and made modifications " + "to ensure they would all work together before integrating them into the community bot.\n\n" + "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> " + "walked away as grand champions. Make sure you check out `!snakes sal`, `!snakes draw` " + "and `!snakes hatch` to see what they came up with." + ) + ) + + embed.add_field( + name="Contributors", + value=( + ", ".join(contributors) + ) + ) + + await ctx.channel.send(embed=embed) + + @snakes_group.command(name='card') + async def card_command(self, ctx: Context, *, name: Snake = None) -> None: + """ + Create an interesting little card from a snake. + + Created by juan and Someone during the first code jam. + """ + # Get the snake data we need + if not name: + name_obj = await self._get_snake_name() + name = name_obj['scientific'] + content = await self._get_snek(name) + + elif isinstance(name, dict): + content = name + + else: + content = await self._get_snek(name) + + # Make the card + async with ctx.typing(): + + stream = BytesIO() + async with async_timeout.timeout(10): + async with self.bot.http_session.get(content['image_list'][0]) as response: + stream.write(await response.read()) + + stream.seek(0) + + func = partial(self._generate_card, stream, content) + final_buffer = await self.bot.loop.run_in_executor(None, func) + + # Send it! + await ctx.send( + f"A wild {content['name'].title()} appears!", + file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png") + ) + + @snakes_group.command(name='fact') + async def fact_command(self, ctx: Context) -> None: + """ + Gets a snake-related fact. + + Written by Andrew and Prithaj. + Modified by lemon. + """ + question = random.choice(self.snake_facts)["fact"] + embed = Embed( + title="Snake fact", + color=SNAKE_COLOR, + description=question + ) + await ctx.channel.send(embed=embed) + + @snakes_group.command(name='snakify') + async def snakify_command(self, ctx: Context, *, message: str = None) -> None: + """ + How would I talk if I were a snake? + + If `message` is passed, the bot will snakify the message. + Otherwise, a random message from the user's history is snakified. + + Written by Momo and kel. + Modified by lemon. + """ + with ctx.typing(): + embed = Embed() + user = ctx.message.author + + if not message: + + # Get a random message from the users history + messages = [] + async for message in ctx.channel.history(limit=500).filter( + lambda msg: msg.author == ctx.message.author # Message was sent by author. + ): + messages.append(message.content) + + message = self._get_random_long_message(messages) + + # Set the avatar + if user.avatar is not None: + avatar = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}" + else: + avatar = ctx.author.default_avatar_url + + # Build and send the embed + embed.set_author( + name=f"{user.name}#{user.discriminator}", + icon_url=avatar, + ) + embed.description = f"*{self._snakify(message)}*" + + await ctx.channel.send(embed=embed) + + @snakes_group.command(name='video', aliases=('get_video',)) + async def video_command(self, ctx: Context, *, search: str = None) -> None: + """ + Gets a YouTube video about snakes. + + If `search` is given, a snake with that name will be searched on Youtube. + + Written by Andrew and Prithaj. + """ + # Are we searching for anything specific? + if search: + query = search + ' snake' + else: + snake = await self._get_snake_name() + query = snake['name'] + + # Build the URL and make the request + url = f'https://www.googleapis.com/youtube/v3/search' + response = await self.bot.http_session.get( + url, + params={ + "part": "snippet", + "q": urllib.parse.quote(query), + "type": "video", + "key": Tokens.youtube + } + ) + response = await response.json() + data = response['items'] + + # Send the user a video + if len(data) > 0: + num = random.randint(0, len(data) - 1) + youtube_base_url = 'https://www.youtube.com/watch?v=' + await ctx.channel.send( + content=f"{youtube_base_url}{data[num]['id']['videoId']}" + ) + else: + log.warning(f"YouTube API error. Full response looks like {response}") + + @snakes_group.command(name='zen') + async def zen_command(self, ctx: Context) -> None: + """ + Gets a random quote from the Zen of Python, except as if spoken by a snake. + + Written by Prithaj and Andrew. + Modified by lemon. + """ + embed = Embed( + title="Zzzen of Pythhon", + color=SNAKE_COLOR + ) + + # Get the zen quote and snakify it + zen_quote = random.choice(ZEN.splitlines()) + zen_quote = self._snakify(zen_quote) + + # Embed and send + embed.description = zen_quote + await ctx.channel.send( + embed=embed + ) + # endregion + + # region: Error handlers + @get_command.error + @card_command.error + @video_command.error + async def command_error(self, ctx: Context, error: CommandError) -> None: + """Local error handler for the Snake Cog.""" + embed = Embed() + embed.colour = Colour.red() + + if isinstance(error, BadArgument): + embed.description = str(error) + embed.title = random.choice(ERROR_REPLIES) + + elif isinstance(error, OSError): + log.error(f"snake_card encountered an OSError: {error} ({error.original})") + embed.description = "Could not generate the snake card! Please try again." + embed.title = random.choice(ERROR_REPLIES) + + else: + log.error(f"Unhandled tag command error: {error} ({error.original})") + return + + await ctx.send(embed=embed) + # endregion diff --git a/bot/exts/evergreen/snakes/utils.py b/bot/exts/evergreen/snakes/utils.py new file mode 100644 index 00000000..7d6caf04 --- /dev/null +++ b/bot/exts/evergreen/snakes/utils.py @@ -0,0 +1,716 @@ +import asyncio +import io +import json +import logging +import math +import random +from itertools import product +from pathlib import Path +from typing import List, Tuple + +from PIL import Image +from PIL.ImageDraw import ImageDraw +from discord import File, Member, Reaction +from discord.ext.commands import Cog, Context + +from bot.constants import Roles + +SNAKE_RESOURCES = Path("bot/resources/snakes").absolute() + +h1 = r'''``` + ---- + ------ + /--------\ + |--------| + |--------| + \------/ + ----```''' +h2 = r'''``` + ---- + ------ + /---\-/--\ + |-----\--| + |--------| + \------/ + ----```''' +h3 = r'''``` + ---- + ------ + /---\-/--\ + |-----\--| + |-----/--| + \----\-/ + ----```''' +h4 = r'''``` + ----- + ----- \ + /--| /---\ + |--\ -\---| + |--\--/-- / + \------- / + ------```''' +stages = [h1, h2, h3, h4] +snakes = { + "Baby Python": "https://i.imgur.com/SYOcmSa.png", + "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png", + "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png", + "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png", + "Baby Cobra": "https://i.imgur.com/jk14ryt.png" +} + +BOARD_TILE_SIZE = 56 # the size of each board tile +BOARD_PLAYER_SIZE = 20 # the size of each player icon +BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons) +# The size of the image to download +# Should a power of 2 and higher than BOARD_PLAYER_SIZE +PLAYER_ICON_IMAGE_SIZE = 32 +MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board + +# board definition (from, to) +BOARD = { + # ladders + 2: 38, + 7: 14, + 8: 31, + 15: 26, + 21: 42, + 28: 84, + 36: 44, + 51: 67, + 71: 91, + 78: 98, + 87: 94, + + # snakes + 99: 80, + 95: 75, + 92: 88, + 89: 68, + 74: 53, + 64: 60, + 62: 19, + 49: 11, + 46: 25, + 16: 6 +} + +DEFAULT_SNAKE_COLOR: int = 0x15c7ea +DEFAULT_BACKGROUND_COLOR: int = 0 +DEFAULT_IMAGE_DIMENSIONS: Tuple[int] = (200, 200) +DEFAULT_SNAKE_LENGTH: int = 22 +DEFAULT_SNAKE_WIDTH: int = 8 +DEFAULT_SEGMENT_LENGTH_RANGE: Tuple[int] = (7, 10) +DEFAULT_IMAGE_MARGINS: Tuple[int] = (50, 50) +DEFAULT_TEXT: str = "snek\nit\nup" +DEFAULT_TEXT_POSITION: Tuple[int] = ( + 10, + 10 +) +DEFAULT_TEXT_COLOR: int = 0xf2ea15 +X = 0 +Y = 1 +ANGLE_RANGE = math.pi * 2 + + +def get_resource(file: str) -> List[dict]: + """Load Snake resources JSON.""" + with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile: + return json.load(snakefile) + + +def smoothstep(t: float) -> float: + """Smooth curve with a zero derivative at 0 and 1, making it useful for interpolating.""" + return t * t * (3. - 2. * t) + + +def lerp(t: float, a: float, b: float) -> float: + """Linear interpolation between a and b, given a fraction t.""" + return a + t * (b - a) + + +class PerlinNoiseFactory(object): + """ + Callable that produces Perlin noise for an arbitrary point in an arbitrary number of dimensions. + + The underlying grid is aligned with the integers. + + There is no limit to the coordinates used; new gradients are generated on the fly as necessary. + + Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 + Licensed under ISC + """ + + def __init__(self, dimension: int, octaves: int = 1, tile: Tuple[int] = (), unbias: bool = False): + """ + Create a new Perlin noise factory in the given number of dimensions. + + dimension should be an integer and at least 1. + + More octaves create a foggier and more-detailed noise pattern. More than 4 octaves is rather excessive. + + ``tile`` can be used to make a seamlessly tiling pattern. + For example: + pnf = PerlinNoiseFactory(2, tile=(0, 3)) + + This will produce noise that tiles every 3 units vertically, but never tiles horizontally. + + If ``unbias`` is True, the smoothstep function will be applied to the output before returning + it, to counteract some of Perlin noise's significant bias towards the center of its output range. + """ + self.dimension = dimension + self.octaves = octaves + self.tile = tile + (0,) * dimension + self.unbias = unbias + + # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply + # by this to scale to ±1 + self.scale_factor = 2 * dimension ** -0.5 + + self.gradient = {} + + def _generate_gradient(self) -> Tuple[float, ...]: + """ + Generate a random unit vector at each grid point. + + This is the "gradient" vector, in that the grid tile slopes towards it + """ + # 1 dimension is special, since the only unit vector is trivial; + # instead, use a slope between -1 and 1 + if self.dimension == 1: + return (random.uniform(-1, 1),) + + # Generate a random point on the surface of the unit n-hypersphere; + # this is the same as a random unit vector in n dimensions. Thanks + # to: http://mathworld.wolfram.com/SpherePointPicking.html + # Pick n normal random variables with stddev 1 + random_point = [random.gauss(0, 1) for _ in range(self.dimension)] + # Then scale the result to a unit vector + scale = sum(n * n for n in random_point) ** -0.5 + return tuple(coord * scale for coord in random_point) + + def get_plain_noise(self, *point) -> float: + """Get plain noise for a single point, without taking into account either octaves or tiling.""" + if len(point) != self.dimension: + raise ValueError("Expected {0} values, got {1}".format( + self.dimension, len(point))) + + # Build a list of the (min, max) bounds in each dimension + grid_coords = [] + for coord in point: + min_coord = math.floor(coord) + max_coord = min_coord + 1 + grid_coords.append((min_coord, max_coord)) + + # Compute the dot product of each gradient vector and the point's + # distance from the corresponding grid point. This gives you each + # gradient's "influence" on the chosen point. + dots = [] + for grid_point in product(*grid_coords): + if grid_point not in self.gradient: + self.gradient[grid_point] = self._generate_gradient() + gradient = self.gradient[grid_point] + + dot = 0 + for i in range(self.dimension): + dot += gradient[i] * (point[i] - grid_point[i]) + dots.append(dot) + + # Interpolate all those dot products together. The interpolation is + # done with smoothstep to smooth out the slope as you pass from one + # grid cell into the next. + # Due to the way product() works, dot products are ordered such that + # the last dimension alternates: (..., min), (..., max), etc. So we + # can interpolate adjacent pairs to "collapse" that last dimension. Then + # the results will alternate in their second-to-last dimension, and so + # forth, until we only have a single value left. + dim = self.dimension + while len(dots) > 1: + dim -= 1 + s = smoothstep(point[dim] - grid_coords[dim][0]) + + next_dots = [] + while dots: + next_dots.append(lerp(s, dots.pop(0), dots.pop(0))) + + dots = next_dots + + return dots[0] * self.scale_factor + + def __call__(self, *point) -> float: + """ + Get the value of this Perlin noise function at the given point. + + The number of values given should match the number of dimensions. + """ + ret = 0 + for o in range(self.octaves): + o2 = 1 << o + new_point = [] + for i, coord in enumerate(point): + coord *= o2 + if self.tile[i]: + coord %= self.tile[i] * o2 + new_point.append(coord) + ret += self.get_plain_noise(*new_point) / o2 + + # Need to scale n back down since adding all those extra octaves has + # probably expanded it beyond ±1 + # 1 octave: ±1 + # 2 octaves: ±1½ + # 3 octaves: ±1¾ + ret /= 2 - 2 ** (1 - self.octaves) + + if self.unbias: + # The output of the plain Perlin noise algorithm has a fairly + # strong bias towards the center due to the central limit theorem + # -- in fact the top and bottom 1/8 virtually never happen. That's + # a quarter of our entire output range! If only we had a function + # in [0..1] that could introduce a bias towards the endpoints... + r = (ret + 1) / 2 + # Doing it this many times is a completely made-up heuristic. + for _ in range(int(self.octaves / 2 + 0.5)): + r = smoothstep(r) + ret = r * 2 - 1 + + return ret + + +def create_snek_frame( + perlin_factory: PerlinNoiseFactory, perlin_lookup_vertical_shift: float = 0, + image_dimensions: Tuple[int] = DEFAULT_IMAGE_DIMENSIONS, image_margins: Tuple[int] = DEFAULT_IMAGE_MARGINS, + snake_length: int = DEFAULT_SNAKE_LENGTH, + snake_color: int = DEFAULT_SNAKE_COLOR, bg_color: int = DEFAULT_BACKGROUND_COLOR, + segment_length_range: Tuple[int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH, + text: str = DEFAULT_TEXT, text_position: Tuple[int] = DEFAULT_TEXT_POSITION, + text_color: Tuple[int] = DEFAULT_TEXT_COLOR +) -> Image: + """ + Creates a single random snek frame using Perlin noise. + + `perlin_lookup_vertical_shift` represents the Perlin noise shift in the Y-dimension for this frame. + If `text` is given, display the given text with the snek. + """ + start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) + start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) + points = [(start_x, start_y)] + + for index in range(0, snake_length): + angle = perlin_factory.get_plain_noise( + ((1 / (snake_length + 1)) * (index + 1)) + perlin_lookup_vertical_shift + ) * ANGLE_RANGE + current_point = points[index] + segment_length = random.randint(segment_length_range[0], segment_length_range[1]) + points.append(( + current_point[X] + segment_length * math.cos(angle), + current_point[Y] + segment_length * math.sin(angle) + )) + + # normalize bounds + min_dimensions = [start_x, start_y] + max_dimensions = [start_x, start_y] + for point in points: + min_dimensions[X] = min(point[X], min_dimensions[X]) + min_dimensions[Y] = min(point[Y], min_dimensions[Y]) + max_dimensions[X] = max(point[X], max_dimensions[X]) + max_dimensions[Y] = max(point[Y], max_dimensions[Y]) + + # shift towards middle + dimension_range = (max_dimensions[X] - min_dimensions[X], max_dimensions[Y] - min_dimensions[Y]) + shift = ( + image_dimensions[X] / 2 - (dimension_range[X] / 2 + min_dimensions[X]), + image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y]) + ) + + image = Image.new(mode='RGB', size=image_dimensions, color=bg_color) + draw = ImageDraw(image) + for index in range(1, len(points)): + point = points[index] + previous = points[index - 1] + draw.line( + ( + shift[X] + previous[X], + shift[Y] + previous[Y], + shift[X] + point[X], + shift[Y] + point[Y] + ), + width=snake_width, + fill=snake_color + ) + if text is not None: + draw.multiline_text(text_position, text, fill=text_color) + del draw + return image + + +def frame_to_png_bytes(image: Image) -> io.BytesIO: + """Convert image to byte stream.""" + stream = io.BytesIO() + image.save(stream, format='PNG') + stream.seek(0) + return stream + + +log = logging.getLogger(__name__) +START_EMOJI = "\u2611" # :ballot_box_with_check: - Start the game +CANCEL_EMOJI = "\u274C" # :x: - Cancel or leave the game +ROLL_EMOJI = "\U0001F3B2" # :game_die: - Roll the die! +JOIN_EMOJI = "\U0001F64B" # :raising_hand: - Join the game. +STARTUP_SCREEN_EMOJI = [ + JOIN_EMOJI, + START_EMOJI, + CANCEL_EMOJI +] +GAME_SCREEN_EMOJI = [ + ROLL_EMOJI, + CANCEL_EMOJI +] + + +class SnakeAndLaddersGame: + """Snakes and Ladders game Cog.""" + + def __init__(self, snakes: Cog, context: Context): + self.snakes = snakes + self.ctx = context + self.channel = self.ctx.channel + self.state = 'booting' + self.started = False + self.author = self.ctx.author + self.players = [] + self.player_tiles = {} + self.round_has_rolled = {} + self.avatar_images = {} + self.board = None + self.positions = None + self.rolls = [] + + async def open_game(self) -> None: + """ + Create a new Snakes and Ladders game. + + Listen for reactions until players have joined, and the game has been started. + """ + def startup_event_check(reaction_: Reaction, user_: Member) -> bool: + """Make sure that this reaction is what we want to operate on.""" + return ( + all(( + reaction_.message.id == startup.id, # Reaction is on startup message + reaction_.emoji in STARTUP_SCREEN_EMOJI, # Reaction is one of the startup emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + # Check to see if the bot can remove reactions + if not self.channel.permissions_for(self.ctx.guild.me).manage_messages: + log.warning( + "Unable to start Snakes and Ladders - " + f"Missing manage_messages permissions in {self.channel}" + ) + return + + await self._add_player(self.author) + await self.channel.send( + "**Snakes and Ladders**: A new game is about to start!", + file=File( + str(SNAKE_RESOURCES / "snakes_and_ladders" / "banner.jpg"), + filename='Snakes and Ladders.jpg' + ) + ) + startup = await self.channel.send( + f"Press {JOIN_EMOJI} to participate, and press " + f"{START_EMOJI} to start the game" + ) + for emoji in STARTUP_SCREEN_EMOJI: + await startup.add_reaction(emoji) + + self.state = 'waiting' + + while not self.started: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=startup_event_check + ) + if reaction.emoji == JOIN_EMOJI: + await self.player_join(user) + elif reaction.emoji == CANCEL_EMOJI: + if user == self.author or (self._is_moderator(user) and user not in self.players): + # Allow game author or non-playing moderation staff to cancel a waiting game + await self.cancel_game() + return + else: + await self.player_leave(user) + elif reaction.emoji == START_EMOJI: + if self.ctx.author == user: + self.started = True + await self.start_game(user) + await startup.delete() + break + + await startup.remove_reaction(reaction.emoji, user) + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + await self.cancel_game() + return # We're done, no reactions for the last 5 minutes + + async def _add_player(self, user: Member) -> None: + """Add player to game.""" + self.players.append(user) + self.player_tiles[user.id] = 1 + + avatar_bytes = await user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE).read() + im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) + self.avatar_images[user.id] = im + + async def player_join(self, user: Member) -> None: + """ + Handle players joining the game. + + Prevent player joining if they have already joined, if the game is full, or if the game is + in a waiting state. + """ + for p in self.players: + if user == p: + await self.channel.send(user.mention + " You are already in the game.", delete_after=10) + return + if self.state != 'waiting': + await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10) + return + if len(self.players) is MAX_PLAYERS: + await self.channel.send(user.mention + " The game is full!", delete_after=10) + return + + await self._add_player(user) + + await self.channel.send( + f"**Snakes and Ladders**: {user.mention} has joined the game.\n" + f"There are now {str(len(self.players))} players in the game.", + delete_after=10 + ) + + async def player_leave(self, user: Member) -> bool: + """ + Handle players leaving the game. + + Leaving is prevented if the user wasn't part of the game. + + If the number of players reaches 0, the game is terminated. In this case, a sentinel boolean + is returned True to prevent a game from continuing after it's destroyed. + """ + is_surrendered = False # Sentinel value to assist with stopping a surrendered game + for p in self.players: + if user == p: + self.players.remove(p) + self.player_tiles.pop(p.id, None) + self.round_has_rolled.pop(p.id, None) + await self.channel.send( + "**Snakes and Ladders**: " + user.mention + " has left the game.", + delete_after=10 + ) + + if self.state != 'waiting' and len(self.players) == 0: + await self.channel.send("**Snakes and Ladders**: The game has been surrendered!") + is_surrendered = True + self._destruct() + + return is_surrendered + else: + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + return is_surrendered + + async def cancel_game(self) -> None: + """Cancel the running game.""" + await self.channel.send("**Snakes and Ladders**: Game has been canceled.") + self._destruct() + + async def start_game(self, user: Member) -> None: + """ + Allow the game author to begin the game. + + The game cannot be started if the game is in a waiting state. + """ + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) + return + + if not self.state == 'waiting': + await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10) + return + + self.state = 'starting' + player_list = ', '.join(user.mention for user in self.players) + await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list) + await self.start_round() + + async def start_round(self) -> None: + """Begin the round.""" + def game_event_check(reaction_: Reaction, user_: Member) -> bool: + """Make sure that this reaction is what we want to operate on.""" + return ( + all(( + reaction_.message.id == self.positions.id, # Reaction is on positions message + reaction_.emoji in GAME_SCREEN_EMOJI, # Reaction is one of the game emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + self.state = 'roll' + for user in self.players: + self.round_has_rolled[user.id] = False + board_img = Image.open(str(SNAKE_RESOURCES / "snakes_and_ladders" / "board.jpg")) + player_row_size = math.ceil(MAX_PLAYERS / 2) + + for i, player in enumerate(self.players): + tile = self.player_tiles[player.id] + tile_coordinates = self._board_coordinate_from_index(tile) + x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE + y_offset = \ + BOARD_MARGIN[1] + ( + (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE) + x_offset += BOARD_PLAYER_SIZE * (i % player_row_size) + y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size) + board_img.paste(self.avatar_images[player.id], + box=(x_offset, y_offset)) + + board_file = File(frame_to_png_bytes(board_img), filename='Board.jpg') + player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) + + # Store and send new messages + temp_board = await self.channel.send( + "**Snakes and Ladders**: A new round has started! Current board:", + file=board_file + ) + temp_positions = await self.channel.send( + f"**Current positions**:\n{player_list}\n\nUse {ROLL_EMOJI} to roll the dice!" + ) + + # Delete the previous messages + if self.board and self.positions: + await self.board.delete() + await self.positions.delete() + + # remove the roll messages + for roll in self.rolls: + await roll.delete() + self.rolls = [] + + # Save new messages + self.board = temp_board + self.positions = temp_positions + + # Wait for rolls + for emoji in GAME_SCREEN_EMOJI: + await self.positions.add_reaction(emoji) + + is_surrendered = False + while True: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=game_event_check + ) + + if reaction.emoji == ROLL_EMOJI: + await self.player_roll(user) + elif reaction.emoji == CANCEL_EMOJI: + if self._is_moderator(user) and user not in self.players: + # Only allow non-playing moderation staff to cancel a running game + await self.cancel_game() + return + else: + is_surrendered = await self.player_leave(user) + + await self.positions.remove_reaction(reaction.emoji, user) + + if self._check_all_rolled(): + break + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + await self.cancel_game() + return # We're done, no reactions for the last 5 minutes + + # Round completed + # Check to see if the game was surrendered before completing the round, without this + # sentinel, the game object would be deleted but the next round still posted into purgatory + if not is_surrendered: + await self._complete_round() + + async def player_roll(self, user: Member) -> None: + """Handle the player's roll.""" + if user.id not in self.player_tiles: + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + return + if self.state != 'roll': + await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10) + return + if self.round_has_rolled[user.id]: + return + roll = random.randint(1, 6) + self.rolls.append(await self.channel.send(f"{user.mention} rolled a **{roll}**!")) + next_tile = self.player_tiles[user.id] + roll + + # apply snakes and ladders + if next_tile in BOARD: + target = BOARD[next_tile] + if target < next_tile: + await self.channel.send( + f"{user.mention} slips on a snake and falls back to **{target}**", + delete_after=15 + ) + else: + await self.channel.send( + f"{user.mention} climbs a ladder to **{target}**", + delete_after=15 + ) + next_tile = target + + self.player_tiles[user.id] = min(100, next_tile) + self.round_has_rolled[user.id] = True + + async def _complete_round(self) -> None: + """At the conclusion of a round check to see if there's been a winner.""" + self.state = 'post_round' + + # check for winner + winner = self._check_winner() + if winner is None: + # there is no winner, start the next round + await self.start_round() + return + + # announce winner and exit + await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") + self._destruct() + + def _check_winner(self) -> Member: + """Return a winning member if we're in the post-round state and there's a winner.""" + if self.state != 'post_round': + return None + return next((player for player in self.players if self.player_tiles[player.id] == 100), + None) + + def _check_all_rolled(self) -> bool: + """Check if all members have made their roll.""" + return all(rolled for rolled in self.round_has_rolled.values()) + + def _destruct(self) -> None: + """Clean up the finished game object.""" + del self.snakes.active_sal[self.channel] + + def _board_coordinate_from_index(self, index: int) -> Tuple[int, int]: + """Convert the tile number to the x/y coordinates for graphical purposes.""" + y_level = 9 - math.floor((index - 1) / 10) + is_reversed = math.floor((index - 1) / 10) % 2 != 0 + x_level = (index - 1) % 10 + if is_reversed: + x_level = 9 - x_level + return x_level, y_level + + @staticmethod + def _is_moderator(user: Member) -> bool: + """Return True if the user is a Moderator.""" + return any(Roles.moderator == role.id for role in user.roles) diff --git a/bot/exts/evergreen/speedrun.py b/bot/exts/evergreen/speedrun.py new file mode 100644 index 00000000..76c5e8d3 --- /dev/null +++ b/bot/exts/evergreen/speedrun.py @@ -0,0 +1,28 @@ +import json +import logging +from pathlib import Path +from random import choice + +from discord.ext import commands + +log = logging.getLogger(__name__) +with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf-8") as file: + LINKS = json.load(file) + + +class Speedrun(commands.Cog): + """Commands about the video game speedrunning community.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(name="speedrun") + async def get_speedrun(self, ctx: commands.Context) -> None: + """Sends a link to a video of a random speedrun.""" + await ctx.send(choice(LINKS)) + + +def setup(bot: commands.Bot) -> None: + """Load the Speedrun cog.""" + bot.add_cog(Speedrun(bot)) + log.info("Speedrun cog loaded") diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py new file mode 100644 index 00000000..99b64497 --- /dev/null +++ b/bot/exts/evergreen/trivia_quiz.py @@ -0,0 +1,303 @@ +import asyncio +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands +from fuzzywuzzy import fuzz + +from bot.constants import Roles + + +logger = logging.getLogger(__name__) + + +WRONG_ANS_RESPONSE = [ + "No one answered correctly!", + "Better luck next time" +] + + +class TriviaQuiz(commands.Cog): + """A cog for all quiz commands.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.questions = self.load_questions() + self.game_status = {} # A variable to store the game status: either running or not running. + self.game_owners = {} # A variable to store the person's ID who started the quiz game in a channel. + self.question_limit = 4 + self.player_scores = {} # A variable to store all player's scores for a bot session. + self.game_player_scores = {} # A variable to store temporary game player's scores. + self.categories = { + "general": "Test your general knowledge" + # "retro": "Questions related to retro gaming." + } + + @staticmethod + def load_questions() -> dict: + """Load the questions from the JSON file.""" + p = Path("bot", "resources", "evergreen", "trivia_quiz.json") + with p.open() as json_data: + questions = json.load(json_data) + return questions + + @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) + async def quiz_game(self, ctx: commands.Context, category: str = None) -> None: + """ + Start a quiz! + + Questions for the quiz can be selected from the following categories: + - general : Test your general knowledge. (default) + (More to come!) + """ + if ctx.channel.id not in self.game_status: + self.game_status[ctx.channel.id] = False + + if ctx.channel.id not in self.game_player_scores: + self.game_player_scores[ctx.channel.id] = {} + + # Stop game if running. + if self.game_status[ctx.channel.id] is True: + return await ctx.send( + f"Game is already running..." + f"do `{self.bot.command_prefix}quiz stop`" + ) + + # Send embed showing available categories if inputted category is invalid. + if category is None: + category = random.choice(list(self.categories)) + + category = category.lower() + if category not in self.categories: + embed = self.category_embed() + await ctx.send(embed=embed) + return + + # Start game if not running. + if self.game_status[ctx.channel.id] is False: + self.game_owners[ctx.channel.id] = ctx.author + self.game_status[ctx.channel.id] = True + start_embed = self.make_start_embed(category) + + await ctx.send(embed=start_embed) # send an embed with the rules + await asyncio.sleep(1) + + topic = self.questions[category] + + done_question = [] + hint_no = 0 + answer = None + while self.game_status[ctx.channel.id]: + # Exit quiz if number of questions for a round are already sent. + if len(done_question) > self.question_limit and hint_no == 0: + await ctx.send("The round has ended.") + await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) + + self.game_status[ctx.channel.id] = False + del self.game_owners[ctx.channel.id] + self.game_player_scores[ctx.channel.id] = {} + + break + + # If no hint has been sent or any time alert. Basically if hint_no = 0 means it is a new question. + if hint_no == 0: + # Select a random question which has not been used yet. + while True: + question_dict = random.choice(topic) + if question_dict["id"] not in done_question: + done_question.append(question_dict["id"]) + break + + q = question_dict["question"] + answer = question_dict["answer"] + + embed = discord.Embed(colour=discord.Colour.gold()) + embed.title = f"Question #{len(done_question)}" + embed.description = q + await ctx.send(embed=embed) # Send question embed. + + # A function to check whether user input is the correct answer(close to the right answer) + def check(m: discord.Message) -> bool: + ratio = fuzz.ratio(answer.lower(), m.content.lower()) + return ratio > 85 and m.channel == ctx.channel + + try: + msg = await self.bot.wait_for('message', check=check, timeout=10) + except asyncio.TimeoutError: + # In case of TimeoutError and the game has been stopped, then do nothing. + if self.game_status[ctx.channel.id] is False: + break + + # if number of hints sent or time alerts sent is less than 2, then send one. + if hint_no < 2: + hint_no += 1 + if "hints" in question_dict: + hints = question_dict["hints"] + await ctx.send(f"**Hint #{hint_no+1}\n**{hints[hint_no]}") + else: + await ctx.send(f"{30 - hint_no * 10}s left!") + + # Once hint or time alerts has been sent 2 times, the hint_no value will be 3 + # If hint_no > 2, then it means that all hints/time alerts have been sent. + # Also means that the answer is not yet given and the bot sends the answer and the next question. + else: + if self.game_status[ctx.channel.id] is False: + break + + response = random.choice(WRONG_ANS_RESPONSE) + await ctx.send(response) + await self.send_answer(ctx.channel, question_dict) + await asyncio.sleep(1) + + hint_no = 0 # init hint_no = 0 so that 2 hints/time alerts can be sent for the new question. + + await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) + await asyncio.sleep(2) + else: + if self.game_status[ctx.channel.id] is False: + break + + # Reduce points by 25 for every hint/time alert that has been sent. + points = 100 - 25*hint_no + if msg.author in self.game_player_scores[ctx.channel.id]: + self.game_player_scores[ctx.channel.id][msg.author] += points + else: + self.game_player_scores[ctx.channel.id][msg.author] = points + + # Also updating the overall scoreboard. + if msg.author in self.player_scores: + self.player_scores[msg.author] += points + else: + self.player_scores[msg.author] = points + + hint_no = 0 + + await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points!") + await self.send_answer(ctx.channel, question_dict) + await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) + await asyncio.sleep(2) + + @staticmethod + def make_start_embed(category: str) -> discord.Embed: + """Generate a starting/introduction embed for the quiz.""" + start_embed = discord.Embed(colour=discord.Colour.red()) + start_embed.title = "Quiz game Starting!!" + start_embed.description = "Each game consists of 5 questions.\n" + start_embed.description += "**Rules :**\nNo cheating and have fun!" + start_embed.description += f"\n **Category** : {category}" + start_embed.set_footer( + text="Points for each question reduces by 25 after 10s or after a hint. Total time is 30s per question" + ) + return start_embed + + @quiz_game.command(name="stop") + async def stop_quiz(self, ctx: commands.Context) -> None: + """ + Stop a quiz game if its running in the channel. + + Note: Only mods or the owner of the quiz can stop it. + """ + if self.game_status[ctx.channel.id] is True: + # Check if the author is the game starter or a moderator. + if ( + ctx.author == self.game_owners[ctx.channel.id] + or any(Roles.moderator == role.id for role in ctx.author.roles) + ): + await ctx.send("Quiz stopped.") + await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) + + self.game_status[ctx.channel.id] = False + del self.game_owners[ctx.channel.id] + self.game_player_scores[ctx.channel.id] = {} + else: + await ctx.send(f"{ctx.author.mention}, you are not authorised to stop this game :ghost:!") + else: + await ctx.send("No quiz running.") + + @quiz_game.command(name="leaderboard") + async def leaderboard(self, ctx: commands.Context) -> None: + """View everyone's score for this bot session.""" + await self.send_score(ctx.channel, self.player_scores) + + @staticmethod + async def send_score(channel: discord.TextChannel, player_data: dict) -> None: + """A function which sends the score.""" + if len(player_data) == 0: + await channel.send("No one has made it onto the leaderboard yet.") + return + + embed = discord.Embed(colour=discord.Colour.blue()) + embed.title = "Score Board" + embed.description = "" + + sorted_dict = sorted(player_data.items(), key=lambda a: a[1], reverse=True) + for item in sorted_dict: + embed.description += f"{item[0]} : {item[1]}\n" + + await channel.send(embed=embed) + + @staticmethod + async def declare_winner(channel: discord.TextChannel, player_data: dict) -> None: + """Announce the winner of the quiz in the game channel.""" + if player_data: + highest_points = max(list(player_data.values())) + no_of_winners = list(player_data.values()).count(highest_points) + + # Check if more than 1 player has highest points. + if no_of_winners > 1: + word = "You guys" + winners = [] + points_copy = list(player_data.values()).copy() + + for _ in range(no_of_winners): + index = points_copy.index(highest_points) + winners.append(list(player_data.keys())[index]) + points_copy[index] = 0 + + winners_mention = " ".join(winner.mention for winner in winners) + else: + word = "You" + author_index = list(player_data.values()).index(highest_points) + winner = list(player_data.keys())[author_index] + winners_mention = winner.mention + + await channel.send( + f"Congratulations {winners_mention} :tada: " + f"{word} have won this quiz game with a grand total of {highest_points} points!" + ) + + def category_embed(self) -> discord.Embed: + """Build an embed showing all available trivia categories.""" + embed = discord.Embed(colour=discord.Colour.blue()) + embed.title = "The available question categories are:" + embed.set_footer(text="If a category is not chosen, a random one will be selected.") + embed.description = "" + + for cat, description in self.categories.items(): + embed.description += f"**- {cat.capitalize()}**\n{description.capitalize()}\n" + + return embed + + @staticmethod + async def send_answer(channel: discord.TextChannel, question_dict: dict) -> None: + """Send the correct answer of a question to the game channel.""" + answer = question_dict["answer"] + info = question_dict["info"] + embed = discord.Embed(color=discord.Colour.red()) + embed.title = f"The correct answer is **{answer}**\n" + embed.description = "" + + if info != "": + embed.description += f"**Information**\n{info}\n\n" + + embed.description += "Let's move to the next question.\nRemaining questions: " + await channel.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Load the cog.""" + bot.add_cog(TriviaQuiz(bot)) + logger.debug("TriviaQuiz cog loaded") diff --git a/bot/exts/evergreen/uptime.py b/bot/exts/evergreen/uptime.py new file mode 100644 index 00000000..6f24f545 --- /dev/null +++ b/bot/exts/evergreen/uptime.py @@ -0,0 +1,34 @@ +import logging + +import arrow +from dateutil.relativedelta import relativedelta +from discord.ext import commands + +from bot import start_time + +log = logging.getLogger(__name__) + + +class Uptime(commands.Cog): + """A cog for posting the bot's uptime.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(name="uptime") + async def uptime(self, ctx: commands.Context) -> None: + """Responds with the uptime of the bot.""" + difference = relativedelta(start_time - arrow.utcnow()) + uptime_string = start_time.shift( + seconds=-difference.seconds, + minutes=-difference.minutes, + hours=-difference.hours, + days=-difference.days + ).humanize() + await ctx.send(f"I started up {uptime_string}.") + + +def setup(bot: commands.Bot) -> None: + """Uptime Cog load.""" + bot.add_cog(Uptime(bot)) + log.info("Uptime cog loaded") diff --git a/bot/exts/halloween/8ball.py b/bot/exts/halloween/8ball.py new file mode 100644 index 00000000..2e1c2804 --- /dev/null +++ b/bot/exts/halloween/8ball.py @@ -0,0 +1,34 @@ +import asyncio +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/halloween/responses.json"), "r", encoding="utf8") as f: + responses = json.load(f) + + +class SpookyEightBall(commands.Cog): + """Spooky Eightball answers.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(aliases=('spooky8ball',)) + async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None: + """Responds with a random response to a question.""" + choice = random.choice(responses['responses']) + msg = await ctx.send(choice[0]) + if len(choice) > 1: + await asyncio.sleep(random.randint(2, 5)) + await msg.edit(content=f"{choice[0]} \n{choice[1]}") + + +def setup(bot: commands.Bot) -> None: + """Spooky Eight Ball Cog Load.""" + bot.add_cog(SpookyEightBall(bot)) + log.info("SpookyEightBall cog loaded") diff --git a/bot/exts/halloween/__init__.py b/bot/exts/halloween/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py new file mode 100644 index 00000000..3f2b895e --- /dev/null +++ b/bot/exts/halloween/candy_collection.py @@ -0,0 +1,225 @@ +import functools +import json +import logging +import os +import random +from typing import List, Union + +import discord +from discord.ext import commands + +from bot.constants import Channels, Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +json_location = os.path.join("bot", "resources", "halloween", "candy_collection.json") + +# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy) +ADD_CANDY_REACTION_CHANCE = 20 # 5% +ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10% +ADD_SKULL_REACTION_CHANCE = 50 # 2% +ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% + + +class CandyCollection(commands.Cog): + """Candy collection game Cog.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + with open(json_location) as candy: + self.candy_json = json.load(candy) + self.msg_reacted = self.candy_json['msg_reacted'] + self.get_candyinfo = dict() + for userinfo in self.candy_json['records']: + userid = userinfo['userid'] + self.get_candyinfo[userid] = userinfo + + @in_month(Month.october) + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Randomly adds candy or skull reaction to non-bot messages in the Event channel.""" + # make sure its a human message + if message.author.bot: + return + # ensure it's hacktober channel + if message.channel.id != Channels.seasonalbot_commands: + return + + # do random check for skull first as it has the lower chance + if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1: + d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False} + self.msg_reacted.append(d) + return await message.add_reaction('\N{SKULL}') + # check for the candy chance next + if random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1: + d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False} + self.msg_reacted.append(d) + return await message.add_reaction('\N{CANDY}') + + @in_month(Month.october) + @commands.Cog.listener() + async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None: + """Add/remove candies from a person if the reaction satisfies criteria.""" + message = reaction.message + # check to ensure the reactor is human + if user.bot: + return + + # check to ensure it is in correct channel + if message.channel.id != Channels.seasonalbot_commands: + return + + # if its not a candy or skull, and it is one of 10 most recent messages, + # proceed to add a skull/candy with higher chance + if str(reaction.emoji) not in ('\N{SKULL}', '\N{CANDY}'): + if message.id in await self.ten_recent_msg(): + await self.reacted_msg_chance(message) + return + + for react in self.msg_reacted: + # check to see if the message id of a message we added a + # reaction to is in json file, and if nobody has won/claimed it yet + if react['msg_id'] == message.id and react['won'] is False: + react['user_reacted'] = user.id + react['won'] = True + try: + # if they have record/candies in json already it will do this + user_records = self.get_candyinfo[user.id] + if str(reaction.emoji) == '\N{CANDY}': + user_records['record'] += 1 + if str(reaction.emoji) == '\N{SKULL}': + if user_records['record'] <= 3: + user_records['record'] = 0 + lost = 'all of your' + else: + lost = random.randint(1, 3) + user_records['record'] -= lost + await self.send_spook_msg(message.author, message.channel, lost) + + except KeyError: + # otherwise it will raise KeyError so we need to add them to file + if str(reaction.emoji) == '\N{CANDY}': + print('ok') + d = {"userid": user.id, "record": 1} + self.candy_json['records'].append(d) + await self.remove_reactions(reaction) + + async def reacted_msg_chance(self, message: discord.Message) -> None: + """ + Randomly add a skull or candy reaction to a message if there is a reaction there already. + + This event has a higher probability of occurring than a reaction add to a message without an + existing reaction. + """ + if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1: + d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False} + self.msg_reacted.append(d) + return await message.add_reaction('\N{SKULL}') + + if random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1: + d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False} + self.msg_reacted.append(d) + return await message.add_reaction('\N{CANDY}') + + async def ten_recent_msg(self) -> List[int]: + """Get the last 10 messages sent in the channel.""" + ten_recent = [] + recent_msg_id = max( + message.id for message in self.bot._connection._messages + if message.channel.id == Channels.seasonalbot_commands + ) + + channel = await self.hacktober_channel() + ten_recent.append(recent_msg_id) + + for i in range(9): + o = discord.Object(id=recent_msg_id + i) + msg = await next(channel.history(limit=1, before=o)) + ten_recent.append(msg.id) + + return ten_recent + + async def get_message(self, msg_id: int) -> Union[discord.Message, None]: + """Get the message from its ID.""" + try: + o = discord.Object(id=msg_id + 1) + # Use history rather than get_message due to + # poor ratelimit (50/1s vs 1/1s) + msg = await next(self.hacktober_channel.history(limit=1, before=o)) + + if msg.id != msg_id: + return None + + return msg + + except Exception: + return None + + async def hacktober_channel(self) -> discord.TextChannel: + """Get #hacktoberbot channel from its ID.""" + return self.bot.get_channel(id=Channels.seasonalbot_commands) + + async def remove_reactions(self, reaction: discord.Reaction) -> None: + """Remove all candy/skull reactions.""" + try: + async for user in reaction.users(): + await reaction.message.remove_reaction(reaction.emoji, user) + + except discord.HTTPException: + pass + + async def send_spook_msg(self, author: discord.Member, channel: discord.TextChannel, candies: int) -> None: + """Send a spooky message.""" + e = discord.Embed(colour=author.colour) + e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; " + f"I took {candies} candies and quickly took flight.") + await channel.send(embed=e) + + def save_to_json(self) -> None: + """Save JSON to a local file.""" + with open(json_location, 'w') as outfile: + json.dump(self.candy_json, outfile) + + @in_month(Month.october) + @commands.command() + async def candy(self, ctx: commands.Context) -> None: + """Get the candy leaderboard and save to JSON.""" + # Use run_in_executor to prevent blocking + thing = functools.partial(self.save_to_json) + await self.bot.loop.run_in_executor(None, thing) + + emoji = ( + '\N{FIRST PLACE MEDAL}', + '\N{SECOND PLACE MEDAL}', + '\N{THIRD PLACE MEDAL}', + '\N{SPORTS MEDAL}', + '\N{SPORTS MEDAL}' + ) + + top_sorted = sorted(self.candy_json['records'], key=lambda k: k.get('record', 0), reverse=True) + top_five = top_sorted[:5] + + usersid = [] + records = [] + for record in top_five: + usersid.append(record['userid']) + records.append(record['record']) + + value = '\n'.join(f'{emoji[index]} <@{usersid[index]}>: {records[index]}' + for index in range(0, len(usersid))) or 'No Candies' + + e = discord.Embed(colour=discord.Colour.blurple()) + e.add_field(name="Top Candy Records", value=value, inline=False) + e.add_field(name='\u200b', + value=f"Candies will randomly appear on messages sent. " + f"\nHit the candy when it appears as fast as possible to get the candy! " + f"\nBut beware the ghosts...", + inline=False) + await ctx.send(embed=e) + + +def setup(bot: commands.Bot) -> None: + """Candy Collection game Cog load.""" + bot.add_cog(CandyCollection(bot)) + log.info("CandyCollection cog loaded") diff --git a/bot/exts/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py new file mode 100644 index 00000000..f15a665a --- /dev/null +++ b/bot/exts/halloween/hacktober-issue-finder.py @@ -0,0 +1,111 @@ +import datetime +import logging +import random +from typing import Dict, Optional + +import aiohttp +import discord +from discord.ext import commands + +from bot.constants import Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +URL = "https://api.github.com/search/issues?per_page=100&q=is:issue+label:hacktoberfest+language:python+state:open" +HEADERS = {"Accept": "application / vnd.github.v3 + json"} + + +class HacktoberIssues(commands.Cog): + """Find a random hacktober python issue on GitHub.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.cache_normal = None + self.cache_timer_normal = datetime.datetime(1, 1, 1) + self.cache_beginner = None + self.cache_timer_beginner = datetime.datetime(1, 1, 1) + + @in_month(Month.october) + @commands.command() + async def hacktoberissues(self, ctx: commands.Context, option: str = "") -> None: + """ + Get a random python hacktober issue from Github. + + If the command is run with beginner (`.hacktoberissues beginner`): + It will also narrow it down to the "first good issue" label. + """ + with ctx.typing(): + issues = await self.get_issues(ctx, option) + if issues is None: + return + issue = random.choice(issues["items"]) + embed = self.format_embed(issue) + await ctx.send(embed=embed) + + async def get_issues(self, ctx: commands.Context, option: str) -> Optional[Dict]: + """Get a list of the python issues with the label 'hacktoberfest' from the Github api.""" + if option == "beginner": + if (ctx.message.created_at - self.cache_timer_beginner).seconds <= 60: + log.debug("using cache") + return self.cache_beginner + elif (ctx.message.created_at - self.cache_timer_normal).seconds <= 60: + log.debug("using cache") + return self.cache_normal + + async with aiohttp.ClientSession() as session: + if option == "beginner": + url = URL + '+label:"good first issue"' + if self.cache_beginner is not None: + page = random.randint(1, min(1000, self.cache_beginner["total_count"]) // 100) + url += f"&page={page}" + else: + url = URL + if self.cache_normal is not None: + page = random.randint(1, min(1000, self.cache_normal["total_count"]) // 100) + url += f"&page={page}" + + log.debug(f"making api request to url: {url}") + async with session.get(url, headers=HEADERS) as response: + if response.status != 200: + log.error(f"expected 200 status (got {response.status}) from the GitHub api.") + await ctx.send(f"ERROR: expected 200 status (got {response.status}) from the GitHub api.") + await ctx.send(await response.text()) + return None + data = await response.json() + + if len(data["items"]) == 0: + log.error(f"no issues returned from GitHub api. with url: {response.url}") + await ctx.send(f"ERROR: no issues returned from GitHub api. with url: {response.url}") + return None + + if option == "beginner": + self.cache_beginner = data + self.cache_timer_beginner = ctx.message.created_at + else: + self.cache_normal = data + self.cache_timer_normal = ctx.message.created_at + + return data + + @staticmethod + def format_embed(issue: Dict) -> discord.Embed: + """Format the issue data into a embed.""" + title = issue["title"] + issue_url = issue["url"].replace("api.", "").replace("/repos/", "/") + body = issue["body"] + labels = [label["name"] for label in issue["labels"]] + + embed = discord.Embed(title=title) + embed.description = body + embed.add_field(name="labels", value="\n".join(labels)) + embed.url = issue_url + embed.set_footer(text=issue_url) + + return embed + + +def setup(bot: commands.Bot) -> None: + """Hacktober issue finder Cog Load.""" + bot.add_cog(HacktoberIssues(bot)) + log.info("hacktober-issue-finder cog loaded") diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py new file mode 100644 index 00000000..5dfa2f51 --- /dev/null +++ b/bot/exts/halloween/hacktoberstats.py @@ -0,0 +1,342 @@ +import json +import logging +import re +from collections import Counter +from datetime import datetime +from pathlib import Path +from typing import List, Tuple + +import aiohttp +import discord +from discord.ext import commands + +from bot.constants import Channels, Month, WHITELISTED_CHANNELS +from bot.utils.decorators import in_month, override_in_channel +from bot.utils.persist import make_persistent + +log = logging.getLogger(__name__) + +CURRENT_YEAR = datetime.now().year # Used to construct GH API query +PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded +HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2019,) + + +class HacktoberStats(commands.Cog): + """Hacktoberfest statistics Cog.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json")) + self.linked_accounts = self.load_linked_users() + + @in_month(Month.october) + @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True) + @override_in_channel(HACKTOBER_WHITELIST) + async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None: + """ + Display an embed for a user's Hacktoberfest contributions. + + If invoked without a subcommand or github_username, get the invoking user's stats if they've + linked their Discord name to GitHub using .stats link. If invoked with a github_username, + get that user's contributions + """ + if not github_username: + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) + + if str(author_id) in self.linked_accounts.keys(): + github_username = self.linked_accounts[author_id]["github_username"] + logging.info(f"Getting stats for {author_id} linked GitHub account '{github_username}'") + else: + msg = ( + f"{author_mention}, you have not linked a GitHub account\n\n" + f"You can link your GitHub account using:\n```{ctx.prefix}hackstats link github_username```\n" + f"Or query GitHub stats directly using:\n```{ctx.prefix}hackstats github_username```" + ) + await ctx.send(msg) + return + + await self.get_stats(ctx, github_username) + + @in_month(Month.october) + @hacktoberstats_group.command(name="link") + @override_in_channel(HACKTOBER_WHITELIST) + async def link_user(self, ctx: commands.Context, github_username: str = None) -> None: + """ + Link the invoking user's Github github_username to their Discord ID. + + Linked users are stored as a nested dict: + { + Discord_ID: { + "github_username": str + "date_added": datetime + } + } + """ + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) + if github_username: + if str(author_id) in self.linked_accounts.keys(): + old_username = self.linked_accounts[author_id]["github_username"] + logging.info(f"{author_id} has changed their github link from '{old_username}' to '{github_username}'") + await ctx.send(f"{author_mention}, your GitHub username has been updated to: '{github_username}'") + else: + logging.info(f"{author_id} has added a github link to '{github_username}'") + await ctx.send(f"{author_mention}, your GitHub username has been added") + + self.linked_accounts[author_id] = { + "github_username": github_username, + "date_added": datetime.now() + } + + self.save_linked_users() + else: + logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username") + await ctx.send(f"{author_mention}, a GitHub username is required to link your account") + + @in_month(Month.october) + @hacktoberstats_group.command(name="unlink") + @override_in_channel(HACKTOBER_WHITELIST) + async def unlink_user(self, ctx: commands.Context) -> None: + """Remove the invoking user's account link from the log.""" + author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) + + stored_user = self.linked_accounts.pop(author_id, None) + if stored_user: + await ctx.send(f"{author_mention}, your GitHub profile has been unlinked") + logging.info(f"{author_id} has unlinked their GitHub account") + else: + await ctx.send(f"{author_mention}, you do not currently have a linked GitHub account") + logging.info(f"{author_id} tried to unlink their GitHub account but no account was linked") + + self.save_linked_users() + + def load_linked_users(self) -> dict: + """ + Load list of linked users from local JSON file. + + Linked users are stored as a nested dict: + { + Discord_ID: { + "github_username": str + "date_added": datetime + } + } + """ + if self.link_json.exists(): + logging.info(f"Loading linked GitHub accounts from '{self.link_json}'") + with open(self.link_json, 'r') as file: + linked_accounts = json.load(file) + + logging.info(f"Loaded {len(linked_accounts)} linked GitHub accounts from '{self.link_json}'") + return linked_accounts + else: + logging.info(f"Linked account log: '{self.link_json}' does not exist") + return {} + + def save_linked_users(self) -> None: + """ + Save list of linked users to local JSON file. + + Linked users are stored as a nested dict: + { + Discord_ID: { + "github_username": str + "date_added": datetime + } + } + """ + logging.info(f"Saving linked_accounts to '{self.link_json}'") + with open(self.link_json, 'w') as file: + json.dump(self.linked_accounts, file, default=str) + logging.info(f"linked_accounts saved to '{self.link_json}'") + + async def get_stats(self, ctx: commands.Context, github_username: str) -> None: + """ + Query GitHub's API for PRs created by a GitHub user during the month of October. + + PRs with the 'invalid' tag are ignored + + If a valid github_username is provided, an embed is generated and posted to the channel + + Otherwise, post a helpful error message + """ + async with ctx.typing(): + prs = await self.get_october_prs(github_username) + + if prs: + stats_embed = self.build_embed(github_username, prs) + await ctx.send('Here are some stats!', embed=stats_embed) + else: + await ctx.send(f"No October GitHub contributions found for '{github_username}'") + + def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed: + """Return a stats embed built from github_username's PRs.""" + logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'") + pr_stats = self._summarize_prs(prs) + + n = pr_stats['n_prs'] + if n >= PRS_FOR_SHIRT: + shirtstr = f"**{github_username} has earned a tshirt!**" + elif n == PRS_FOR_SHIRT - 1: + shirtstr = f"**{github_username} is 1 PR away from a tshirt!**" + else: + shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a tshirt!**" + + stats_embed = discord.Embed( + title=f"{github_username}'s Hacktoberfest", + color=discord.Color(0x9c4af7), + description=( + f"{github_username} has made {n} " + f"{HacktoberStats._contributionator(n)} in " + f"October\n\n" + f"{shirtstr}\n\n" + ) + ) + + stats_embed.set_thumbnail(url=f"https://www.github.com/{github_username}.png") + stats_embed.set_author( + name="Hacktoberfest", + url="https://hacktoberfest.digitalocean.com", + icon_url="https://hacktoberfest.digitalocean.com/pretty_logo.png" + ) + stats_embed.add_field( + name="Top 5 Repositories:", + value=self._build_top5str(pr_stats) + ) + + logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'") + return stats_embed + + @staticmethod + async def get_october_prs(github_username: str) -> List[dict]: + """ + Query GitHub's API for PRs created during the month of October by github_username. + + PRs with an 'invalid' tag are ignored + + If PRs are found, return a list of dicts with basic PR information + + For each PR: + { + "repo_url": str + "repo_shortname": str (e.g. "python-discord/seasonalbot") + "created_at": datetime.datetime + } + + Otherwise, return None + """ + logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'") + base_url = "https://api.github.com/search/issues?q=" + not_label = "invalid" + action_type = "pr" + is_query = f"public+author:{github_username}" + not_query = "draft" + date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00" + per_page = "300" + query_url = ( + f"{base_url}" + f"-label:{not_label}" + f"+type:{action_type}" + f"+is:{is_query}" + f"+-is:{not_query}" + f"+created:{date_range}" + f"&per_page={per_page}" + ) + + headers = {"user-agent": "Discord Python Hacktoberbot"} + async with aiohttp.ClientSession() as session: + async with session.get(query_url, headers=headers) as resp: + jsonresp = await resp.json() + + if "message" in jsonresp.keys(): + # One of the parameters is invalid, short circuit for now + api_message = jsonresp["errors"][0]["message"] + logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") + return + else: + if jsonresp["total_count"] == 0: + # Short circuit if there aren't any PRs + logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'") + return + else: + logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") + outlist = [] + for item in jsonresp["items"]: + shortname = HacktoberStats._get_shortname(item["repository_url"]) + itemdict = { + "repo_url": f"https://www.github.com/{shortname}", + "repo_shortname": shortname, + "created_at": datetime.strptime( + item["created_at"], r"%Y-%m-%dT%H:%M:%SZ" + ), + } + outlist.append(itemdict) + return outlist + + @staticmethod + def _get_shortname(in_url: str) -> str: + """ + Extract shortname from https://api.github.com/repos/* URL. + + e.g. "https://api.github.com/repos/python-discord/seasonalbot" + | + V + "python-discord/seasonalbot" + """ + exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)" + return re.findall(exp, in_url)[0] + + @staticmethod + def _summarize_prs(prs: List[dict]) -> dict: + """ + Generate statistics from an input list of PR dictionaries, as output by get_october_prs. + + Return a dictionary containing: + { + "n_prs": int + "top5": [(repo_shortname, ncontributions), ...] + } + """ + contributed_repos = [pr["repo_shortname"] for pr in prs] + return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)} + + @staticmethod + def _build_top5str(stats: List[tuple]) -> str: + """ + Build a string from the Top 5 contributions that is compatible with a discord.Embed field. + + Top 5 contributions should be a list of tuples, as output in the stats dictionary by + _summarize_prs + + String is of the form: + n contribution(s) to [shortname](url) + ... + """ + base_url = "https://www.github.com/" + contributionstrs = [] + for repo in stats['top5']: + n = repo[1] + contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({base_url}{repo[0]})") + + return "\n".join(contributionstrs) + + @staticmethod + def _contributionator(n: int) -> str: + """Return "contribution" or "contributions" based on the value of n.""" + if n == 1: + return "contribution" + else: + return "contributions" + + @staticmethod + def _author_mention_from_context(ctx: commands.Context) -> Tuple: + """Return stringified Message author ID and mentionable string from commands.Context.""" + author_id = str(ctx.message.author.id) + author_mention = ctx.message.author.mention + + return author_id, author_mention + + +def setup(bot: commands.Bot) -> None: + """Hacktoberstats Cog load.""" + bot.add_cog(HacktoberStats(bot)) + log.info("HacktoberStats cog loaded") diff --git a/bot/exts/halloween/halloween_facts.py b/bot/exts/halloween/halloween_facts.py new file mode 100644 index 00000000..222768f4 --- /dev/null +++ b/bot/exts/halloween/halloween_facts.py @@ -0,0 +1,59 @@ +import json +import logging +import random +from datetime import timedelta +from pathlib import Path +from typing import Tuple + +import discord +from discord.ext import commands + +log = logging.getLogger(__name__) + +SPOOKY_EMOJIS = [ + "\N{BAT}", + "\N{DERELICT HOUSE BUILDING}", + "\N{EXTRATERRESTRIAL ALIEN}", + "\N{GHOST}", + "\N{JACK-O-LANTERN}", + "\N{SKULL}", + "\N{SKULL AND CROSSBONES}", + "\N{SPIDER WEB}", +] +PUMPKIN_ORANGE = discord.Color(0xFF7518) +INTERVAL = timedelta(hours=6).total_seconds() + + +class HalloweenFacts(commands.Cog): + """A Cog for displaying interesting facts about Halloween.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + with open(Path("bot/resources/halloween/halloween_facts.json"), "r") as file: + self.halloween_facts = json.load(file) + self.facts = list(enumerate(self.halloween_facts)) + random.shuffle(self.facts) + + def random_fact(self) -> Tuple[int, str]: + """Return a random fact from the loaded facts.""" + return random.choice(self.facts) + + @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") + async def get_random_fact(self, ctx: commands.Context) -> None: + """Reply with the most recent Halloween fact.""" + index, fact = self.random_fact() + embed = self._build_embed(index, fact) + await ctx.send(embed=embed) + + @staticmethod + def _build_embed(index: int, fact: str) -> discord.Embed: + """Builds a Discord embed from the given fact and its index.""" + emoji = random.choice(SPOOKY_EMOJIS) + title = f"{emoji} Halloween Fact #{index + 1}" + return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) + + +def setup(bot: commands.Bot) -> None: + """Halloween facts Cog load.""" + bot.add_cog(HalloweenFacts(bot)) + log.info("HalloweenFacts cog loaded") diff --git a/bot/exts/halloween/halloweenify.py b/bot/exts/halloween/halloweenify.py new file mode 100644 index 00000000..dfcc2b1e --- /dev/null +++ b/bot/exts/halloween/halloweenify.py @@ -0,0 +1,52 @@ +import logging +from json import load +from pathlib import Path +from random import choice + +import discord +from discord.ext import commands +from discord.ext.commands.cooldowns import BucketType + +log = logging.getLogger(__name__) + + +class Halloweenify(commands.Cog): + """A cog to change a invokers nickname to a spooky one!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.cooldown(1, 300, BucketType.user) + @commands.command() + async def halloweenify(self, ctx: commands.Context) -> None: + """Change your nickname into a much spookier one!""" + async with ctx.typing(): + with open(Path("bot/resources/halloween/halloweenify.json"), "r") as f: + data = load(f) + + # Choose a random character from our list we loaded above and set apart the nickname and image url. + character = choice(data["characters"]) + nickname = ''.join([nickname for nickname in character]) + image = ''.join([character[nickname] for nickname in character]) + + # Build up a Embed + embed = discord.Embed() + embed.colour = discord.Colour.dark_orange() + embed.title = "Not spooky enough?" + embed.description = ( + f"**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, " + f"{ctx.author.display_name} isn\'t scary at all! " + "Let me think of something better. Hmm... I got it!\n\n " + f"Your new nickname will be: \n :ghost: **{nickname}** :jack_o_lantern:" + ) + embed.set_image(url=image) + + await ctx.author.edit(nick=nickname) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Halloweenify Cog load.""" + bot.add_cog(Halloweenify(bot)) + log.info("Halloweenify cog loaded") diff --git a/bot/exts/halloween/monsterbio.py b/bot/exts/halloween/monsterbio.py new file mode 100644 index 00000000..bfa8a026 --- /dev/null +++ b/bot/exts/halloween/monsterbio.py @@ -0,0 +1,56 @@ +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/halloween/monster.json"), "r", encoding="utf8") as f: + TEXT_OPTIONS = json.load(f) # Data for a mad-lib style generation of text + + +class MonsterBio(commands.Cog): + """A cog that generates a spooky monster biography.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + def generate_name(self, seeded_random: random.Random) -> str: + """Generates a name (for either monster species or monster name).""" + n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"])) + return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings)) + + @commands.command(brief="Sends your monster bio!") + async def monsterbio(self, ctx: commands.Context) -> None: + """Sends a description of a monster.""" + seeded_random = random.Random(ctx.message.author.id) # Seed a local Random instance rather than the system one + + name = self.generate_name(seeded_random) + species = self.generate_name(seeded_random) + biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"]) + words = {"monster_name": name, "monster_species": species} + for key, value in biography_text.items(): + if key == "text": + continue + + options = seeded_random.sample(TEXT_OPTIONS[key], value) + words[key] = ' '.join(options) + + embed = discord.Embed( + title=f"{name}'s Biography", + color=seeded_random.choice([Colours.orange, Colours.purple]), + description=biography_text["text"].format_map(words), + ) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Monster bio Cog load.""" + bot.add_cog(MonsterBio(bot)) + log.info("MonsterBio cog loaded.") diff --git a/bot/exts/halloween/monstersurvey.py b/bot/exts/halloween/monstersurvey.py new file mode 100644 index 00000000..12e1d022 --- /dev/null +++ b/bot/exts/halloween/monstersurvey.py @@ -0,0 +1,206 @@ +import json +import logging +import os + +from discord import Embed +from discord.ext import commands +from discord.ext.commands import Bot, Cog, Context + +log = logging.getLogger(__name__) + +EMOJIS = { + 'SUCCESS': u'\u2705', + 'ERROR': u'\u274C' +} + + +class MonsterSurvey(Cog): + """ + Vote for your favorite monster. + + This Cog allows users to vote for their favorite listed monster. + + Users may change their vote, but only their current vote will be counted. + """ + + def __init__(self, bot: Bot): + """Initializes values for the bot to use within the voting commands.""" + self.bot = bot + self.registry_location = os.path.join(os.getcwd(), 'bot', 'resources', 'halloween', 'monstersurvey.json') + with open(self.registry_location, 'r') as jason: + self.voter_registry = json.load(jason) + + def json_write(self) -> None: + """Write voting results to a local JSON file.""" + log.info("Saved Monster Survey Results") + with open(self.registry_location, 'w') as jason: + json.dump(self.voter_registry, jason, indent=2) + + def cast_vote(self, id: int, monster: str) -> None: + """ + Cast a user's vote for the specified monster. + + If the user has already voted, their existing vote is removed. + """ + vr = self.voter_registry + for m in vr.keys(): + if id not in vr[m]['votes'] and m == monster: + vr[m]['votes'].append(id) + else: + if id in vr[m]['votes'] and m != monster: + vr[m]['votes'].remove(id) + + def get_name_by_leaderboard_index(self, n: int) -> str: + """Return the monster at the specified leaderboard index.""" + n = n - 1 + vr = self.voter_registry + top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) + name = top[n] if n >= 0 else None + return name + + @commands.group( + name='monster', + aliases=('mon',) + ) + async def monster_group(self, ctx: Context) -> None: + """The base voting command. If nothing is called, then it will return an embed.""" + if ctx.invoked_subcommand is None: + async with ctx.typing(): + default_embed = Embed( + title='Monster Voting', + color=0xFF6800, + description='Vote for your favorite monster!' + ) + default_embed.add_field( + name='.monster show monster_name(optional)', + value='Show a specific monster. If none is listed, it will give you an error with valid choices.', + inline=False) + default_embed.add_field( + name='.monster vote monster_name', + value='Vote for a specific monster. You get one vote, but can change it at any time.', + inline=False + ) + default_embed.add_field( + name='.monster leaderboard', + value='Which monster has the most votes? This command will tell you.', + inline=False + ) + default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry.keys())}") + + await ctx.send(embed=default_embed) + + @monster_group.command( + name='vote' + ) + async def monster_vote(self, ctx: Context, name: str = None) -> None: + """ + Cast a vote for a particular monster. + + Displays a list of monsters that can be voted for if one is not specified. + """ + if name is None: + await ctx.invoke(self.monster_leaderboard) + return + + async with ctx.typing(): + # Check to see if user used a numeric (leaderboard) index to vote + try: + idx = int(name) + name = self.get_name_by_leaderboard_index(idx) + except ValueError: + name = name.lower() + + vote_embed = Embed( + name='Monster Voting', + color=0xFF6800 + ) + + m = self.voter_registry.get(name) + if m is None: + vote_embed.description = f'You cannot vote for {name} because it\'s not in the running.' + vote_embed.add_field( + name='Use `.monster show {monster_name}` for more information on a specific monster', + value='or use `.monster vote {monster}` to cast your vote for said monster.', + inline=False + ) + vote_embed.add_field( + name='You may vote for or show the following monsters:', + value=f"{', '.join(self.voter_registry.keys())}" + ) + else: + self.cast_vote(ctx.author.id, name) + vote_embed.add_field( + name='Vote successful!', + value=f'You have successfully voted for {m["full_name"]}!', + inline=False + ) + vote_embed.set_thumbnail(url=m['image']) + vote_embed.set_footer(text="Please note that any previous votes have been removed.") + self.json_write() + + await ctx.send(embed=vote_embed) + + @monster_group.command( + name='show' + ) + async def monster_show(self, ctx: Context, name: str = None) -> None: + """Shows the named monster. If one is not named, it sends the default voting embed instead.""" + if name is None: + await ctx.invoke(self.monster_leaderboard) + return + + async with ctx.typing(): + # Check to see if user used a numeric (leaderboard) index to vote + try: + idx = int(name) + name = self.get_name_by_leaderboard_index(idx) + except ValueError: + name = name.lower() + + m = self.voter_registry.get(name) + if not m: + await ctx.send('That monster does not exist.') + await ctx.invoke(self.monster_vote) + return + + embed = Embed(title=m['full_name'], color=0xFF6800) + embed.add_field(name='Summary', value=m['summary']) + embed.set_image(url=m['image']) + embed.set_footer(text=f'To vote for this monster, type .monster vote {name}') + + await ctx.send(embed=embed) + + @monster_group.command( + name='leaderboard', + aliases=('lb',) + ) + async def monster_leaderboard(self, ctx: Context) -> None: + """Shows the current standings.""" + async with ctx.typing(): + vr = self.voter_registry + top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) + total_votes = sum(len(m['votes']) for m in self.voter_registry.values()) + + embed = Embed(title="Monster Survey Leader Board", color=0xFF6800) + for rank, m in enumerate(top): + votes = len(vr[m]['votes']) + percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0 + embed.add_field(name=f"{rank+1}. {vr[m]['full_name']}", + value=( + f"{votes} votes. {percentage:.1f}% of total votes.\n" + f"Vote for this monster by typing " + f"'.monster vote {m}'\n" + f"Get more information on this monster by typing " + f"'.monster show {m}'" + ), + inline=False) + + embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ") + + await ctx.send(embed=embed) + + +def setup(bot: Bot) -> None: + """Monster survey Cog load.""" + bot.add_cog(MonsterSurvey(bot)) + log.info("MonsterSurvey cog loaded") diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py new file mode 100644 index 00000000..3823a3e4 --- /dev/null +++ b/bot/exts/halloween/scarymovie.py @@ -0,0 +1,132 @@ +import logging +import random +from os import environ + +import aiohttp +from discord import Embed +from discord.ext import commands + +log = logging.getLogger(__name__) + + +TMDB_API_KEY = environ.get('TMDB_API_KEY') +TMDB_TOKEN = environ.get('TMDB_TOKEN') + + +class ScaryMovie(commands.Cog): + """Selects a random scary movie and embeds info into Discord chat.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(name='scarymovie', alias=['smovie']) + async def random_movie(self, ctx: commands.Context) -> None: + """Randomly select a scary movie and display information about it.""" + async with ctx.typing(): + selection = await self.select_movie() + movie_details = await self.format_metadata(selection) + + await ctx.send(embed=movie_details) + + @staticmethod + async def select_movie() -> dict: + """Selects a random movie and returns a JSON of movie details from TMDb.""" + url = 'https://api.themoviedb.org/4/discover/movie' + params = { + 'with_genres': '27', + 'vote_count.gte': '5' + } + headers = { + 'Authorization': 'Bearer ' + TMDB_TOKEN, + 'Content-Type': 'application/json;charset=utf-8' + } + + # Get total page count of horror movies + async with aiohttp.ClientSession() as session: + response = await session.get(url=url, params=params, headers=headers) + total_pages = await response.json() + total_pages = total_pages.get('total_pages') + + # Get movie details from one random result on a random page + params['page'] = random.randint(1, total_pages) + response = await session.get(url=url, params=params, headers=headers) + response = await response.json() + selection_id = random.choice(response.get('results')).get('id') + + # Get full details and credits + selection = await session.get( + url='https://api.themoviedb.org/3/movie/' + str(selection_id), + params={'api_key': TMDB_API_KEY, 'append_to_response': 'credits'} + ) + + return await selection.json() + + @staticmethod + async def format_metadata(movie: dict) -> Embed: + """Formats raw TMDb data to be embedded in Discord chat.""" + # Build the relevant URLs. + movie_id = movie.get("id") + poster_path = movie.get("poster_path") + tmdb_url = f'https://www.themoviedb.org/movie/{movie_id}' if movie_id else None + poster = f'https://image.tmdb.org/t/p/original{poster_path}' if poster_path else None + + # Get cast names + cast = [] + for actor in movie.get('credits', {}).get('cast', [])[:3]: + cast.append(actor.get('name')) + + # Get director name + director = movie.get('credits', {}).get('crew', []) + if director: + director = director[0].get('name') + + # Determine the spookiness rating + rating = '' + rating_count = movie.get('vote_average', 0) + + if rating_count: + rating_count /= 2 + + for _ in range(int(rating_count)): + rating += ':skull:' + if (rating_count % 1) >= .5: + rating += ':bat:' + + # Try to get year of release and runtime + year = movie.get('release_date', [])[:4] + runtime = movie.get('runtime') + runtime = f"{runtime} minutes" if runtime else None + + # Not all these attributes will always be present + movie_attributes = { + "Directed by": director, + "Starring": ', '.join(cast), + "Running time": runtime, + "Release year": year, + "Spookiness rating": rating, + } + + embed = Embed( + colour=0x01d277, + title='**' + movie.get('title') + '**', + url=tmdb_url, + description=movie.get('overview') + ) + + if poster: + embed.set_image(url=poster) + + # Add the attributes that we actually have data for, but not the others. + for name, value in movie_attributes.items(): + if value: + embed.add_field(name=name, value=value) + + embed.set_footer(text='powered by themoviedb.org') + + return embed + + +def setup(bot: commands.Bot) -> None: + """Scary movie Cog load.""" + bot.add_cog(ScaryMovie(bot)) + log.info("ScaryMovie cog loaded") diff --git a/bot/exts/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py new file mode 100644 index 00000000..268de3fb --- /dev/null +++ b/bot/exts/halloween/spookyavatar.py @@ -0,0 +1,53 @@ +import logging +import os +from io import BytesIO + +import aiohttp +import discord +from PIL import Image +from discord.ext import commands + +from bot.utils.halloween import spookifications + +log = logging.getLogger(__name__) + + +class SpookyAvatar(commands.Cog): + """A cog that spookifies an avatar.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + async def get(self, url: str) -> bytes: + """Returns the contents of the supplied URL.""" + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + return await resp.read() + + @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), + brief='Spookify an user\'s avatar.') + async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: + """A command to print the user's spookified avatar.""" + if user is None: + user = ctx.message.author + + async with ctx.typing(): + embed = discord.Embed(colour=0xFF0000) + embed.title = "Is this you or am I just really paranoid?" + embed.set_author(name=str(user.name), icon_url=user.avatar_url) + + image_bytes = await ctx.author.avatar_url.read() + im = Image.open(BytesIO(image_bytes)) + modified_im = spookifications.get_random_effect(im) + modified_im.save(str(ctx.message.id)+'.png') + f = discord.File(str(ctx.message.id)+'.png') + embed.set_image(url='attachment://'+str(ctx.message.id)+'.png') + + await ctx.send(file=f, embed=embed) + os.remove(str(ctx.message.id)+'.png') + + +def setup(bot: commands.Bot) -> None: + """Spooky avatar Cog load.""" + bot.add_cog(SpookyAvatar(bot)) + log.info("SpookyAvatar cog loaded") diff --git a/bot/exts/halloween/spookygif.py b/bot/exts/halloween/spookygif.py new file mode 100644 index 00000000..818de8cd --- /dev/null +++ b/bot/exts/halloween/spookygif.py @@ -0,0 +1,39 @@ +import logging + +import aiohttp +import discord +from discord.ext import commands + +from bot.constants import Tokens + +log = logging.getLogger(__name__) + + +class SpookyGif(commands.Cog): + """A cog to fetch a random spooky gif from the web!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(name="spookygif", aliases=("sgif", "scarygif")) + async def spookygif(self, ctx: commands.Context) -> None: + """Fetches a random gif from the GIPHY API and responds with it.""" + async with ctx.typing(): + async with aiohttp.ClientSession() as session: + params = {'api_key': Tokens.giphy, 'tag': 'halloween', 'rating': 'g'} + # Make a GET request to the Giphy API to get a random halloween gif. + async with session.get('http://api.giphy.com/v1/gifs/random', params=params) as resp: + data = await resp.json() + url = data['data']['image_url'] + + embed = discord.Embed(colour=0x9b59b6) + embed.title = "A spooooky gif!" + embed.set_image(url=url) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Spooky GIF Cog load.""" + bot.add_cog(SpookyGif(bot)) + log.info("SpookyGif cog loaded") diff --git a/bot/exts/halloween/spookyrating.py b/bot/exts/halloween/spookyrating.py new file mode 100644 index 00000000..7f78f536 --- /dev/null +++ b/bot/exts/halloween/spookyrating.py @@ -0,0 +1,67 @@ +import bisect +import json +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with Path("bot/resources/halloween/spooky_rating.json").open() as file: + SPOOKY_DATA = json.load(file) + SPOOKY_DATA = sorted((int(key), value) for key, value in SPOOKY_DATA.items()) + + +class SpookyRating(commands.Cog): + """A cog for calculating one's spooky rating.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.local_random = random.Random() + + @commands.command() + @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) + async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None: + """ + Calculates the spooky rating of someone. + + Any user will always yield the same result, no matter who calls the command + """ + if who is None: + who = ctx.author + + # This ensures that the same result over multiple runtimes + self.local_random.seed(who.id) + spooky_percent = self.local_random.randint(1, 101) + + # We need the -1 due to how bisect returns the point + # see the documentation for further detail + # https://docs.python.org/3/library/bisect.html#bisect.bisect + index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1 + + _, data = SPOOKY_DATA[index] + + embed = discord.Embed( + title=data['title'], + description=f'{who} scored {spooky_percent}%!', + color=Colours.orange + ) + embed.add_field( + name='A whisper from Satan', + value=data['text'] + ) + embed.set_thumbnail( + url=data['image'] + ) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Spooky Rating Cog load.""" + bot.add_cog(SpookyRating(bot)) + log.info("SpookyRating cog loaded") diff --git a/bot/exts/halloween/spookyreact.py b/bot/exts/halloween/spookyreact.py new file mode 100644 index 00000000..16f18019 --- /dev/null +++ b/bot/exts/halloween/spookyreact.py @@ -0,0 +1,76 @@ +import logging +import re + +import discord +from discord.ext.commands import Bot, Cog + +from bot.constants import Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +SPOOKY_TRIGGERS = { + 'spooky': (r"\bspo{2,}ky\b", "\U0001F47B"), + 'skeleton': (r"\bskeleton\b", "\U0001F480"), + 'doot': (r"\bdo{2,}t\b", "\U0001F480"), + 'pumpkin': (r"\bpumpkin\b", "\U0001F383"), + 'halloween': (r"\bhalloween\b", "\U0001F383"), + 'jack-o-lantern': (r"\bjack-o-lantern\b", "\U0001F383"), + 'danger': (r"\bdanger\b", "\U00002620") +} + + +class SpookyReact(Cog): + """A cog that makes the bot react to message triggers.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @in_month(Month.october) + @Cog.listener() + async def on_message(self, ctx: discord.Message) -> None: + """ + A command to send the seasonalbot github project. + + Lines that begin with the bot's command prefix are ignored + + Seasonalbot's own messages are ignored + """ + for trigger in SPOOKY_TRIGGERS.keys(): + trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower()) + if trigger_test: + # Check message for bot replies and/or command invocations + # Short circuit if they're found, logging is handled in _short_circuit_check + if await self._short_circuit_check(ctx): + return + else: + await ctx.add_reaction(SPOOKY_TRIGGERS[trigger][1]) + logging.info(f"Added '{trigger}' reaction to message ID: {ctx.id}") + + async def _short_circuit_check(self, ctx: discord.Message) -> bool: + """ + Short-circuit helper check. + + Return True if: + * author is the bot + * prefix is not None + """ + # Check for self reaction + if ctx.author == self.bot.user: + logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}") + return True + + # Check for command invocation + # Because on_message doesn't give a full Context object, generate one first + tmp_ctx = await self.bot.get_context(ctx) + if tmp_ctx.prefix: + logging.debug(f"Ignoring reactions on command invocation. Message ID: {ctx.id}") + return True + + return False + + +def setup(bot: Bot) -> None: + """Spooky reaction Cog load.""" + bot.add_cog(SpookyReact(bot)) + log.info("SpookyReact cog loaded") diff --git a/bot/exts/halloween/spookysound.py b/bot/exts/halloween/spookysound.py new file mode 100644 index 00000000..e0676d0a --- /dev/null +++ b/bot/exts/halloween/spookysound.py @@ -0,0 +1,48 @@ +import logging +import random +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Hacktoberfest + +log = logging.getLogger(__name__) + + +class SpookySound(commands.Cog): + """A cog that plays a spooky sound in a voice channel on command.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.sound_files = list(Path("bot/resources/halloween/spookysounds").glob("*.mp3")) + self.channel = None + + @commands.cooldown(rate=1, per=1) + @commands.command(brief="Play a spooky sound, restricted to once per 2 mins") + async def spookysound(self, ctx: commands.Context) -> None: + """ + Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. + + Cannot be used more than once in 2 minutes. + """ + if not self.channel: + await self.bot.wait_until_ready() + self.channel = self.bot.get_channel(Hacktoberfest.voice_id) + + await ctx.send("Initiating spooky sound...") + file_path = random.choice(self.sound_files) + src = discord.FFmpegPCMAudio(str(file_path.resolve())) + voice = await self.channel.connect() + voice.play(src, after=lambda e: self.bot.loop.create_task(self.disconnect(voice))) + + @staticmethod + async def disconnect(voice: discord.VoiceClient) -> None: + """Helper method to disconnect a given voice client.""" + await voice.disconnect() + + +def setup(bot: commands.Bot) -> None: + """Spooky sound Cog load.""" + bot.add_cog(SpookySound(bot)) + log.info("SpookySound cog loaded") diff --git a/bot/exts/halloween/timeleft.py b/bot/exts/halloween/timeleft.py new file mode 100644 index 00000000..8cb3f4f6 --- /dev/null +++ b/bot/exts/halloween/timeleft.py @@ -0,0 +1,60 @@ +import logging +from datetime import datetime +from typing import Tuple + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class TimeLeft(commands.Cog): + """A Cog that tells you how long left until Hacktober is over!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @staticmethod + def in_october() -> bool: + """Return True if the current month is October.""" + return datetime.utcnow().month == 10 + + @staticmethod + def load_date() -> Tuple[int, datetime, datetime]: + """Return of a tuple of the current time and the end and start times of the next October.""" + now = datetime.utcnow() + year = now.year + if now.month > 10: + year += 1 + end = datetime(year, 11, 1, 11, 59, 59) + start = datetime(year, 10, 1) + return now, end, start + + @commands.command() + async def timeleft(self, ctx: commands.Context) -> None: + """ + Calculates the time left until the end of Hacktober. + + Whilst in October, displays the days, hours and minutes left. + Only displays the days left until the beginning and end whilst in a different month + """ + now, end, start = self.load_date() + diff = end - now + days, seconds = diff.days, diff.seconds + if self.in_october(): + minutes = seconds // 60 + hours, minutes = divmod(minutes, 60) + await ctx.send(f"There is currently only {days} days, {hours} hours and {minutes}" + "minutes left until the end of Hacktober.") + else: + start_diff = start - now + start_days = start_diff.days + await ctx.send( + f"It is not currently Hacktober. However, the next one will start in {start_days} days " + f"and will finish in {days} days." + ) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(TimeLeft(bot)) + log.info("TimeLeft cog loaded") diff --git a/bot/exts/pride/__init__.py b/bot/exts/pride/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/pride/drag_queen_name.py b/bot/exts/pride/drag_queen_name.py new file mode 100644 index 00000000..43813fbd --- /dev/null +++ b/bot/exts/pride/drag_queen_name.py @@ -0,0 +1,33 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class DragNames(commands.Cog): + """Gives a random drag queen name!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.names = self.load_names() + + @staticmethod + def load_names() -> list: + """Loads a list of drag queen names.""" + with open(Path("bot/resources/pride/drag_queen_names.json"), "r", encoding="utf-8") as f: + return json.load(f) + + @commands.command(name="dragname", aliases=["dragqueenname", "queenme"]) + async def dragname(self, ctx: commands.Context) -> None: + """Sends a message with a drag queen name.""" + await ctx.send(random.choice(self.names)) + + +def setup(bot: commands.Bot) -> None: + """Cog loader for drag queen name generator.""" + bot.add_cog(DragNames(bot)) + log.info("Drag queen name generator cog loaded!") diff --git a/bot/exts/pride/pride_anthem.py b/bot/exts/pride/pride_anthem.py new file mode 100644 index 00000000..b0c6d34e --- /dev/null +++ b/bot/exts/pride/pride_anthem.py @@ -0,0 +1,58 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class PrideAnthem(commands.Cog): + """Embed a random youtube video for a gay anthem!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.anthems = self.load_vids() + + def get_video(self, genre: str = None) -> dict: + """ + Picks a random anthem from the list. + + If `genre` is supplied, it will pick from videos attributed with that genre. + If none can be found, it will log this as well as provide that information to the user. + """ + if not genre: + return random.choice(self.anthems) + else: + songs = [song for song in self.anthems if genre.casefold() in song["genre"]] + try: + return random.choice(songs) + except IndexError: + log.info("No videos for that genre.") + + @staticmethod + def load_vids() -> list: + """Loads a list of videos from the resources folder as dictionaries.""" + with open(Path("bot/resources/pride/anthems.json"), "r", encoding="utf-8") as f: + anthems = json.load(f) + return anthems + + @commands.command(name="prideanthem", aliases=["anthem", "pridesong"]) + async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None: + """ + Sends a message with a video of a random pride anthem. + + If `genre` is supplied, it will select from that genre only. + """ + anthem = self.get_video(genre) + if anthem: + await ctx.send(anthem["url"]) + else: + await ctx.send("I couldn't find a video, sorry!") + + +def setup(bot: commands.Bot) -> None: + """Cog loader for pride anthem.""" + bot.add_cog(PrideAnthem(bot)) + log.info("Pride anthems cog loaded!") diff --git a/bot/exts/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py new file mode 100644 index 00000000..85e49d5c --- /dev/null +++ b/bot/exts/pride/pride_avatar.py @@ -0,0 +1,145 @@ +import logging +from io import BytesIO +from pathlib import Path + +import discord +from PIL import Image, ImageDraw +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +OPTIONS = { + "agender": "agender", + "androgyne": "androgyne", + "androgynous": "androgyne", + "aromantic": "aromantic", + "aro": "aromantic", + "ace": "asexual", + "asexual": "asexual", + "bigender": "bigender", + "bisexual": "bisexual", + "bi": "bisexual", + "demiboy": "demiboy", + "demigirl": "demigirl", + "demi": "demisexual", + "demisexual": "demisexual", + "gay": "gay", + "lgbt": "gay", + "queer": "gay", + "homosexual": "gay", + "fluid": "genderfluid", + "genderfluid": "genderfluid", + "genderqueer": "genderqueer", + "intersex": "intersex", + "lesbian": "lesbian", + "non-binary": "nonbinary", + "enby": "nonbinary", + "nb": "nonbinary", + "nonbinary": "nonbinary", + "omnisexual": "omnisexual", + "omni": "omnisexual", + "pansexual": "pansexual", + "pan": "pansexual", + "pangender": "pangender", + "poly": "polysexual", + "polysexual": "polysexual", + "polyamory": "polyamory", + "polyamorous": "polyamory", + "transgender": "transgender", + "trans": "transgender", + "trigender": "trigender" +} + + +class PrideAvatar(commands.Cog): + """Put an LGBT spin on your avatar!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @staticmethod + def crop_avatar(avatar: Image) -> Image: + """This crops the avatar into a circle.""" + mask = Image.new("L", avatar.size, 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0) + avatar.size, fill=255) + avatar.putalpha(mask) + return avatar + + @staticmethod + def crop_ring(ring: Image, px: int) -> Image: + """This crops the ring into a circle.""" + mask = Image.new("L", ring.size, 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((0, 0) + ring.size, fill=255) + draw.ellipse((px, px, 1024-px, 1024-px), fill=0) + ring.putalpha(mask) + return ring + + @commands.group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) + async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None: + """ + This surrounds an avatar with a border of a specified LGBT flag. + + This defaults to the LGBT rainbow flag if none is given. + The amount of pixels can be given which determines the thickness of the flag border. + This has a maximum of 512px and defaults to a 64px border. + The full image is 1024x1024. + """ + pixels = 0 if pixels < 0 else 512 if pixels > 512 else pixels + + option = option.lower() + + if option not in OPTIONS.keys(): + return await ctx.send("I don't have that flag!") + + flag = OPTIONS[option] + + async with ctx.typing(): + + # Get avatar bytes + image_bytes = await ctx.author.avatar_url.read() + avatar = Image.open(BytesIO(image_bytes)) + avatar = avatar.convert("RGBA").resize((1024, 1024)) + + avatar = self.crop_avatar(avatar) + + ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) + ring = ring.convert("RGBA") + ring = self.crop_ring(ring, pixels) + + avatar.alpha_composite(ring, (0, 0)) + bufferedio = BytesIO() + avatar.save(bufferedio, format="PNG") + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed + embed = discord.Embed( + name="Your Lovely Pride Avatar", + description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" + ) + embed.set_image(url="attachment://pride_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + @prideavatar.command() + async def flags(self, ctx: commands.Context) -> None: + """This lists the flags that can be used with the prideavatar command.""" + choices = sorted(set(OPTIONS.values())) + options = "• " + "\n• ".join(choices) + embed = discord.Embed( + title="I have the following flags:", + description=options, + colour=Colours.soft_red + ) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Cog load.""" + bot.add_cog(PrideAvatar(bot)) + log.info("PrideAvatar cog loaded") diff --git a/bot/exts/pride/pride_facts.py b/bot/exts/pride/pride_facts.py new file mode 100644 index 00000000..2db8f5c2 --- /dev/null +++ b/bot/exts/pride/pride_facts.py @@ -0,0 +1,107 @@ +import json +import logging +import random +from datetime import datetime +from pathlib import Path +from typing import Union + +import dateutil.parser +import discord +from discord.ext import commands + +from bot.constants import Channels, Colours, Month +from bot.utils.decorators import seasonal_task + +log = logging.getLogger(__name__) + +Sendable = Union[commands.Context, discord.TextChannel] + + +class PrideFacts(commands.Cog): + """Provides a new fact every day during the Pride season!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.facts = self.load_facts() + + self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily()) + + @staticmethod + def load_facts() -> dict: + """Loads a dictionary of years mapping to lists of facts.""" + with open(Path("bot/resources/pride/facts.json"), "r", encoding="utf-8") as f: + return json.load(f) + + @seasonal_task(Month.june) + async def send_pride_fact_daily(self) -> None: + """Background task to post the daily pride fact every day.""" + await self.bot.wait_until_ready() + + channel = self.bot.get_channel(Channels.seasonalbot_commands) + await self.send_select_fact(channel, datetime.utcnow()) + + async def send_random_fact(self, ctx: commands.Context) -> None: + """Provides a fact from any previous day, or today.""" + now = datetime.utcnow() + previous_years_facts = (self.facts[x] for x in self.facts.keys() if int(x) < now.year) + current_year_facts = self.facts.get(str(now.year), [])[:now.day] + previous_facts = current_year_facts + [x for y in previous_years_facts for x in y] + try: + await ctx.send(embed=self.make_embed(random.choice(previous_facts))) + except IndexError: + await ctx.send("No facts available") + + async def send_select_fact(self, target: Sendable, _date: Union[str, datetime]) -> None: + """Provides the fact for the specified day, if the day is today, or is in the past.""" + now = datetime.utcnow() + if isinstance(_date, str): + try: + date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True) + except (ValueError, OverflowError) as err: + await target.send(f"Error parsing date: {err}") + return + else: + date = _date + if date.year < now.year or (date.year == now.year and date.day <= now.day): + try: + await target.send(embed=self.make_embed(self.facts[str(date.year)][date.day - 1])) + except KeyError: + await target.send(f"The year {date.year} is not yet supported") + return + except IndexError: + await target.send(f"Day {date.day} of {date.year} is not yet support") + return + else: + await target.send("The fact for the selected day is not yet available.") + + @commands.command(name="pridefact", aliases=["pridefacts"]) + async def pridefact(self, ctx: commands.Context) -> None: + """ + Sends a message with a pride fact of the day. + + If "random" is given as an argument, a random previous fact will be provided. + + If a date is given as an argument, and the date is in the past, the fact from that day + will be provided. + """ + message_body = ctx.message.content[len(ctx.invoked_with) + 2:] + if message_body == "": + await self.send_select_fact(ctx, datetime.utcnow()) + elif message_body.lower().startswith("rand"): + await self.send_random_fact(ctx) + else: + await self.send_select_fact(ctx, message_body) + + def make_embed(self, fact: str) -> discord.Embed: + """Makes a nice embed for the fact to be sent.""" + return discord.Embed( + colour=Colours.pink, + title="Pride Fact!", + description=fact + ) + + +def setup(bot: commands.Bot) -> None: + """Cog loader for pride facts.""" + bot.add_cog(PrideFacts(bot)) + log.info("Pride facts cog loaded!") diff --git a/bot/exts/valentines/__init__.py b/bot/exts/valentines/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py new file mode 100644 index 00000000..1e883d21 --- /dev/null +++ b/bot/exts/valentines/be_my_valentine.py @@ -0,0 +1,237 @@ +import logging +import random +from json import load +from pathlib import Path +from typing import Optional, Tuple + +import discord +from discord.ext import commands +from discord.ext.commands.cooldowns import BucketType + +from bot.constants import Channels, Client, Colours, Lovefest, Month +from bot.utils.decorators import in_month + +log = logging.getLogger(__name__) + +HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] + + +class BeMyValentine(commands.Cog): + """A cog that sends Valentines to other users!""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.valentines = self.load_json() + + @staticmethod + def load_json() -> dict: + """Load Valentines messages from the static resources.""" + p = Path("bot/resources/valentines/bemyvalentine_valentines.json") + with p.open() as json_data: + valentines = load(json_data) + return valentines + + @in_month(Month.february) + @commands.group(name="lovefest") + async def lovefest_role(self, ctx: commands.Context) -> None: + """ + Subscribe or unsubscribe from the lovefest role. + + The lovefest role makes you eligible to receive anonymous valentines from other users. + + 1) use the command \".lovefest sub\" to get the lovefest role. + 2) use the command \".lovefest unsub\" to get rid of the lovefest role. + """ + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @lovefest_role.command(name="sub") + async def add_role(self, ctx: commands.Context) -> None: + """Adds the lovefest role.""" + user = ctx.author + role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) + if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: + await user.add_roles(role) + await ctx.send("The Lovefest role has been added !") + else: + await ctx.send("You already have the role !") + + @lovefest_role.command(name="unsub") + async def remove_role(self, ctx: commands.Context) -> None: + """Removes the lovefest role.""" + user = ctx.author + role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) + if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: + await ctx.send("You dont have the lovefest role.") + else: + await user.remove_roles(role) + await ctx.send("The lovefest role has been successfully removed !") + + @commands.cooldown(1, 1800, BucketType.user) + @commands.group(name='bemyvalentine', invoke_without_command=True) + async def send_valentine( + self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None + ) -> None: + """ + Send a valentine to user, if specified, or to a random user with the lovefest role. + + syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message] + (optional) + + example: .bemyvalentine (sends valentine as a poem or a compliment to a random user) + example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman) + example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman) + NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command. + """ + if ctx.guild is None: + # This command should only be used in the server + msg = "You are supposed to use this command in the server." + return await ctx.send(msg) + + if user: + if Lovefest.role_id not in [role.id for role in user.roles]: + message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" + return await ctx.send(message) + + if user == ctx.author: + # Well a user can't valentine himself/herself. + return await ctx.send("Come on dude, you can't send a valentine to yourself :expressionless:") + + emoji_1, emoji_2 = self.random_emoji() + lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) + channel = self.bot.get_channel(Channels.seasonalbot_commands) + valentine, title = self.valentine_check(valentine_type) + + if user is None: + author = ctx.author + user = self.random_user(author, lovefest_role.members) + if user is None: + return await ctx.send("There are no users avilable to whome your valentine can be sent.") + + embed = discord.Embed( + title=f'{emoji_1} {title} {user.display_name} {emoji_2}', + description=f'{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**', + color=Colours.pink + ) + await channel.send(user.mention, embed=embed) + + @commands.cooldown(1, 1800, BucketType.user) + @send_valentine.command(name='secret') + async def anonymous( + self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None + ) -> None: + """ + Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role. + + **This command should be DMed to the bot.** + + syntax : .bemyvalentine secret [user](optional) [p/poem/c/compliment/or you can type your own valentine message] + (optional) + + example : .bemyvalentine secret (sends valentine as a poem or a compliment to a random user in DM making you + anonymous) + example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous) + example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to + Iceman in DM making you anonymous) + """ + if ctx.guild is not None: + # This command is only DM specific + msg = "You are not supposed to use this command in the server, DM the command to the bot." + return await ctx.send(msg) + + if user: + if Lovefest.role_id not in [role.id for role in user.roles]: + message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" + return await ctx.send(message) + + if user == ctx.author: + # Well a user cant valentine himself/herself. + return await ctx.send('Come on dude, you cant send a valentine to yourself :expressionless:') + + guild = self.bot.get_guild(id=Client.guild) + emoji_1, emoji_2 = self.random_emoji() + lovefest_role = discord.utils.get(guild.roles, id=Lovefest.role_id) + valentine, title = self.valentine_check(valentine_type) + + if user is None: + author = ctx.author + user = self.random_user(author, lovefest_role.members) + if user is None: + return await ctx.send("There are no users avilable to whome your valentine can be sent.") + + embed = discord.Embed( + title=f'{emoji_1}{title} {user.display_name}{emoji_2}', + description=f'{valentine} \n **{emoji_2}From anonymous{emoji_1}**', + color=Colours.pink + ) + try: + await user.send(embed=embed) + except discord.Forbidden: + await ctx.author.send(f"{user} has DMs disabled, so I couldn't send the message. Sorry!") + else: + await ctx.author.send(f"Your message has been sent to {user}") + + def valentine_check(self, valentine_type: str) -> Tuple[str, str]: + """Return the appropriate Valentine type & title based on the invoking user's input.""" + if valentine_type is None: + valentine, title = self.random_valentine() + + elif valentine_type.lower() in ['p', 'poem']: + valentine = self.valentine_poem() + title = 'A poem dedicated to' + + elif valentine_type.lower() in ['c', 'compliment']: + valentine = self.valentine_compliment() + title = 'A compliment for' + + else: + # in this case, the user decides to type his own valentine. + valentine = valentine_type + title = 'A message for' + return valentine, title + + @staticmethod + def random_user(author: discord.Member, members: discord.Member) -> None: + """ + Picks a random member from the list provided in `members`. + + The invoking author is ignored. + """ + if author in members: + members.remove(author) + + return random.choice(members) if members else None + + @staticmethod + def random_emoji() -> Tuple[str, str]: + """Return two random emoji from the module-defined constants.""" + emoji_1 = random.choice(HEART_EMOJIS) + emoji_2 = random.choice(HEART_EMOJIS) + return emoji_1, emoji_2 + + def random_valentine(self) -> Tuple[str, str]: + """Grabs a random poem or a compliment (any message).""" + valentine_poem = random.choice(self.valentines['valentine_poems']) + valentine_compliment = random.choice(self.valentines['valentine_compliments']) + random_valentine = random.choice([valentine_compliment, valentine_poem]) + if random_valentine == valentine_poem: + title = 'A poem dedicated to' + else: + title = 'A compliment for ' + return random_valentine, title + + def valentine_poem(self) -> str: + """Grabs a random poem.""" + valentine_poem = random.choice(self.valentines['valentine_poems']) + return valentine_poem + + def valentine_compliment(self) -> str: + """Grabs a random compliment.""" + valentine_compliment = random.choice(self.valentines['valentine_compliments']) + return valentine_compliment + + +def setup(bot: commands.Bot) -> None: + """Be my Valentine Cog load.""" + bot.add_cog(BeMyValentine(bot)) + log.info("BeMyValentine cog loaded") diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py new file mode 100644 index 00000000..03d3d7d5 --- /dev/null +++ b/bot/exts/valentines/lovecalculator.py @@ -0,0 +1,104 @@ +import bisect +import hashlib +import json +import logging +import random +from pathlib import Path +from typing import Union + +import discord +from discord import Member +from discord.ext import commands +from discord.ext.commands import BadArgument, Cog, clean_content + +from bot.constants import Roles + +log = logging.getLogger(__name__) + +with Path("bot/resources/valentines/love_matches.json").open() as file: + LOVE_DATA = json.load(file) + LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items()) + + +class LoveCalculator(Cog): + """A cog for calculating the love between two people.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(aliases=('love_calculator', 'love_calc')) + @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) + async def love(self, ctx: commands.Context, who: Union[Member, str], whom: Union[Member, str] = None) -> None: + """ + Tells you how much the two love each other. + + This command accepts users or arbitrary strings as arguments. + Users are converted from: + - User ID + - Mention + - name#discrim + - name + - nickname + + Any two arguments will always yield the same result, though the order of arguments matters: + Running .love joseph erlang will always yield the same result. + Running .love erlang joseph won't yield the same result as .love joseph erlang + + If you want to use multiple words for one argument, you must include quotes. + .love "Zes Vappa" "morning coffee" + + If only one argument is provided, the subject will become one of the helpers at random. + """ + if whom is None: + staff = ctx.guild.get_role(Roles.helpers).members + whom = random.choice(staff) + + def normalize(arg: Union[Member, str]) -> str: + if isinstance(arg, Member): + # If we are given a member, return name#discrim without any extra changes + arg = str(arg) + else: + # Otherwise normalise case and remove any leading/trailing whitespace + arg = arg.strip().title() + # This has to be done manually to be applied to usernames + return clean_content(escape_markdown=True).convert(ctx, arg) + + who, whom = [await normalize(arg) for arg in (who, whom)] + + # Make sure user didn't provide something silly such as 10 spaces + if not (who and whom): + raise BadArgument('Arguments be non-empty strings.') + + # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary) + # + # hashlib is used over the builtin hash() to guarantee same result over multiple runtimes + m = hashlib.sha256(who.encode() + whom.encode()) + # Mod 101 for [0, 100] + love_percent = sum(m.digest()) % 101 + + # We need the -1 due to how bisect returns the point + # see the documentation for further detail + # https://docs.python.org/3/library/bisect.html#bisect.bisect + index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1 + # We already have the nearest "fit" love level + # We only need the dict, so we can ditch the first element + _, data = LOVE_DATA[index] + + status = random.choice(data['titles']) + embed = discord.Embed( + title=status, + description=f'{who} \N{HEAVY BLACK HEART} {whom} scored {love_percent}%!\n\u200b', + color=discord.Color.dark_magenta() + ) + embed.add_field( + name='A letter from Dr. Love:', + value=data['text'] + ) + + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Love calculator Cog load.""" + bot.add_cog(LoveCalculator(bot)) + log.info("LoveCalculator cog loaded") diff --git a/bot/exts/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py new file mode 100644 index 00000000..ce1d7d5b --- /dev/null +++ b/bot/exts/valentines/movie_generator.py @@ -0,0 +1,63 @@ +import logging +import random +from os import environ +from urllib import parse + +import discord +from discord.ext import commands + +TMDB_API_KEY = environ.get("TMDB_API_KEY") + +log = logging.getLogger(__name__) + + +class RomanceMovieFinder(commands.Cog): + """A Cog that returns a random romance movie suggestion to a user.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(name="romancemovie") + async def romance_movie(self, ctx: commands.Context) -> None: + """Randomly selects a romance movie and displays information about it.""" + # Selecting a random int to parse it to the page parameter + random_page = random.randint(0, 20) + # TMDB api params + params = { + "api_key": TMDB_API_KEY, + "language": "en-US", + "sort_by": "popularity.desc", + "include_adult": "false", + "include_video": "false", + "page": random_page, + "with_genres": "10749" + } + # The api request url + request_url = "https://api.themoviedb.org/3/discover/movie?" + parse.urlencode(params) + async with self.bot.http_session.get(request_url) as resp: + # Trying to load the json file returned from the api + try: + data = await resp.json() + # Selecting random result from results object in the json file + selected_movie = random.choice(data["results"]) + + embed = discord.Embed( + title=f":sparkling_heart: {selected_movie['title']} :sparkling_heart:", + description=selected_movie["overview"], + ) + embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}") + embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"]) + embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"]) + await ctx.send(embed=embed) + except KeyError: + warning_message = "A KeyError was raised while fetching information on the movie. The API service" \ + " could be unavailable or the API key could be set incorrectly." + embed = discord.Embed(title=warning_message) + log.warning(warning_message) + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Romance movie Cog load.""" + bot.add_cog(RomanceMovieFinder(bot)) + log.info("RomanceMovieFinder cog loaded") diff --git a/bot/exts/valentines/myvalenstate.py b/bot/exts/valentines/myvalenstate.py new file mode 100644 index 00000000..0256c39a --- /dev/null +++ b/bot/exts/valentines/myvalenstate.py @@ -0,0 +1,87 @@ +import collections +import json +import logging +from pathlib import Path +from random import choice + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/valentines/valenstates.json"), "r") as file: + STATES = json.load(file) + + +class MyValenstate(commands.Cog): + """A Cog to find your most likely Valentine's vacation destination.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + def levenshtein(self, source: str, goal: str) -> int: + """Calculates the Levenshtein Distance between source and goal.""" + if len(source) < len(goal): + return self.levenshtein(goal, source) + if len(source) == 0: + return len(goal) + if len(goal) == 0: + return len(source) + + pre_row = list(range(0, len(source) + 1)) + for i, source_c in enumerate(source): + cur_row = [i + 1] + for j, goal_c in enumerate(goal): + if source_c != goal_c: + cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]) + 1) + else: + cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j])) + pre_row = cur_row + return pre_row[-1] + + @commands.command() + async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None: + """Find the vacation spot(s) with the most matching characters to the invoking user.""" + eq_chars = collections.defaultdict(int) + if name is None: + author = ctx.message.author.name.lower().replace(' ', '') + else: + author = name.lower().replace(' ', '') + + for state in STATES.keys(): + lower_state = state.lower().replace(' ', '') + eq_chars[state] = self.levenshtein(author, lower_state) + + matches = [x for x, y in eq_chars.items() if y == min(eq_chars.values())] + valenstate = choice(matches) + matches.remove(valenstate) + + embed_title = "But there are more!" + if len(matches) > 1: + leftovers = f"{', '.join(matches[:len(matches)-2])}, and {matches[len(matches)-1]}" + embed_text = f"You have {len(matches)} more matches, these being {leftovers}." + elif len(matches) == 1: + embed_title = "But there's another one!" + leftovers = str(matches) + embed_text = f"You have another match, this being {leftovers}." + else: + embed_title = "You have a true match!" + embed_text = "This state is your true Valenstate! There are no states that would suit" \ + " you better" + + embed = discord.Embed( + title=f'Your Valenstate is {valenstate} \u2764', + description=f'{STATES[valenstate]["text"]}', + colour=Colours.pink + ) + embed.add_field(name=embed_title, value=embed_text) + embed.set_image(url=STATES[valenstate]["flag"]) + await ctx.channel.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Valenstate Cog load.""" + bot.add_cog(MyValenstate(bot)) + log.info("MyValenstate cog loaded") diff --git a/bot/exts/valentines/pickuplines.py b/bot/exts/valentines/pickuplines.py new file mode 100644 index 00000000..8b2c9822 --- /dev/null +++ b/bot/exts/valentines/pickuplines.py @@ -0,0 +1,45 @@ +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/valentines/pickup_lines.json"), "r", encoding="utf8") as f: + pickup_lines = load(f) + + +class PickupLine(commands.Cog): + """A cog that gives random cheesy pickup lines.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command() + async def pickupline(self, ctx: commands.Context) -> None: + """ + Gives you a random pickup line. + + Note that most of them are very cheesy. + """ + random_line = random.choice(pickup_lines['lines']) + embed = discord.Embed( + title=':cheese: Your pickup line :cheese:', + description=random_line['line'], + color=Colours.pink + ) + embed.set_thumbnail( + url=random_line.get('image', pickup_lines['placeholder']) + ) + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Pickup lines Cog load.""" + bot.add_cog(PickupLine(bot)) + log.info('PickupLine cog loaded') diff --git a/bot/exts/valentines/savethedate.py b/bot/exts/valentines/savethedate.py new file mode 100644 index 00000000..e0bc3904 --- /dev/null +++ b/bot/exts/valentines/savethedate.py @@ -0,0 +1,42 @@ +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] + +with open(Path("bot/resources/valentines/date_ideas.json"), "r", encoding="utf8") as f: + VALENTINES_DATES = load(f) + + +class SaveTheDate(commands.Cog): + """A cog that gives random suggestion for a Valentine's date.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command() + async def savethedate(self, ctx: commands.Context) -> None: + """Gives you ideas for what to do on a date with your valentine.""" + random_date = random.choice(VALENTINES_DATES['ideas']) + emoji_1 = random.choice(HEART_EMOJIS) + emoji_2 = random.choice(HEART_EMOJIS) + embed = discord.Embed( + title=f"{emoji_1}{random_date['name']}{emoji_2}", + description=f"{random_date['description']}", + colour=Colours.pink + ) + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Save the date Cog Load.""" + bot.add_cog(SaveTheDate(bot)) + log.info("SaveTheDate cog loaded") diff --git a/bot/exts/valentines/valentine_zodiac.py b/bot/exts/valentines/valentine_zodiac.py new file mode 100644 index 00000000..c8d77e75 --- /dev/null +++ b/bot/exts/valentines/valentine_zodiac.py @@ -0,0 +1,58 @@ +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +LETTER_EMOJI = ':love_letter:' +HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] + + +class ValentineZodiac(commands.Cog): + """A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.zodiacs = self.load_json() + + @staticmethod + def load_json() -> dict: + """Load zodiac compatibility from static JSON resource.""" + p = Path("bot/resources/valentines/zodiac_compatibility.json") + with p.open() as json_data: + zodiacs = load(json_data) + return zodiacs + + @commands.command(name="partnerzodiac") + async def counter_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: + """Provides a counter compatible zodiac sign to the given user's zodiac sign.""" + try: + compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.lower()]) + except KeyError: + return await ctx.send(zodiac_sign.capitalize() + " zodiac sign does not exist.") + + emoji1 = random.choice(HEART_EMOJIS) + emoji2 = random.choice(HEART_EMOJIS) + embed = discord.Embed( + title="Zodic Compatibility", + description=f'{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac["Zodiac"]}\n' + f'{emoji2}Compatibility meter : {compatible_zodiac["compatibility_score"]}{emoji2}', + color=Colours.pink + ) + embed.add_field( + name=f'A letter from Dr.Zodiac {LETTER_EMOJI}', + value=compatible_zodiac['description'] + ) + await ctx.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Valentine zodiac Cog load.""" + bot.add_cog(ValentineZodiac(bot)) + log.info("ValentineZodiac cog loaded") diff --git a/bot/exts/valentines/whoisvalentine.py b/bot/exts/valentines/whoisvalentine.py new file mode 100644 index 00000000..b8586dca --- /dev/null +++ b/bot/exts/valentines/whoisvalentine.py @@ -0,0 +1,53 @@ +import json +import logging +from pathlib import Path +from random import choice + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path("bot/resources/valentines/valentine_facts.json"), "r") as file: + FACTS = json.load(file) + + +class ValentineFacts(commands.Cog): + """A Cog for displaying facts about Saint Valentine.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(aliases=('whoisvalentine', 'saint_valentine')) + async def who_is_valentine(self, ctx: commands.Context) -> None: + """Displays info about Saint Valentine.""" + embed = discord.Embed( + title="Who is Saint Valentine?", + description=FACTS['whois'], + color=Colours.pink + ) + embed.set_thumbnail( + url='https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_' + 'facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg' + ) + + await ctx.channel.send(embed=embed) + + @commands.command() + async def valentine_fact(self, ctx: commands.Context) -> None: + """Shows a random fact about Valentine's Day.""" + embed = discord.Embed( + title=choice(FACTS['titles']), + description=choice(FACTS['text']), + color=Colours.pink + ) + + await ctx.channel.send(embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Who is Valentine Cog load.""" + bot.add_cog(ValentineFacts(bot)) + log.info("ValentineFacts cog loaded") diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py deleted file mode 100644 index 7caaf615..00000000 --- a/bot/seasons/__init__.py +++ /dev/null @@ -1,201 +0,0 @@ -import logging -import pkgutil -from datetime import datetime -from pathlib import Path -from typing import List, Optional, Set, Type - -from bot.constants import Colours, Month - -__all__ = ( - "SeasonBase", - "Christmas", - "Easter", - "Halloween", - "Pride", - "Valentines", - "Wildcard", - "get_season_names", - "get_extensions", - "get_current_season", - "get_season", -) - -log = logging.getLogger(__name__) - - -class SeasonBase: - """ - Base for Seasonal classes. - - This serves as the off-season fallback for when no specific - seasons are active. - - Seasons are 'registered' simply by inheriting from `SeasonBase`. - We discover them by calling `__subclasses__`. - """ - - season_name: str = "Evergreen" - bot_name: str = "SeasonalBot" - - colour: str = Colours.soft_green - description: str = "The default season!" - - branding_path: str = "seasonal/evergreen" - - months: Set[Month] = set(Month) - - -class Christmas(SeasonBase): - """Branding for december.""" - - season_name = "Festive season" - bot_name = "Merrybot" - - colour = Colours.soft_red - description = ( - "The time is here to get into the festive spirit! No matter who you are, where you are, " - "or what beliefs you may follow, we hope every one of you enjoy this festive season!" - ) - - branding_path = "seasonal/christmas" - - months = {Month.december} - - -class Easter(SeasonBase): - """Branding for april.""" - - season_name = "Easter" - bot_name = "BunnyBot" - - colour = Colours.bright_green - description = ( - "Bunny here, bunny there, bunny everywhere! Here at Python Discord, we celebrate " - "our version of Easter during the entire month of April." - ) - - branding_path = "seasonal/easter" - - months = {Month.april} - - -class Halloween(SeasonBase): - """Branding for october.""" - - season_name = "Halloween" - bot_name = "NeonBot" - - colour = Colours.orange - description = "Trick or treat?!" - - branding_path = "seasonal/halloween" - - months = {Month.october} - - -class Pride(SeasonBase): - """Branding for june.""" - - season_name = "Pride" - bot_name = "ProudBot" - - colour = Colours.pink - description = ( - "The month of June is a special month for us at Python Discord. It is very important to us " - "that everyone feels welcome here, no matter their origin, identity or sexuality. During the " - "month of June, while some of you are participating in Pride festivals across the world, " - "we will be celebrating individuality and commemorating the history and challenges " - "of the LGBTQ+ community with a Pride event of our own!" - ) - - branding_path = "seasonal/pride" - - months = {Month.june} - - -class Valentines(SeasonBase): - """Branding for february.""" - - season_name = "Valentines" - bot_name = "TenderBot" - - colour = Colours.pink - description = "Love is in the air!" - - branding_path = "seasonal/valentines" - - months = {Month.february} - - -class Wildcard(SeasonBase): - """Branding for august.""" - - season_name = "Wildcard" - bot_name = "RetroBot" - - colour = Colours.purple - description = "A season full of surprises!" - - months = {Month.august} - - -def get_season_names() -> List[str]: - """Return names of all packages located in /bot/seasons/.""" - seasons = [ - package.name - for package in pkgutil.iter_modules(__path__) - if package.ispkg - ] - - return seasons - - -def get_extensions() -> List[str]: - """ - Give a list of dot-separated paths to all extensions. - - The strings are formatted in a way such that the bot's `load_extension` - method can take them. Use this to load all available extensions. - """ - base_path = Path(__path__[0]) - extensions = [] - - for season in get_season_names(): - for module in pkgutil.iter_modules([base_path.joinpath(season)]): - extensions.append(f"bot.seasons.{season}.{module.name}") - - return extensions - - -def get_current_season() -> Type[SeasonBase]: - """Give active season, based on current UTC month.""" - current_month = Month(datetime.utcnow().month) - - active_seasons = tuple( - season - for season in SeasonBase.__subclasses__() - if current_month in season.months - ) - - if not active_seasons: - return SeasonBase - - if len(active_seasons) > 1: - log.warning(f"Multiple active season in month {current_month.name}") - - return active_seasons[0] - - -def get_season(name: str) -> Optional[Type[SeasonBase]]: - """ - Give season such that its class name or its `season_name` attr match `name` (caseless). - - If no such season exists, return None. - """ - name = name.casefold() - - for season in [SeasonBase] + SeasonBase.__subclasses__(): - matches = (season.__name__.casefold(), season.season_name.casefold()) - - if name in matches: - return season diff --git a/bot/seasons/christmas/__init__.py b/bot/seasons/christmas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py deleted file mode 100644 index f7590e04..00000000 --- a/bot/seasons/christmas/adventofcode.py +++ /dev/null @@ -1,744 +0,0 @@ -import asyncio -import json -import logging -import math -import re -from datetime import datetime, timedelta -from pathlib import Path -from typing import List, Tuple - -import aiohttp -import discord -from bs4 import BeautifulSoup -from discord.ext import commands -from pytz import timezone - -from bot.constants import AdventOfCode as AocConfig, Channels, Colours, Emojis, Month, Tokens, WHITELISTED_CHANNELS -from bot.utils import unlocked_role -from bot.utils.decorators import in_month, override_in_channel - -log = logging.getLogger(__name__) - -AOC_REQUEST_HEADER = {"user-agent": "PythonDiscord AoC Event Bot"} -AOC_SESSION_COOKIE = {"session": Tokens.aoc_session_cookie} - -EST = timezone("EST") -COUNTDOWN_STEP = 60 * 5 - -AOC_WHITELIST = WHITELISTED_CHANNELS + (Channels.advent_of_code,) - - -def is_in_advent() -> bool: - """Utility function to check if we are between December 1st and December 25th.""" - # Run the code from the 1st to the 24th - return datetime.now(EST).day in range(1, 25) and datetime.now(EST).month == 12 - - -def time_left_to_aoc_midnight() -> Tuple[datetime, timedelta]: - """Calculates the amount of time left until midnight in UTC-5 (Advent of Code maintainer timezone).""" - # Change all time properties back to 00:00 - todays_midnight = datetime.now(EST).replace(microsecond=0, - second=0, - minute=0, - hour=0) - - # We want tomorrow so add a day on - tomorrow = todays_midnight + timedelta(days=1) - - # Calculate the timedelta between the current time and midnight - return tomorrow, tomorrow - datetime.now(EST) - - -async def countdown_status(bot: commands.Bot) -> None: - """Set the playing status of the bot to the minutes & hours left until the next day's challenge.""" - while is_in_advent(): - _, time_left = time_left_to_aoc_midnight() - - aligned_seconds = int(math.ceil(time_left.seconds / COUNTDOWN_STEP)) * COUNTDOWN_STEP - hours, minutes = aligned_seconds // 3600, aligned_seconds // 60 % 60 - - if aligned_seconds == 0: - playing = f"right now!" - elif aligned_seconds == COUNTDOWN_STEP: - playing = f"in less than {minutes} minutes" - elif hours == 0: - playing = f"in {minutes} minutes" - elif hours == 23: - playing = f"since {60 - minutes} minutes ago" - else: - playing = f"in {hours} hours and {minutes} minutes" - - # Status will look like "Playing in 5 hours and 30 minutes" - await bot.change_presence(activity=discord.Game(playing)) - - # Sleep until next aligned time or a full step if already aligned - delay = time_left.seconds % COUNTDOWN_STEP or COUNTDOWN_STEP - await asyncio.sleep(delay) - - -async def day_countdown(bot: commands.Bot) -> None: - """ - Calculate the number of seconds left until the next day of Advent. - - Once we have calculated this we should then sleep that number and when the time is reached, ping - the Advent of Code role notifying them that the new challenge is ready. - """ - while is_in_advent(): - tomorrow, time_left = time_left_to_aoc_midnight() - - # Correct `time_left.seconds` for the sleep we have after unlocking the role (-5) and adding - # a second (+1) as the bot is consistently ~0.5 seconds early in announcing the puzzles. - await asyncio.sleep(time_left.seconds - 4) - - channel = bot.get_channel(Channels.advent_of_code) - - if not channel: - log.error("Could not find the AoC channel to send notification in") - break - - aoc_role = channel.guild.get_role(AocConfig.role_id) - if not aoc_role: - log.error("Could not find the AoC role to announce the daily puzzle") - break - - async with unlocked_role(aoc_role, delay=5): - puzzle_url = f"https://adventofcode.com/{AocConfig.year}/day/{tomorrow.day}" - - # Check if the puzzle is already available to prevent our members from spamming - # the puzzle page before it's available by making a small HEAD request. - for retry in range(1, 5): - log.debug(f"Checking if the puzzle is already available (attempt {retry}/4)") - async with bot.http_session.head(puzzle_url, raise_for_status=False) as resp: - if resp.status == 200: - log.debug("Puzzle is available; let's send an announcement message.") - break - log.debug(f"The puzzle is not yet available (status={resp.status})") - await asyncio.sleep(10) - else: - log.error("The puzzle does does not appear to be available at this time, canceling announcement") - break - - await channel.send( - f"{aoc_role.mention} Good morning! Day {tomorrow.day} is ready to be attempted. " - f"View it online now at {puzzle_url}. Good luck!" - ) - - # Wait a couple minutes so that if our sleep didn't sleep enough - # time we don't end up announcing twice. - await asyncio.sleep(120) - - -class AdventOfCode(commands.Cog): - """Advent of Code festivities! Ho Ho Ho!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - self._base_url = f"https://adventofcode.com/{AocConfig.year}" - self.global_leaderboard_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" - self.private_leaderboard_url = f"{self._base_url}/leaderboard/private/view/{AocConfig.leaderboard_id}" - - self.about_aoc_filepath = Path("./bot/resources/advent_of_code/about.json") - self.cached_about_aoc = self._build_about_embed() - - self.cached_global_leaderboard = None - self.cached_private_leaderboard = None - - self.countdown_task = None - self.status_task = None - - countdown_coro = day_countdown(self.bot) - self.countdown_task = self.bot.loop.create_task(countdown_coro) - - status_coro = countdown_status(self.bot) - self.status_task = self.bot.loop.create_task(status_coro) - - @in_month(Month.december) - @commands.group(name="adventofcode", aliases=("aoc",)) - @override_in_channel(AOC_WHITELIST) - async def adventofcode_group(self, ctx: commands.Context) -> None: - """All of the Advent of Code commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @adventofcode_group.command( - name="subscribe", - aliases=("sub", "notifications", "notify", "notifs"), - brief="Notifications for new days" - ) - @override_in_channel(AOC_WHITELIST) - async def aoc_subscribe(self, ctx: commands.Context) -> None: - """Assign the role for notifications about new days being ready.""" - role = ctx.guild.get_role(AocConfig.role_id) - unsubscribe_command = f"{ctx.prefix}{ctx.command.root_parent} unsubscribe" - - if role not in ctx.author.roles: - await ctx.author.add_roles(role) - await ctx.send("Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. " - f"You can run `{unsubscribe_command}` to disable them again for you.") - else: - await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " - f"If you don't want them any more, run `{unsubscribe_command}` instead.") - - @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") - @override_in_channel(AOC_WHITELIST) - async def aoc_unsubscribe(self, ctx: commands.Context) -> None: - """Remove the role for notifications about new days being ready.""" - role = ctx.guild.get_role(AocConfig.role_id) - - if role in ctx.author.roles: - await ctx.author.remove_roles(role) - await ctx.send("Okay! You have been __unsubscribed__ from notifications about new Advent of Code tasks.") - else: - await ctx.send("Hey, you don't even get any notifications about new Advent of Code tasks currently anyway.") - - @adventofcode_group.command(name="countdown", aliases=("count", "c"), brief="Return time left until next day") - @override_in_channel(AOC_WHITELIST) - async def aoc_countdown(self, ctx: commands.Context) -> None: - """Return time left until next day.""" - if not is_in_advent(): - datetime_now = datetime.now(EST) - - # Calculate the delta to this & next year's December 1st to see which one is closest and not in the past - this_year = datetime(datetime_now.year, 12, 1, tzinfo=EST) - next_year = datetime(datetime_now.year + 1, 12, 1, tzinfo=EST) - deltas = (dec_first - datetime_now for dec_first in (this_year, next_year)) - delta = min(delta for delta in deltas if delta >= timedelta()) # timedelta() gives 0 duration delta - - # Add a finer timedelta if there's less than a day left - if delta.days == 0: - delta_str = f"approximately {delta.seconds // 3600} hours" - else: - delta_str = f"{delta.days} days" - - await ctx.send(f"The Advent of Code event is not currently running. " - f"The next event will start in {delta_str}.") - return - - tomorrow, time_left = time_left_to_aoc_midnight() - - hours, minutes = time_left.seconds // 3600, time_left.seconds // 60 % 60 - - await ctx.send(f"There are {hours} hours and {minutes} minutes left until day {tomorrow.day}.") - - @adventofcode_group.command(name="about", aliases=("ab", "info"), brief="Learn about Advent of Code") - @override_in_channel(AOC_WHITELIST) - async def about_aoc(self, ctx: commands.Context) -> None: - """Respond with an explanation of all things Advent of Code.""" - await ctx.send("", embed=self.cached_about_aoc) - - @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)") - @override_in_channel(AOC_WHITELIST) - async def join_leaderboard(self, ctx: commands.Context) -> None: - """DM the user the information for joining the PyDis AoC private leaderboard.""" - author = ctx.message.author - log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") - - info_str = ( - "Head over to https://adventofcode.com/leaderboard/private " - f"with code `{AocConfig.leaderboard_join_code}` to join the PyDis private leaderboard!" - ) - try: - await author.send(info_str) - except discord.errors.Forbidden: - log.debug(f"{author.name} ({author.id}) has disabled DMs from server members") - await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") - else: - await ctx.message.add_reaction(Emojis.envelope) - - @adventofcode_group.command( - name="leaderboard", - aliases=("board", "lb"), - brief="Get a snapshot of the PyDis private AoC leaderboard", - ) - @override_in_channel(AOC_WHITELIST) - async def aoc_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: - """ - Pull the top number_of_people_to_display members from the PyDis leaderboard and post an embed. - - For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the - Advent of Code section of the bot constants. number_of_people_to_display values greater than this - limit will default to this maximum and provide feedback to the user. - """ - async with ctx.typing(): - await self._check_leaderboard_cache(ctx) - - if not self.cached_private_leaderboard: - # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() - # Short circuit here if there's an issue - return - - number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) - - # Generate leaderboard table for embed - members_to_print = self.cached_private_leaderboard.top_n(number_of_people_to_display) - table = AocPrivateLeaderboard.build_leaderboard_embed(members_to_print) - - # Build embed - aoc_embed = discord.Embed( - description=f"Total members: {len(self.cached_private_leaderboard.members)}", - colour=Colours.soft_green, - timestamp=self.cached_private_leaderboard.last_updated - ) - aoc_embed.set_author(name="Advent of Code", url=self.private_leaderboard_url) - aoc_embed.set_footer(text="Last Updated") - - await ctx.send( - content=f"Here's the current Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", - embed=aoc_embed, - ) - - @adventofcode_group.command( - name="stats", - aliases=("dailystats", "ds"), - brief="Get daily statistics for the PyDis private leaderboard" - ) - @override_in_channel(AOC_WHITELIST) - async def private_leaderboard_daily_stats(self, ctx: commands.Context) -> None: - """ - Respond with a table of the daily completion statistics for the PyDis private leaderboard. - - Embed will display the total members and the number of users who have completed each day's puzzle - """ - async with ctx.typing(): - await self._check_leaderboard_cache(ctx) - - if not self.cached_private_leaderboard: - # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() - # Short circuit here if there's an issue - return - - # Build ASCII table - total_members = len(self.cached_private_leaderboard.members) - _star = Emojis.star - header = f"{'Day':4}{_star:^8}{_star*2:^4}{'% ' + _star:^8}{'% ' + _star*2:^4}\n{'='*35}" - table = "" - for day, completions in enumerate(self.cached_private_leaderboard.daily_completion_summary): - per_one_star = f"{(completions[0]/total_members)*100:.2f}" - per_two_star = f"{(completions[1]/total_members)*100:.2f}" - - table += f"{day+1:3}){completions[0]:^8}{completions[1]:^6}{per_one_star:^10}{per_two_star:^6}\n" - - table = f"```\n{header}\n{table}```" - - # Build embed - daily_stats_embed = discord.Embed( - colour=Colours.soft_green, timestamp=self.cached_private_leaderboard.last_updated - ) - daily_stats_embed.set_author(name="Advent of Code", url=self._base_url) - daily_stats_embed.set_footer(text="Last Updated") - - await ctx.send( - content=f"Here's the current daily statistics!\n\n{table}", embed=daily_stats_embed - ) - - @adventofcode_group.command( - name="global", - aliases=("globalboard", "gb"), - brief="Get a snapshot of the global AoC leaderboard", - ) - @override_in_channel(AOC_WHITELIST) - async def global_leaderboard(self, ctx: commands.Context, number_of_people_to_display: int = 10) -> None: - """ - Pull the top number_of_people_to_display members from the global AoC leaderboard and post an embed. - - For readability, number_of_people_to_display defaults to 10. A maximum value is configured in the - Advent of Code section of the bot constants. number_of_people_to_display values greater than this - limit will default to this maximum and provide feedback to the user. - """ - async with ctx.typing(): - await self._check_leaderboard_cache(ctx, global_board=True) - - if not self.cached_global_leaderboard: - # Feedback on issues with leaderboard caching are sent by _check_leaderboard_cache() - # Short circuit here if there's an issue - return - - number_of_people_to_display = await self._check_n_entries(ctx, number_of_people_to_display) - - # Generate leaderboard table for embed - members_to_print = self.cached_global_leaderboard.top_n(number_of_people_to_display) - table = AocGlobalLeaderboard.build_leaderboard_embed(members_to_print) - - # Build embed - aoc_embed = discord.Embed(colour=Colours.soft_green, timestamp=self.cached_global_leaderboard.last_updated) - aoc_embed.set_author(name="Advent of Code", url=self._base_url) - aoc_embed.set_footer(text="Last Updated") - - await ctx.send( - f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}", - embed=aoc_embed, - ) - - async def _check_leaderboard_cache(self, ctx: commands.Context, global_board: bool = False) -> None: - """ - Check age of current leaderboard & pull a new one if the board is too old. - - global_board is a boolean to toggle between the global board and the Pydis private board - """ - # Toggle between global & private leaderboards - if global_board: - log.debug("Checking global leaderboard cache") - leaderboard_str = "cached_global_leaderboard" - _shortstr = "global" - else: - log.debug("Checking private leaderboard cache") - leaderboard_str = "cached_private_leaderboard" - _shortstr = "private" - - leaderboard = getattr(self, leaderboard_str) - if not leaderboard: - log.debug(f"No cached {_shortstr} leaderboard found") - await self._boardgetter(global_board) - else: - leaderboard_age = datetime.utcnow() - leaderboard.last_updated - age_seconds = leaderboard_age.total_seconds() - if age_seconds < AocConfig.leaderboard_cache_age_threshold_seconds: - log.debug(f"Cached {_shortstr} leaderboard age less than threshold ({age_seconds} seconds old)") - else: - log.debug(f"Cached {_shortstr} leaderboard age greater than threshold ({age_seconds} seconds old)") - await self._boardgetter(global_board) - - leaderboard = getattr(self, leaderboard_str) - if not leaderboard: - await ctx.send( - "", - embed=_error_embed_helper( - title=f"Something's gone wrong and there's no cached {_shortstr} leaderboard!", - description="Please check in with a staff member.", - ), - ) - - async def _check_n_entries(self, ctx: commands.Context, number_of_people_to_display: int) -> int: - """Check for n > max_entries and n <= 0.""" - max_entries = AocConfig.leaderboard_max_displayed_members - author = ctx.message.author - if not 0 <= number_of_people_to_display <= max_entries: - log.debug( - f"{author.name} ({author.id}) attempted to fetch an invalid number " - f" of entries from the AoC leaderboard ({number_of_people_to_display})" - ) - await ctx.send( - f":x: {author.mention}, number of entries to display must be a positive " - f"integer less than or equal to {max_entries}\n\n" - f"Head to {self.private_leaderboard_url} to view the entire leaderboard" - ) - number_of_people_to_display = max_entries - - return number_of_people_to_display - - def _build_about_embed(self) -> discord.Embed: - """Build and return the informational "About AoC" embed from the resources file.""" - with self.about_aoc_filepath.open("r") as f: - embed_fields = json.load(f) - - about_embed = discord.Embed(title=self._base_url, colour=Colours.soft_green, url=self._base_url) - about_embed.set_author(name="Advent of Code", url=self._base_url) - for field in embed_fields: - about_embed.add_field(**field) - - about_embed.set_footer(text=f"Last Updated (UTC): {datetime.utcnow()}") - - return about_embed - - async def _boardgetter(self, global_board: bool) -> None: - """Invoke the proper leaderboard getter based on the global_board boolean.""" - if global_board: - self.cached_global_leaderboard = await AocGlobalLeaderboard.from_url() - else: - self.cached_private_leaderboard = await AocPrivateLeaderboard.from_url() - - def cog_unload(self) -> None: - """Cancel season-related tasks on cog unload.""" - log.debug("Unloading the cog and canceling the background task.") - self.countdown_task.cancel() - self.status_task.cancel() - - -class AocMember: - """Object representing the Advent of Code user.""" - - def __init__(self, name: str, aoc_id: int, stars: int, starboard: list, local_score: int, global_score: int): - self.name = name - self.aoc_id = aoc_id - self.stars = stars - self.starboard = starboard - self.local_score = local_score - self.global_score = global_score - self.completions = self._completions_from_starboard(self.starboard) - - def __repr__(self): - """Generate a user-friendly representation of the AocMember & their score.""" - return f"<{self.name} ({self.aoc_id}): {self.local_score}>" - - @classmethod - def member_from_json(cls, injson: dict) -> "AocMember": - """ - Generate an AocMember from AoC's private leaderboard API JSON. - - injson is expected to be the dict contained in: - - AoC_APIjson['members'][:str] - - Returns an AocMember object - """ - return cls( - name=injson["name"] if injson["name"] else "Anonymous User", - aoc_id=int(injson["id"]), - stars=injson["stars"], - starboard=cls._starboard_from_json(injson["completion_day_level"]), - local_score=injson["local_score"], - global_score=injson["global_score"], - ) - - @staticmethod - def _starboard_from_json(injson: dict) -> list: - """ - Generate starboard from AoC's private leaderboard API JSON. - - injson is expected to be the dict contained in: - - AoC_APIjson['members'][:str]['completion_day_level'] - - Returns a list of 25 lists, where each nested list contains a pair of booleans representing - the code challenge completion status for that day - """ - # Basic input validation - if not isinstance(injson, dict): - raise ValueError - - # Initialize starboard - starboard = [] - for _i in range(25): - starboard.append([False, False]) - - # Iterate over days, which are the keys of injson (as str) - for day in injson: - idx = int(day) - 1 - # If there is a second star, the first star must be completed - if "2" in injson[day].keys(): - starboard[idx] = [True, True] - # If the day exists in injson, then at least the first star is completed - else: - starboard[idx] = [True, False] - - return starboard - - @staticmethod - def _completions_from_starboard(starboard: list) -> tuple: - """Return days completed, as a (1 star, 2 star) tuple, from starboard.""" - completions = [0, 0] - for day in starboard: - if day[0]: - completions[0] += 1 - if day[1]: - completions[1] += 1 - - return tuple(completions) - - -class AocPrivateLeaderboard: - """Object representing the Advent of Code private leaderboard.""" - - def __init__(self, members: list, owner_id: int, event_year: int): - self.members = members - self._owner_id = owner_id - self._event_year = event_year - self.last_updated = datetime.utcnow() - - self.daily_completion_summary = self.calculate_daily_completion() - - def top_n(self, n: int = 10) -> dict: - """ - Return the top n participants on the leaderboard. - - If n is not specified, default to the top 10 - """ - return self.members[:n] - - def calculate_daily_completion(self) -> List[tuple]: - """ - Calculate member completion rates by day. - - Return a list of tuples for each day containing the number of users who completed each part - of the challenge - """ - daily_member_completions = [] - for day in range(25): - one_star_count = 0 - two_star_count = 0 - for member in self.members: - if member.starboard[day][1]: - one_star_count += 1 - two_star_count += 1 - elif member.starboard[day][0]: - one_star_count += 1 - else: - daily_member_completions.append((one_star_count, two_star_count)) - - return(daily_member_completions) - - @staticmethod - async def json_from_url( - leaderboard_id: int = AocConfig.leaderboard_id, year: int = AocConfig.year - ) -> "AocPrivateLeaderboard": - """ - Request the API JSON from Advent of Code for leaderboard_id for the specified year's event. - - If no year is input, year defaults to the current year - """ - api_url = f"https://adventofcode.com/{year}/leaderboard/private/view/{leaderboard_id}.json" - - log.debug("Querying Advent of Code Private Leaderboard API") - async with aiohttp.ClientSession(cookies=AOC_SESSION_COOKIE, headers=AOC_REQUEST_HEADER) as session: - async with session.get(api_url) as resp: - if resp.status == 200: - raw_dict = await resp.json() - else: - log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") - resp.raise_for_status() - - return raw_dict - - @classmethod - def from_json(cls, injson: dict) -> "AocPrivateLeaderboard": - """Generate an AocPrivateLeaderboard object from AoC's private leaderboard API JSON.""" - return cls( - members=cls._sorted_members(injson["members"]), owner_id=injson["owner_id"], event_year=injson["event"] - ) - - @classmethod - async def from_url(cls) -> "AocPrivateLeaderboard": - """Helper wrapping of AocPrivateLeaderboard.json_from_url and AocPrivateLeaderboard.from_json.""" - api_json = await cls.json_from_url() - return cls.from_json(api_json) - - @staticmethod - def _sorted_members(injson: dict) -> list: - """ - Generate a sorted list of AocMember objects from AoC's private leaderboard API JSON. - - Output list is sorted based on the AocMember.local_score - """ - members = [AocMember.member_from_json(injson[member]) for member in injson] - members.sort(key=lambda x: x.local_score, reverse=True) - - return members - - @staticmethod - def build_leaderboard_embed(members_to_print: List[AocMember]) -> str: - """ - Build a text table from members_to_print, a list of AocMember objects. - - Returns a string to be used as the content of the bot's leaderboard response - """ - stargroup = f"{Emojis.star}, {Emojis.star*2}" - header = f"{' '*3}{'Score'} {'Name':^25} {stargroup:^7}\n{'-'*44}" - table = "" - for i, member in enumerate(members_to_print): - if member.name == "Anonymous User": - name = f"{member.name} #{member.aoc_id}" - else: - name = member.name - - table += ( - f"{i+1:2}) {member.local_score:4} {name:25.25} " - f"({member.completions[0]:2}, {member.completions[1]:2})\n" - ) - else: - table = f"```{header}\n{table}```" - - return table - - -class AocGlobalLeaderboard: - """Object representing the Advent of Code global leaderboard.""" - - def __init__(self, members: List[tuple]): - self.members = members - self.last_updated = datetime.utcnow() - - def top_n(self, n: int = 10) -> dict: - """ - Return the top n participants on the leaderboard. - - If n is not specified, default to the top 10 - """ - return self.members[:n] - - @classmethod - async def from_url(cls) -> "AocGlobalLeaderboard": - """ - Generate an list of tuples for the entries on AoC's global leaderboard. - - Because there is no API for this, web scraping needs to be used - """ - aoc_url = f"https://adventofcode.com/{AocConfig.year}/leaderboard" - - async with aiohttp.ClientSession(headers=AOC_REQUEST_HEADER) as session: - async with session.get(aoc_url) as resp: - if resp.status == 200: - raw_html = await resp.text() - else: - log.warning(f"Bad response received from AoC ({resp.status}), check session cookie") - resp.raise_for_status() - - soup = BeautifulSoup(raw_html, "html.parser") - ele = soup.find_all("div", class_="leaderboard-entry") - - exp = r"(?:[ ]{,2}(\d+)\))?[ ]+(\d+)\s+([\w\(\)\#\@\-\d ]+)" - - lb_list = [] - for entry in ele: - # Strip off the AoC++ decorator - raw_str = entry.text.replace("(AoC++)", "").rstrip() - - # Use a regex to extract the info from the string to unify formatting - # Group 1: Rank - # Group 2: Global Score - # Group 3: Member string - r = re.match(exp, raw_str) - - rank = int(r.group(1)) if r.group(1) else None - global_score = int(r.group(2)) - - member = r.group(3) - if member.lower().startswith("(anonymous"): - # Normalize anonymous user string by stripping () and title casing - member = re.sub(r"[\(\)]", "", member).title() - - lb_list.append((rank, global_score, member)) - - return cls(lb_list) - - @staticmethod - def build_leaderboard_embed(members_to_print: List[tuple]) -> str: - """ - Build a text table from members_to_print, a list of tuples. - - Returns a string to be used as the content of the bot's leaderboard response - """ - header = f"{' '*4}{'Score'} {'Name':^25}\n{'-'*36}" - table = "" - for member in members_to_print: - # In the event of a tie, rank is None - if member[0]: - rank = f"{member[0]:3})" - else: - rank = f"{' ':4}" - table += f"{rank} {member[1]:4} {member[2]:25.25}\n" - else: - table = f"```{header}\n{table}```" - - return table - - -def _error_embed_helper(title: str, description: str) -> discord.Embed: - """Return a red-colored Embed with the given title and description.""" - return discord.Embed(title=title, description=description, colour=discord.Colour.red()) - - -def setup(bot: commands.Bot) -> None: - """Advent of Code Cog load.""" - bot.add_cog(AdventOfCode(bot)) - log.info("AdventOfCode cog loaded") diff --git a/bot/seasons/christmas/hanukkah_embed.py b/bot/seasons/christmas/hanukkah_embed.py deleted file mode 100644 index 62efd04e..00000000 --- a/bot/seasons/christmas/hanukkah_embed.py +++ /dev/null @@ -1,114 +0,0 @@ -import datetime -import logging -from typing import List - -from discord import Embed -from discord.ext import commands - -from bot.constants import Colours, Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - - -class HanukkahEmbed(commands.Cog): - """A cog that returns information about Hanukkah festival.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.url = ("https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" - "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on") - self.hanukkah_days = [] - self.hanukkah_months = [] - self.hanukkah_years = [] - - async def get_hanukkah_dates(self) -> List[str]: - """Gets the dates for hanukkah festival.""" - hanukkah_dates = [] - async with self.bot.http_session.get(self.url) as response: - json_data = await response.json() - festivals = json_data['items'] - for festival in festivals: - if festival['title'].startswith('Chanukah'): - date = festival['date'] - hanukkah_dates.append(date) - return hanukkah_dates - - @in_month(Month.december) - @commands.command(name='hanukkah', aliases=['chanukah']) - async def hanukkah_festival(self, ctx: commands.Context) -> None: - """Tells you about the Hanukkah Festivaltime of festival, festival day, etc).""" - hanukkah_dates = await self.get_hanukkah_dates() - self.hanukkah_dates_split(hanukkah_dates) - hanukkah_start_day = int(self.hanukkah_days[0]) - hanukkah_start_month = int(self.hanukkah_months[0]) - hanukkah_start_year = int(self.hanukkah_years[0]) - hanukkah_end_day = int(self.hanukkah_days[8]) - hanukkah_end_month = int(self.hanukkah_months[8]) - hanukkah_end_year = int(self.hanukkah_years[8]) - - hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day) - hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day) - today = datetime.date.today() - # today = datetime.date(2019, 12, 24) (for testing) - day = str(today.day) - month = str(today.month) - year = str(today.year) - embed = Embed() - embed.title = 'Hanukkah' - embed.colour = Colours.blue - if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years: - if int(day) == hanukkah_start_day: - now = datetime.datetime.utcnow() - now = str(now) - hours = int(now[11:13]) + 4 # using only hours - hanukkah_start_hour = 18 - if hours < hanukkah_start_hour: - embed.description = (f"Hanukkah hasnt started yet, " - f"it will start in about {hanukkah_start_hour-hours} hour/s.") - return await ctx.send(embed=embed) - elif hours > hanukkah_start_hour: - embed.description = (f'It is the starting day of Hanukkah ! ' - f'Its been {hours-hanukkah_start_hour} hours hanukkah started !') - return await ctx.send(embed=embed) - festival_day = self.hanukkah_days.index(day) - number_suffixes = ['st', 'nd', 'rd', 'th'] - suffix = '' - if int(festival_day) == 1: - suffix = number_suffixes[0] - if int(festival_day) == 2: - suffix = number_suffixes[1] - if int(festival_day) == 3: - suffix = number_suffixes[2] - if int(festival_day) > 3: - suffix = number_suffixes[3] - message = '' - for _ in range(1, festival_day + 1): - message += ':menorah:' - embed.description = f'It is the {festival_day}{suffix} day of Hanukkah ! \n {message}' - await ctx.send(embed=embed) - else: - if today < hanukkah_start: - festival_starting_month = hanukkah_start.strftime('%B') - embed.description = (f"Hanukkah has not started yet. " - f"Hanukkah will start at sundown on {hanukkah_start_day}th " - f"of {festival_starting_month}.") - else: - festival_end_month = hanukkah_end.strftime('%B') - embed.description = (f"Looks like you missed Hanukkah !" - f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}.") - - await ctx.send(embed=embed) - - def hanukkah_dates_split(self, hanukkah_dates: List[str]) -> None: - """We are splitting the dates for hanukkah into days, months and years.""" - for date in hanukkah_dates: - self.hanukkah_days.append(date[8:10]) - self.hanukkah_months.append(date[5:7]) - self.hanukkah_years.append(date[0:4]) - - -def setup(bot: commands.Bot) -> None: - """Cog load.""" - bot.add_cog(HanukkahEmbed(bot)) - log.info("Hanukkah embed cog loaded") diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/seasons/easter/april_fools_vids.py b/bot/seasons/easter/april_fools_vids.py deleted file mode 100644 index 4869f510..00000000 --- a/bot/seasons/easter/april_fools_vids.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging -import random -from json import load -from pathlib import Path - -from discord.ext import commands - -log = logging.getLogger(__name__) - - -class AprilFoolVideos(commands.Cog): - """A cog for April Fools' that gets a random April Fools' video from Youtube.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.yt_vids = self.load_json() - self.youtubers = ['google'] # will add more in future - - @staticmethod - def load_json() -> dict: - """A function to load JSON data.""" - p = Path('bot/resources/easter/april_fools_vids.json') - with p.open() as json_file: - all_vids = load(json_file) - return all_vids - - @commands.command(name='fool') - async def april_fools(self, ctx: commands.Context) -> None: - """Get a random April Fools' video from Youtube.""" - random_youtuber = random.choice(self.youtubers) - category = self.yt_vids[random_youtuber] - random_vid = random.choice(category) - await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}") - - -def setup(bot: commands.Bot) -> None: - """April Fools' Cog load.""" - bot.add_cog(AprilFoolVideos(bot)) - log.info('April Fools videos cog loaded!') diff --git a/bot/seasons/easter/avatar_easterifier.py b/bot/seasons/easter/avatar_easterifier.py deleted file mode 100644 index e21e35fc..00000000 --- a/bot/seasons/easter/avatar_easterifier.py +++ /dev/null @@ -1,129 +0,0 @@ -import asyncio -import logging -from io import BytesIO -from pathlib import Path -from typing import Tuple, Union - -import discord -from PIL import Image -from PIL.ImageOps import posterize -from discord.ext import commands - -log = logging.getLogger(__name__) - -COLOURS = [ - (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), - (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), - (135, 206, 235), (0, 204, 204), (64, 224, 208) -] # Pastel colours - Easter-like - - -class AvatarEasterifier(commands.Cog): - """Put an Easter spin on your avatar or image!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @staticmethod - def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]: - """ - Finds the closest easter colour to a given pixel. - - Returns a merge between the original colour and the closest colour - """ - r1, g1, b1 = x - - def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]: - """Finds the difference between a pastel colour and the original pixel colour.""" - r2, g2, b2 = point - return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2) - - closest_colours = sorted(COLOURS, key=lambda point: distance(point)) - r2, g2, b2 = closest_colours[0] - r = (r1 + r2) // 2 - g = (g1 + g2) // 2 - b = (b1 + b2) // 2 - - return (r, g, b) - - @commands.command(pass_context=True, aliases=["easterify"]) - async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None: - """ - This "Easterifies" the user's avatar. - - Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. - If colours are not given, a nice little chocolate bunny will sit in the corner. - Colours are split by spaces, unless you wrap the colour name in double quotes. - Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. - """ - async def send(*args, **kwargs) -> str: - """ - This replaces the original ctx.send. - - When invoking the egg decorating command, the egg itself doesn't print to to the channel. - Returns the message content so that if any errors occur, the error message can be output. - """ - if args: - return args[0] - - async with ctx.typing(): - - # Grabs image of avatar - image_bytes = await ctx.author.avatar_url_as(size=256).read() - - old = Image.open(BytesIO(image_bytes)) - old = old.convert("RGBA") - - # Grabs alpha channel since posterize can't be used with an RGBA image. - alpha = old.getchannel("A").getdata() - old = old.convert("RGB") - old = posterize(old, 6) - - data = old.getdata() - setted_data = set(data) - new_d = {} - - for x in setted_data: - new_d[x] = self.closest(x) - await asyncio.sleep(0) # Ensures discord doesn't break in the background. - new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] - - im = Image.new("RGBA", old.size) - im.putdata(new_data) - - if colours: - send_message = ctx.send - ctx.send = send # Assigns ctx.send to a fake send - egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) - if isinstance(egg, str): # When an error message occurs in eggdecorate. - return await send_message(egg) - - ratio = 64 / egg.height - egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) - egg = egg.convert("RGBA") - im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre. - ctx.send = send_message # Reassigns ctx.send - else: - bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) - im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre. - - bufferedio = BytesIO() - im.save(bufferedio, format="PNG") - - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed - embed = discord.Embed( - name="Your Lovely Easterified Avatar", - description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" - ) - embed.set_image(url="attachment://easterified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Avatar Easterifier Cog load.""" - bot.add_cog(AvatarEasterifier(bot)) - log.info("AvatarEasterifier cog loaded") diff --git a/bot/seasons/easter/bunny_name_generator.py b/bot/seasons/easter/bunny_name_generator.py deleted file mode 100644 index 97c467e1..00000000 --- a/bot/seasons/easter/bunny_name_generator.py +++ /dev/null @@ -1,93 +0,0 @@ -import json -import logging -import random -import re -from pathlib import Path -from typing import List, Union - -from discord.ext import commands - -log = logging.getLogger(__name__) - -with Path("bot/resources/easter/bunny_names.json").open("r", encoding="utf8") as f: - BUNNY_NAMES = json.load(f) - - -class BunnyNameGenerator(commands.Cog): - """Generate a random bunny name, or bunnify your Discord username!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - def find_separators(self, displayname: str) -> Union[List[str], None]: - """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" - new_name = re.split(r'[_.\s]', displayname) - if displayname not in new_name: - return new_name - - def find_vowels(self, displayname: str) -> str: - """ - Finds vowels in the user's display name. - - If the Discord name contains a vowel and the letter y, it will match one or more of these patterns. - - Only the most recently matched pattern will apply the changes. - """ - expressions = [ - (r'a.+y', 'patchy'), - (r'e.+y', 'ears'), - (r'i.+y', 'ditsy'), - (r'o.+y', 'oofy'), - (r'u.+y', 'uffy'), - ] - - for exp, vowel_sub in expressions: - new_name = re.sub(exp, vowel_sub, displayname) - if new_name != displayname: - return new_name - - def append_name(self, displayname: str) -> str: - """Adds a suffix to the end of the Discord name.""" - extensions = ['foot', 'ear', 'nose', 'tail'] - suffix = random.choice(extensions) - appended_name = displayname + suffix - - return appended_name - - @commands.command() - async def bunnyname(self, ctx: commands.Context) -> None: - """Picks a random bunny name from a JSON file.""" - await ctx.send(random.choice(BUNNY_NAMES["names"])) - - @commands.command() - async def bunnifyme(self, ctx: commands.Context) -> None: - """Gets your Discord username and bunnifies it.""" - username = ctx.message.author.display_name - - # If name contains spaces or other separators, get the individual words to randomly bunnify - spaces_in_name = self.find_separators(username) - - # If name contains vowels, see if it matches any of the patterns in this function - # If there are matches, the bunnified name is returned. - vowels_in_name = self.find_vowels(username) - - # Default if the checks above return None - unmatched_name = self.append_name(username) - - if spaces_in_name is not None: - replacements = ['Cotton', 'Fluff', 'Floof' 'Bounce', 'Snuffle', 'Nibble', 'Cuddle', 'Velvetpaw', 'Carrot'] - word_to_replace = random.choice(spaces_in_name) - substitute = random.choice(replacements) - bunnified_name = username.replace(word_to_replace, substitute) - elif vowels_in_name is not None: - bunnified_name = vowels_in_name - elif unmatched_name: - bunnified_name = unmatched_name - - await ctx.send(bunnified_name) - - -def setup(bot: commands.Bot) -> None: - """Bunny Name Generator Cog load.""" - bot.add_cog(BunnyNameGenerator(bot)) - log.info("BunnyNameGenerator cog loaded.") diff --git a/bot/seasons/easter/conversationstarters.py b/bot/seasons/easter/conversationstarters.py deleted file mode 100644 index 3f38ae82..00000000 --- a/bot/seasons/easter/conversationstarters.py +++ /dev/null @@ -1,29 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/easter/starter.json"), "r", encoding="utf8") as f: - starters = json.load(f) - - -class ConvoStarters(commands.Cog): - """Easter conversation topics.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command() - async def topic(self, ctx: commands.Context) -> None: - """Responds with a random topic to start a conversation.""" - await ctx.send(random.choice(starters['starters'])) - - -def setup(bot: commands.Bot) -> None: - """Conversation starters Cog load.""" - bot.add_cog(ConvoStarters(bot)) - log.info("ConvoStarters cog loaded") diff --git a/bot/seasons/easter/easter_riddle.py b/bot/seasons/easter/easter_riddle.py deleted file mode 100644 index f5b1aac7..00000000 --- a/bot/seasons/easter/easter_riddle.py +++ /dev/null @@ -1,101 +0,0 @@ -import asyncio -import logging -import random -from json import load -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -with Path("bot/resources/easter/easter_riddle.json").open("r", encoding="utf8") as f: - RIDDLE_QUESTIONS = load(f) - -TIMELIMIT = 10 - - -class EasterRiddle(commands.Cog): - """This cog contains the command for the Easter quiz!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.winners = [] - self.correct = "" - self.current_channel = None - - @commands.command(aliases=["riddlemethis", "riddleme"]) - async def riddle(self, ctx: commands.Context) -> None: - """ - Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer. - - The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file. - """ - if self.current_channel: - return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") - - self.current_channel = ctx.message.channel - - random_question = random.choice(RIDDLE_QUESTIONS) - question = random_question["question"] - hints = random_question["riddles"] - self.correct = random_question["correct_answer"] - - description = f"You have {TIMELIMIT} seconds before the first hint." - - riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink) - - await ctx.send(embed=riddle_embed) - await asyncio.sleep(TIMELIMIT) - - hint_embed = discord.Embed( - title=f"Here's a hint: {hints[0]}!", - colour=Colours.pink - ) - - await ctx.send(embed=hint_embed) - await asyncio.sleep(TIMELIMIT) - - hint_embed = discord.Embed( - title=f"Here's a hint: {hints[1]}!", - colour=Colours.pink - ) - - await ctx.send(embed=hint_embed) - await asyncio.sleep(TIMELIMIT) - - if self.winners: - win_list = " ".join(self.winners) - content = f"Well done {win_list} for getting it right!" - else: - content = "Nobody got it right..." - - answer_embed = discord.Embed( - title=f"The answer is: {self.correct}!", - colour=Colours.pink - ) - - await ctx.send(content, embed=answer_embed) - - self.winners = [] - self.current_channel = None - - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """If a non-bot user enters a correct answer, their username gets added to self.winners.""" - if self.current_channel != message.channel: - return - - if self.bot.user == message.author: - return - - if message.content.lower() == self.correct.lower(): - self.winners.append(message.author.mention) - - -def setup(bot: commands.Bot) -> None: - """Easter Riddle Cog load.""" - bot.add_cog(EasterRiddle(bot)) - log.info("Easter Riddle bot loaded") diff --git a/bot/seasons/easter/egg_decorating.py b/bot/seasons/easter/egg_decorating.py deleted file mode 100644 index 23df95f1..00000000 --- a/bot/seasons/easter/egg_decorating.py +++ /dev/null @@ -1,119 +0,0 @@ -import json -import logging -import random -from contextlib import suppress -from io import BytesIO -from pathlib import Path -from typing import Union - -import discord -from PIL import Image -from discord.ext import commands - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/evergreen/html_colours.json")) as f: - HTML_COLOURS = json.load(f) - -with open(Path("bot/resources/evergreen/xkcd_colours.json")) as f: - XKCD_COLOURS = json.load(f) - -COLOURS = [ - (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255), - (0, 255, 255, 255), (0, 0, 255, 255), (255, 0, 255, 255), (128, 0, 128, 255) -] # Colours to be replaced - Red, Orange, Yellow, Green, Light Blue, Dark Blue, Pink, Purple - -IRREPLACEABLE = [ - (0, 0, 0, 0), (0, 0, 0, 255) -] # Colours that are meant to stay the same - Transparent and Black - - -class EggDecorating(commands.Cog): - """Decorate some easter eggs!""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - - @staticmethod - def replace_invalid(colour: str) -> Union[int, None]: - """Attempts to match with HTML or XKCD colour names, returning the int value.""" - with suppress(KeyError): - return int(HTML_COLOURS[colour], 16) - with suppress(KeyError): - return int(XKCD_COLOURS[colour], 16) - return None - - @commands.command(aliases=["decorateegg"]) - async def eggdecorate( - self, ctx: commands.Context, *colours: Union[discord.Colour, str] - ) -> Union[Image.Image, discord.Message]: - """ - Picks a random egg design and decorates it using the given colours. - - Colours are split by spaces, unless you wrap the colour name in double quotes. - Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. - """ - if len(colours) < 2: - return await ctx.send("You must include at least 2 colours!") - - invalid = [] - colours = list(colours) - for idx, colour in enumerate(colours): - if isinstance(colour, discord.Colour): - continue - value = self.replace_invalid(colour) - if value: - colours[idx] = discord.Colour(value) - else: - invalid.append(colour) - - if len(invalid) > 1: - return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") - elif len(invalid) == 1: - return await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") - - async with ctx.typing(): - # Expand list to 8 colours - colours_n = len(colours) - if colours_n < 8: - q, r = divmod(8, colours_n) - colours = colours * q + colours[:r] - num = random.randint(1, 6) - im = Image.open(Path(f"bot/resources/easter/easter_eggs/design{num}.png")) - data = list(im.getdata()) - - replaceable = {x for x in data if x not in IRREPLACEABLE} - replaceable = sorted(replaceable, key=COLOURS.index) - - replacing_colours = {colour: colours[i] for i, colour in enumerate(replaceable)} - new_data = [] - for x in data: - if x in replacing_colours: - new_data.append((*replacing_colours[x].to_rgb(), 255)) - # Also ensures that the alpha channel has a value - else: - new_data.append(x) - new_im = Image.new(im.mode, im.size) - new_im.putdata(new_data) - - bufferedio = BytesIO() - new_im.save(bufferedio, format="PNG") - - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="egg.png") # Creates file to be used in embed - embed = discord.Embed( - title="Your Colourful Easter Egg", - description="Here is your pretty little egg. Hope you like it!" - ) - embed.set_image(url="attachment://egg.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - return new_im - - -def setup(bot: commands.bot) -> None: - """Egg decorating Cog load.""" - bot.add_cog(EggDecorating(bot)) - log.info("EggDecorating cog loaded.") diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py deleted file mode 100644 index 99a80b28..00000000 --- a/bot/seasons/easter/egg_facts.py +++ /dev/null @@ -1,61 +0,0 @@ -import logging -import random -from json import load -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Channels, Colours, Month -from bot.utils.decorators import seasonal_task - -log = logging.getLogger(__name__) - - -class EasterFacts(commands.Cog): - """ - A cog contains a command that will return an easter egg fact when called. - - It also contains a background task which sends an easter egg fact in the event channel everyday. - """ - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.facts = self.load_json() - - self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily()) - - @staticmethod - def load_json() -> dict: - """Load a list of easter egg facts from the resource JSON file.""" - p = Path("bot/resources/easter/easter_egg_facts.json") - with p.open(encoding="utf8") as f: - return load(f) - - @seasonal_task(Month.april) - async def send_egg_fact_daily(self) -> None: - """A background task that sends an easter egg fact in the event channel everyday.""" - await self.bot.wait_until_ready() - - channel = self.bot.get_channel(Channels.seasonalbot_commands) - await channel.send(embed=self.make_embed()) - - @commands.command(name='eggfact', aliases=['fact']) - async def easter_facts(self, ctx: commands.Context) -> None: - """Get easter egg facts.""" - embed = self.make_embed() - await ctx.send(embed=embed) - - def make_embed(self) -> discord.Embed: - """Makes a nice embed for the message to be sent.""" - return discord.Embed( - colour=Colours.soft_red, - title="Easter Egg Fact", - description=random.choice(self.facts) - ) - - -def setup(bot: commands.Bot) -> None: - """Easter Egg facts cog load.""" - bot.add_cog(EasterFacts(bot)) - log.info("EasterFacts cog loaded") diff --git a/bot/seasons/easter/egghead_quiz.py b/bot/seasons/easter/egghead_quiz.py deleted file mode 100644 index bd179fe2..00000000 --- a/bot/seasons/easter/egghead_quiz.py +++ /dev/null @@ -1,120 +0,0 @@ -import asyncio -import logging -import random -from json import load -from pathlib import Path -from typing import Union - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/easter/egghead_questions.json"), "r", encoding="utf8") as f: - EGGHEAD_QUESTIONS = load(f) - - -EMOJIS = [ - '\U0001f1e6', '\U0001f1e7', '\U0001f1e8', '\U0001f1e9', '\U0001f1ea', - '\U0001f1eb', '\U0001f1ec', '\U0001f1ed', '\U0001f1ee', '\U0001f1ef', - '\U0001f1f0', '\U0001f1f1', '\U0001f1f2', '\U0001f1f3', '\U0001f1f4', - '\U0001f1f5', '\U0001f1f6', '\U0001f1f7', '\U0001f1f8', '\U0001f1f9', - '\U0001f1fa', '\U0001f1fb', '\U0001f1fc', '\U0001f1fd', '\U0001f1fe', - '\U0001f1ff' -] # Regional Indicators A-Z (used for voting) - -TIMELIMIT = 30 - - -class EggheadQuiz(commands.Cog): - """This cog contains the command for the Easter quiz!""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.quiz_messages = {} - - @commands.command(aliases=["eggheadquiz", "easterquiz"]) - async def eggquiz(self, ctx: commands.Context) -> None: - """ - Gives a random quiz question, waits 30 seconds and then outputs the answer. - - Also informs of the percentages and votes of each option - """ - random_question = random.choice(EGGHEAD_QUESTIONS) - question, answers = random_question["question"], random_question["answers"] - answers = [(EMOJIS[i], a) for i, a in enumerate(answers)] - correct = EMOJIS[random_question["correct_answer"]] - - valid_emojis = [emoji for emoji, _ in answers] - - description = f"You have {TIMELIMIT} seconds to vote.\n\n" - description += "\n".join([f"{emoji} -> **{answer}**" for emoji, answer in answers]) - - q_embed = discord.Embed(title=question, description=description, colour=Colours.pink) - - msg = await ctx.send(embed=q_embed) - for emoji in valid_emojis: - await msg.add_reaction(emoji) - - self.quiz_messages[msg.id] = valid_emojis - - await asyncio.sleep(TIMELIMIT) - - del self.quiz_messages[msg.id] - - msg = await ctx.channel.fetch_message(msg.id) # Refreshes message - - total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions - - if total_no == 0: - return await msg.delete() # To avoid ZeroDivisionError if nobody reacts - - results = ["**VOTES:**"] - for emoji, _ in answers: - num = [len(await r.users().flatten()) for r in msg.reactions if str(r.emoji) == emoji][0] - 1 - percent = round(100 * num / total_no) - s = "" if num == 1 else "s" - string = f"{emoji} - {num} vote{s} ({percent}%)" - results.append(string) - - mentions = " ".join([ - u.mention for u in [ - await r.users().flatten() for r in msg.reactions if str(r.emoji) == correct - ][0] if not u.bot - ]) - - content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..." - - a_embed = discord.Embed( - title=f"The correct answer was {correct}!", - description="\n".join(results), - colour=Colours.pink - ) - - await ctx.send(content, embed=a_embed) - - @staticmethod - async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool: - """Returns whether a given user has reacted more than once to a given message.""" - users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction] - return users.count(user.id) > 1 # Old reaction plus new reaction - - @commands.Cog.listener() - async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None: - """Listener to listen specifically for reactions of quiz messages.""" - if user.bot: - return - if reaction.message.id not in self.quiz_messages: - return - if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]: - return await reaction.message.remove_reaction(reaction, user) - if await self.already_reacted(reaction.message, user): - return await reaction.message.remove_reaction(reaction, user) - - -def setup(bot: commands.Bot) -> None: - """Egghead Quiz Cog load.""" - bot.add_cog(EggheadQuiz(bot)) - log.info("EggheadQuiz bot loaded") diff --git a/bot/seasons/easter/traditions.py b/bot/seasons/easter/traditions.py deleted file mode 100644 index 9529823f..00000000 --- a/bot/seasons/easter/traditions.py +++ /dev/null @@ -1,31 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/easter/traditions.json"), "r", encoding="utf8") as f: - traditions = json.load(f) - - -class Traditions(commands.Cog): - """A cog which allows users to get a random easter tradition or custom from a random country.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(aliases=('eastercustoms',)) - async def easter_tradition(self, ctx: commands.Context) -> None: - """Responds with a random tradition or custom.""" - random_country = random.choice(list(traditions)) - - await ctx.send(f"{random_country}:\n{traditions[random_country]}") - - -def setup(bot: commands.Bot) -> None: - """Traditions Cog load.""" - bot.add_cog(Traditions(bot)) - log.info("Traditions cog loaded") diff --git a/bot/seasons/evergreen/8bitify.py b/bot/seasons/evergreen/8bitify.py deleted file mode 100644 index 60062fc1..00000000 --- a/bot/seasons/evergreen/8bitify.py +++ /dev/null @@ -1,54 +0,0 @@ -from io import BytesIO - -import discord -from PIL import Image -from discord.ext import commands - - -class EightBitify(commands.Cog): - """Make your avatar 8bit!""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - - @staticmethod - def pixelate(image: Image) -> Image: - """Takes an image and pixelates it.""" - return image.resize((32, 32)).resize((1024, 1024)) - - @staticmethod - def quantize(image: Image) -> Image: - """Reduces colour palette to 256 colours.""" - return image.quantize(colors=32) - - @commands.command(name="8bitify") - async def eightbit_command(self, ctx: commands.Context) -> None: - """Pixelates your avatar and changes the palette to an 8bit one.""" - async with ctx.typing(): - image_bytes = await ctx.author.avatar_url.read() - avatar = Image.open(BytesIO(image_bytes)) - avatar = avatar.convert("RGBA").resize((1024, 1024)) - - eightbit = self.pixelate(avatar) - eightbit = self.quantize(eightbit) - - bufferedio = BytesIO() - eightbit.save(bufferedio, format="PNG") - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="8bitavatar.png") - - embed = discord.Embed( - title="Your 8-bit avatar", - description='Here is your avatar. I think it looks all cool and "retro"' - ) - - embed.set_image(url="attachment://8bitavatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Cog load.""" - bot.add_cog(EightBitify(bot)) diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/seasons/evergreen/battleship.py b/bot/seasons/evergreen/battleship.py deleted file mode 100644 index 9b8aaa48..00000000 --- a/bot/seasons/evergreen/battleship.py +++ /dev/null @@ -1,444 +0,0 @@ -import asyncio -import logging -import random -import re -import typing -from dataclasses import dataclass -from functools import partial - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - - -@dataclass -class Square: - """Each square on the battleship grid - if they contain a boat and if they've been aimed at.""" - - boat: typing.Optional[str] - aimed: bool - - -Grid = typing.List[typing.List[Square]] -EmojiSet = typing.Dict[typing.Tuple[bool, bool], str] - - -@dataclass -class Player: - """Each player in the game - their messages for the boards and their current grid.""" - - user: discord.Member - board: discord.Message - opponent_board: discord.Message - grid: Grid - - -# The name of the ship and its size -SHIPS = { - "Carrier": 5, - "Battleship": 4, - "Cruiser": 3, - "Submarine": 3, - "Destroyer": 2, -} - - -# For these two variables, the first boolean is whether the square is a ship (True) or not (False). -# The second boolean is whether the player has aimed for that square (True) or not (False) - -# This is for the player's own board which shows the location of their own ships. -SHIP_EMOJIS = { - (True, True): ":fire:", - (True, False): ":ship:", - (False, True): ":anger:", - (False, False): ":ocean:", -} - -# This is for the opposing player's board which only shows aimed locations. -HIDDEN_EMOJIS = { - (True, True): ":red_circle:", - (True, False): ":black_circle:", - (False, True): ":white_circle:", - (False, False): ":black_circle:", -} - -# For the top row of the board -LETTERS = ( - ":stop_button::regional_indicator_a::regional_indicator_b::regional_indicator_c::regional_indicator_d:" - ":regional_indicator_e::regional_indicator_f::regional_indicator_g::regional_indicator_h:" - ":regional_indicator_i::regional_indicator_j:" -) - -# For the first column of the board -NUMBERS = [ - ":one:", - ":two:", - ":three:", - ":four:", - ":five:", - ":six:", - ":seven:", - ":eight:", - ":nine:", - ":keycap_ten:", -] - -CROSS_EMOJI = "\u274e" -HAND_RAISED_EMOJI = "\U0001f64b" - - -class Game: - """A Battleship Game.""" - - def __init__( - self, - bot: commands.Bot, - channel: discord.TextChannel, - player1: discord.Member, - player2: discord.Member - ) -> None: - - self.bot = bot - self.public_channel = channel - - self.p1 = Player(player1, None, None, self.generate_grid()) - self.p2 = Player(player2, None, None, self.generate_grid()) - - self.gameover: bool = False - - self.turn: typing.Optional[discord.Member] = None - self.next: typing.Optional[discord.Member] = None - - self.match: typing.Optional[typing.Match] = None - self.surrender: bool = False - - self.setup_grids() - - @staticmethod - def generate_grid() -> Grid: - """Generates a grid by instantiating the Squares.""" - return [[Square(None, False) for _ in range(10)] for _ in range(10)] - - @staticmethod - def format_grid(player: Player, emojiset: EmojiSet) -> str: - """ - Gets and formats the grid as a list into a string to be output to the DM. - - Also adds the Letter and Number indexes. - """ - grid = [ - [emojiset[bool(square.boat), square.aimed] for square in row] - for row in player.grid - ] - - rows = ["".join([number] + row) for number, row in zip(NUMBERS, grid)] - return "\n".join([LETTERS] + rows) - - @staticmethod - def get_square(grid: Grid, square: str) -> Square: - """Grabs a square from a grid with an inputted key.""" - index = ord(square[0]) - ord("A") - number = int(square[1:]) - - return grid[number-1][index] # -1 since lists are indexed from 0 - - async def game_over( - self, - *, - winner: discord.Member, - loser: discord.Member - ) -> None: - """Removes games from list of current games and announces to public chat.""" - await self.public_channel.send(f"Game Over! {winner.mention} won against {loser.mention}") - - for player in (self.p1, self.p2): - grid = self.format_grid(player, SHIP_EMOJIS) - await self.public_channel.send(f"{player.user}'s Board:\n{grid}") - - @staticmethod - def check_sink(grid: Grid, boat: str) -> bool: - """Checks if all squares containing a given boat have sunk.""" - return all(square.aimed for row in grid for square in row if square.boat == boat) - - @staticmethod - def check_gameover(grid: Grid) -> bool: - """Checks if all boats have been sunk.""" - return all(square.aimed for row in grid for square in row if square.boat) - - def setup_grids(self) -> None: - """Places the boats on the grids to initialise the game.""" - for player in (self.p1, self.p2): - for name, size in SHIPS.items(): - while True: # Repeats if about to overwrite another boat - ship_collision = False - coords = [] - - coord1 = random.randint(0, 9) - coord2 = random.randint(0, 10 - size) - - if random.choice((True, False)): # Vertical or Horizontal - x, y = coord1, coord2 - xincr, yincr = 0, 1 - else: - x, y = coord2, coord1 - xincr, yincr = 1, 0 - - for i in range(size): - new_x = x + (xincr * i) - new_y = y + (yincr * i) - if player.grid[new_x][new_y].boat: # Check if there's already a boat - ship_collision = True - break - coords.append((new_x, new_y)) - if not ship_collision: # If not overwriting any other boat spaces, break loop - break - - for x, y in coords: - player.grid[x][y].boat = name - - async def print_grids(self) -> None: - """Prints grids to the DM channels.""" - # Convert squares into Emoji - - boards = [ - self.format_grid(player, emojiset) - for emojiset in (HIDDEN_EMOJIS, SHIP_EMOJIS) - for player in (self.p1, self.p2) - ] - - locations = ( - (self.p2, "opponent_board"), (self.p1, "opponent_board"), - (self.p1, "board"), (self.p2, "board") - ) - - for board, location in zip(boards, locations): - player, attr = location - if getattr(player, attr): - await getattr(player, attr).edit(content=board) - else: - setattr(player, attr, await player.user.send(board)) - - def predicate(self, message: discord.Message) -> bool: - """Predicate checking the message typed for each turn.""" - if message.author == self.turn.user and message.channel == self.turn.user.dm_channel: - if message.content.lower() == "surrender": - self.surrender = True - return True - self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip()) - if not self.match: - self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI)) - return bool(self.match) - - async def take_turn(self) -> typing.Optional[Square]: - """Lets the player who's turn it is choose a square.""" - square = None - turn_message = await self.turn.user.send( - "It's your turn! Type the square you want to fire at. Format it like this: A1\n" - "Type `surrender` to give up" - ) - await self.next.user.send("Their turn", delete_after=3.0) - while True: - try: - await self.bot.wait_for("message", check=self.predicate, timeout=60.0) - except asyncio.TimeoutError: - await self.turn.user.send("You took too long. Game over!") - await self.next.user.send(f"{self.turn.user} took too long. Game over!") - await self.public_channel.send( - f"Game over! {self.turn.user.mention} timed out so {self.next.user.mention} wins!" - ) - self.gameover = True - break - else: - if self.surrender: - await self.next.user.send(f"{self.turn.user} surrendered. Game over!") - await self.public_channel.send( - f"Game over! {self.turn.user.mention} surrendered to {self.next.user.mention}!" - ) - self.gameover = True - break - square = self.get_square(self.next.grid, self.match.string) - if square.aimed: - await self.turn.user.send("You've already aimed at this square!", delete_after=3.0) - else: - break - await turn_message.delete() - return square - - async def hit(self, square: Square, alert_messages: typing.List[discord.Message]) -> None: - """Occurs when a player successfully aims for a ship.""" - await self.turn.user.send("Hit!", delete_after=3.0) - alert_messages.append(await self.next.user.send("Hit!")) - if self.check_sink(self.next.grid, square.boat): - await self.turn.user.send(f"You've sunk their {square.boat} ship!", delete_after=3.0) - alert_messages.append(await self.next.user.send(f"Oh no! Your {square.boat} ship sunk!")) - if self.check_gameover(self.next.grid): - await self.turn.user.send("You win!") - await self.next.user.send("You lose!") - self.gameover = True - await self.game_over(winner=self.turn.user, loser=self.next.user) - - async def start_game(self) -> None: - """Begins the game.""" - await self.p1.user.send(f"You're playing battleship with {self.p2.user}.") - await self.p2.user.send(f"You're playing battleship with {self.p1.user}.") - - alert_messages = [] - - self.turn = self.p1 - self.next = self.p2 - - while True: - await self.print_grids() - - if self.gameover: - return - - square = await self.take_turn() - if not square: - return - square.aimed = True - - for message in alert_messages: - await message.delete() - - alert_messages = [] - alert_messages.append(await self.next.user.send(f"{self.turn.user} aimed at {self.match.string}!")) - - if square.boat: - await self.hit(square, alert_messages) - if self.gameover: - return - else: - await self.turn.user.send("Miss!", delete_after=3.0) - alert_messages.append(await self.next.user.send("Miss!")) - - self.turn, self.next = self.next, self.turn - - -class Battleship(commands.Cog): - """Play the classic game Battleship!""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.games: typing.List[Game] = [] - self.waiting: typing.List[discord.Member] = [] - - def predicate( - self, - ctx: commands.Context, - announcement: discord.Message, - reaction: discord.Reaction, - user: discord.Member - ) -> bool: - """Predicate checking the criteria for the announcement message.""" - if self.already_playing(ctx.author): # If they've joined a game since requesting a player 2 - return True # Is dealt with later on - if ( - user.id not in (ctx.me.id, ctx.author.id) - and str(reaction.emoji) == HAND_RAISED_EMOJI - and reaction.message.id == announcement.id - ): - if self.already_playing(user): - self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) - self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) - return False - - if user in self.waiting: - self.bot.loop.create_task(ctx.send( - f"{user.mention} Please cancel your game first before joining another one." - )) - self.bot.loop.create_task(announcement.remove_reaction(reaction, user)) - return False - - return True - - if ( - user.id == ctx.author.id - and str(reaction.emoji) == CROSS_EMOJI - and reaction.message.id == announcement.id - ): - return True - return False - - def already_playing(self, player: discord.Member) -> bool: - """Check if someone is already in a game.""" - return any(player in (game.p1.user, game.p2.user) for game in self.games) - - @commands.group(invoke_without_command=True) - @commands.guild_only() - async def battleship(self, ctx: commands.Context) -> None: - """ - Play a game of Battleship with someone else! - - This will set up a message waiting for someone else to react and play along. - The game takes place entirely in DMs. - Make sure you have your DMs open so that the bot can message you. - """ - if self.already_playing(ctx.author): - return await ctx.send("You're already playing a game!") - - if ctx.author in self.waiting: - return await ctx.send("You've already sent out a request for a player 2") - - announcement = await ctx.send( - "**Battleship**: A new game is about to start!\n" - f"Press {HAND_RAISED_EMOJI} to play against {ctx.author.mention}!\n" - f"(Cancel the game with {CROSS_EMOJI}.)" - ) - self.waiting.append(ctx.author) - await announcement.add_reaction(HAND_RAISED_EMOJI) - await announcement.add_reaction(CROSS_EMOJI) - - try: - reaction, user = await self.bot.wait_for( - "reaction_add", - check=partial(self.predicate, ctx, announcement), - timeout=60.0 - ) - except asyncio.TimeoutError: - self.waiting.remove(ctx.author) - await announcement.delete() - return await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") - - if str(reaction.emoji) == CROSS_EMOJI: - self.waiting.remove(ctx.author) - await announcement.delete() - return await ctx.send(f"{ctx.author.mention} Game cancelled.") - - await announcement.delete() - self.waiting.remove(ctx.author) - if self.already_playing(ctx.author): - return - try: - game = Game(self.bot, ctx.channel, ctx.author, user) - self.games.append(game) - await game.start_game() - self.games.remove(game) - except discord.Forbidden: - await ctx.send( - f"{ctx.author.mention} {user.mention} " - "Game failed. This is likely due to you not having your DMs open. Check and try again." - ) - self.games.remove(game) - except Exception: - # End the game in the event of an unforseen error so the players aren't stuck in a game - await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed") - self.games.remove(game) - raise - - @battleship.command(name="ships", aliases=["boats"]) - async def battleship_ships(self, ctx: commands.Context) -> None: - """Lists the ships that are found on the battleship grid.""" - embed = discord.Embed(colour=Colours.blue) - embed.add_field(name="Name", value="\n".join(SHIPS)) - embed.add_field(name="Size", value="\n".join(str(size) for size in SHIPS.values())) - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Cog load.""" - bot.add_cog(Battleship(bot)) - log.info("Battleship cog loaded") diff --git a/bot/seasons/evergreen/bookmark.py b/bot/seasons/evergreen/bookmark.py deleted file mode 100644 index e703e07b..00000000 --- a/bot/seasons/evergreen/bookmark.py +++ /dev/null @@ -1,65 +0,0 @@ -import logging -import random - -import discord -from discord.ext import commands - -from bot.constants import Colours, ERROR_REPLIES, Emojis, Icons - -log = logging.getLogger(__name__) - - -class Bookmark(commands.Cog): - """Creates personal bookmarks by relaying a message link to the user's DMs.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(name="bookmark", aliases=("bm", "pin")) - async def bookmark( - self, - ctx: commands.Context, - target_message: discord.Message, - *, - title: str = "Bookmark" - ) -> None: - """Send the author a link to `target_message` via DMs.""" - # Prevent users from bookmarking a message in a channel they don't have access to - permissions = ctx.author.permissions_in(target_message.channel) - if not permissions.read_messages: - log.info(f"{ctx.author} tried to bookmark a message in #{target_message.channel} but has no permissions") - embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - color=Colours.soft_red, - description="You don't have permission to view this channel." - ) - await ctx.send(embed=embed) - return - - embed = discord.Embed( - title=title, - colour=Colours.soft_green, - description=target_message.content - ) - embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})") - embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url) - embed.set_thumbnail(url=Icons.bookmark) - - try: - await ctx.author.send(embed=embed) - except discord.Forbidden: - error_embed = discord.Embed( - title=random.choice(ERROR_REPLIES), - description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark", - colour=Colours.soft_red - ) - await ctx.send(embed=error_embed) - else: - log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'") - await ctx.message.add_reaction(Emojis.envelope) - - -def setup(bot: commands.Bot) -> None: - """Load the Bookmark cog.""" - bot.add_cog(Bookmark(bot)) - log.info("Bookmark cog loaded") diff --git a/bot/seasons/evergreen/branding.py b/bot/seasons/evergreen/branding.py deleted file mode 100644 index 2eb563ea..00000000 --- a/bot/seasons/evergreen/branding.py +++ /dev/null @@ -1,504 +0,0 @@ -import asyncio -import itertools -import logging -import random -import typing as t -from datetime import datetime, time, timedelta - -import arrow -import discord -from discord.embeds import EmptyEmbed -from discord.ext import commands - -from bot.bot import SeasonalBot -from bot.constants import Branding, Colours, Emojis, MODERATION_ROLES, Tokens -from bot.seasons import SeasonBase, get_current_season, get_season -from bot.utils.decorators import with_role -from bot.utils.exceptions import BrandingError - -log = logging.getLogger(__name__) -log.setLevel(logging.DEBUG) - -STATUS_OK = 200 # HTTP status code - -FILE_BANNER = "banner.png" -FILE_AVATAR = "avatar.png" -SERVER_ICONS = "server_icons" - -BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" - -PARAMS = {"ref": "seasonal-structure"} # Target branch -HEADERS = {"Accept": "application/vnd.github.v3+json"} # Ensure we use API v3 - -# A Github token is not necessary for the cog to operate, -# unauthorized requests are however limited to 60 per hour -if Tokens.github: - HEADERS["Authorization"] = f"token {Tokens.github}" - - -class GithubFile(t.NamedTuple): - """ - Represents a remote file on Github. - - The sha hash is kept so that we can determine that a file has changed, - despite its filename remaining unchanged. - """ - - download_url: str - path: str - sha: str - - -async def pretty_files(files: t.Iterable[GithubFile]) -> str: - """Provide a human-friendly representation of `files`.""" - return "\n".join(file.path for file in files) - - -async def time_until_midnight() -> timedelta: - """ - Determine amount of time until the next-up UTC midnight. - - The exact `midnight` moment is actually delayed to 5 seconds after, in order - to avoid potential problems due to imprecise sleep. - """ - now = datetime.utcnow() - tomorrow = now + timedelta(days=1) - midnight = datetime.combine(tomorrow, time(second=5)) - - return midnight - now - - -class BrandingManager(commands.Cog): - """ - Manages the guild's branding. - - The purpose of this cog is to help automate the synchronization of the branding - repository with the guild. It is capable of discovering assets in the repository - via Github's API, resolving download urls for them, and delegating - to the `bot` instance to upload them to the guild. - - The cog is designed to be entirely autonomous. The `daemon` background task awakens once - a day (see `time_until_midnight`) to detect new seasons, or to cycle icons within a single - season. If the `Branding.autostart` constant is True, the `daemon` will launch on start-up, - otherwise it can be controlled via the `daemon` cmd group. - - All supported operations, e.g. setting seasons, applying the branding, or cycling icons, can - also be invoked manually, via the following API: - - branding set - - Set the cog's internal state to represent `season_name`, if it exists. - - If no `season_name` is given, set chronologically current season. - - This will not automatically apply the season's branding to the guild, - the cog's state can be detached from the guild. - - Seasons can therefore be 'previewed' using this command. - - branding info - - View detailed information about resolved assets for current season. - - branding refresh - - Refresh internal state, i.e. synchronize with branding repository. - - branding apply - - Apply the current internal state to the guild, i.e. upload the assets. - - branding cycle - - If there are multiple available icons for current season, randomly pick - and apply the next one. - - The daemon calls these methods autonomously as appropriate. The use of this cog - is locked to moderation roles. As it performs media asset uploads, it is prone to - rate-limits - the `apply` command should be used with caution. The `set` command can, - however, be used freely to 'preview' seasonal branding and check whether paths have been - resolved as appropriate. - - While the bot is in debug mode, it will 'mock' asset uploads by logging the passed - download urls and pretending that the upload was successful. Make use of this - to test this cog's behaviour. - """ - - current_season: t.Type[SeasonBase] - - banner: t.Optional[GithubFile] - avatar: t.Optional[GithubFile] - - available_icons: t.List[GithubFile] - remaining_icons: t.List[GithubFile] - - should_cycle: t.Iterator - - daemon: t.Optional[asyncio.Task] - - def __init__(self, bot: SeasonalBot) -> None: - """ - Assign safe default values on init. - - At this point, we don't have information about currently available branding. - Most of these attributes will be overwritten once the daemon connects, or once - the `refresh` command is used. - """ - self.bot = bot - self.current_season = get_current_season() - - self.banner = None - self.avatar = None - - self.should_cycle = itertools.cycle([False]) - - self.available_icons = [] - self.remaining_icons = [] - - if Branding.autostart: - self.daemon = self.bot.loop.create_task(self._daemon_func()) - else: - self.daemon = None - - @property - def _daemon_running(self) -> bool: - """True if the daemon is currently active, False otherwise.""" - return self.daemon is not None and not self.daemon.done() - - async def _daemon_func(self) -> None: - """ - Manage all automated behaviour of the BrandingManager cog. - - Once a day, the daemon will perform the following tasks: - - Update `current_season` - - Poll Github API to see if the available branding for `current_season` has changed - - Update assets if changes are detected (banner, guild icon, bot avatar, bot nickname) - - Check whether it's time to cycle guild icons - - The internal loop runs once when activated, then periodically at the time - given by `time_until_midnight`. - - All method calls in the internal loop are considered safe, i.e. no errors propagate - to the daemon's loop. The daemon itself does not perform any error handling on its own. - """ - await self.bot.wait_until_ready() - - while True: - self.current_season = get_current_season() - branding_changed = await self.refresh() - - if branding_changed: - await self.apply() - - elif next(self.should_cycle): - await self.cycle() - - until_midnight = await time_until_midnight() - await asyncio.sleep(until_midnight.total_seconds()) - - async def _info_embed(self) -> discord.Embed: - """Make an informative embed representing current season.""" - info_embed = discord.Embed(description=self.current_season.description, colour=self.current_season.colour) - - # If we're in a non-evergreen season, also show active months - if self.current_season is not SeasonBase: - active_months = ", ".join(m.name for m in self.current_season.months) - title = f"{self.current_season.season_name} ({active_months})" - else: - title = self.current_season.season_name - - # Use the author field to show the season's name and avatar if available - info_embed.set_author(name=title, icon_url=self.avatar.download_url if self.avatar else EmptyEmbed) - - banner = self.banner.path if self.banner is not None else "Unavailable" - info_embed.add_field(name="Banner", value=banner, inline=False) - - avatar = self.avatar.path if self.avatar is not None else "Unavailable" - info_embed.add_field(name="Avatar", value=avatar, inline=False) - - icons = await pretty_files(self.available_icons) or "Unavailable" - info_embed.add_field(name="Available icons", value=icons, inline=False) - - # Only display cycle frequency if we're actually cycling - if len(self.available_icons) > 1 and Branding.cycle_frequency: - info_embed.set_footer(text=f"Icon cycle frequency: {Branding.cycle_frequency}") - - return info_embed - - async def _reset_remaining_icons(self) -> None: - """Set `remaining_icons` to a shuffled copy of `available_icons`.""" - self.remaining_icons = random.sample(self.available_icons, k=len(self.available_icons)) - - async def _reset_should_cycle(self) -> None: - """ - Reset the `should_cycle` counter based on configured frequency. - - Counter will always yield False if either holds: - - Branding.cycle_frequency is falsey - - There are fewer than 2 available icons for current season - - Cycling can be easily turned off, and we prevent re-uploading the same icon repeatedly. - """ - if len(self.available_icons) > 1 and Branding.cycle_frequency: - wait_period = [False] * (Branding.cycle_frequency - 1) - counter = itertools.cycle(wait_period + [True]) - else: - counter = itertools.cycle([False]) - - self.should_cycle = counter - - async def _get_files(self, path: str, include_dirs: bool = False) -> t.Dict[str, GithubFile]: - """ - Poll `path` in branding repo for information about present files. - - If `include_dirs` is False (default), only returns files at `path`. - Otherwise, will return both files and directories. Never returns symlinks. - - Return dict mapping from filename to corresponding `GithubFile` instance. - This may return an empty dict if the response status is non-200, - or if the target directory is empty. - """ - url = f"{BRANDING_URL}/{path}" - async with self.bot.http_session.get(url, headers=HEADERS, params=PARAMS) as resp: - # Short-circuit if we get non-200 response - if resp.status != STATUS_OK: - log.error(f"Github API returned non-200 response: {resp}") - return {} - directory = await resp.json() # Directory at `path` - - allowed_types = {"file", "dir"} if include_dirs else {"file"} - return { - file["name"]: GithubFile(file["download_url"], file["path"], file["sha"]) - for file in directory - if file["type"] in allowed_types - } - - async def refresh(self) -> bool: - """ - Poll Github API to refresh currently available icons. - - If the current season is not the evergreen, and lacks at least one asset, - we also poll the evergreen seasonal dir as fallback for missing assets. - - Finally, if neither the seasonal nor fallback branding directories contain - an asset, it will simply be ignored. - - Return True if the branding has changed. This will be the case when we enter - a new season, or when something changes in the current seasons's directory - in the branding repository. - """ - old_branding = (self.banner, self.avatar, self.available_icons) - seasonal_dir = await self._get_files(self.current_season.branding_path, include_dirs=True) - - # Only make a call to the fallback directory if there is something to be gained - branding_incomplete = any( - asset not in seasonal_dir - for asset in (FILE_BANNER, FILE_AVATAR, SERVER_ICONS) - ) - if branding_incomplete and self.current_season is not SeasonBase: - fallback_dir = await self._get_files(SeasonBase.branding_path, include_dirs=True) - else: - fallback_dir = {} - - # Resolve assets in this directory, None is a safe value - self.banner = seasonal_dir.get(FILE_BANNER) or fallback_dir.get(FILE_BANNER) - self.avatar = seasonal_dir.get(FILE_AVATAR) or fallback_dir.get(FILE_AVATAR) - - # Now resolve server icons by making a call to the proper sub-directory - if SERVER_ICONS in seasonal_dir: - icons_dir = await self._get_files(f"{self.current_season.branding_path}/{SERVER_ICONS}") - self.available_icons = list(icons_dir.values()) - - elif SERVER_ICONS in fallback_dir: - icons_dir = await self._get_files(f"{SeasonBase.branding_path}/{SERVER_ICONS}") - self.available_icons = list(icons_dir.values()) - - else: - self.available_icons = [] # This should never be the case, but an empty list is a safe value - - # GithubFile instances carry a `sha` attr so this will pick up if a file changes - branding_changed = old_branding != (self.banner, self.avatar, self.available_icons) - - if branding_changed: - log.info(f"New branding detected (season: {self.current_season.season_name})") - await self._reset_remaining_icons() - await self._reset_should_cycle() - - return branding_changed - - async def cycle(self) -> bool: - """ - Apply the next-up server icon. - - Returns True if an icon is available and successfully gets applied, False otherwise. - """ - if not self.available_icons: - log.info("Cannot cycle: no icons for this season") - return False - - if not self.remaining_icons: - await self._reset_remaining_icons() - log.info(f"Set remaining icons: {await pretty_files(self.remaining_icons)}") - - next_up, *self.remaining_icons = self.remaining_icons - success = await self.bot.set_icon(next_up.download_url) - - return success - - async def apply(self) -> t.List[str]: - """ - Apply current branding to the guild and bot. - - This delegates to the bot instance to do all the work. We only provide download urls - for available assets. Assets unavailable in the branding repo will be ignored. - - Returns a list of names of all failed assets. An asset is considered failed - if it isn't found in the branding repo, or if something goes wrong while the - bot is trying to apply it. - - An empty list denotes that all assets have been applied successfully. - """ - report = {asset: False for asset in ("banner", "avatar", "nickname", "icon")} - - if self.banner is not None: - report["banner"] = await self.bot.set_banner(self.banner.download_url) - - if self.avatar is not None: - report["avatar"] = await self.bot.set_avatar(self.avatar.download_url) - - if self.current_season.bot_name: - report["nickname"] = await self.bot.set_nickname(self.current_season.bot_name) - - report["icon"] = await self.cycle() - - failed_assets = [asset for asset, succeeded in report.items() if not succeeded] - return failed_assets - - @with_role(*MODERATION_ROLES) - @commands.group(name="branding") - async def branding_cmds(self, ctx: commands.Context) -> None: - """Manual branding control.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @branding_cmds.command(name="info", aliases=["status"]) - async def branding_info(self, ctx: commands.Context) -> None: - """ - Show assets for current season. - - This can be used to confirm that assets have been resolved properly. - When `apply` is used, it attempts to upload exactly the assets listed here. - """ - await ctx.send(embed=await self._info_embed()) - - @branding_cmds.command(name="refresh") - async def branding_refresh(self, ctx: commands.Context) -> None: - """ - Refresh current season from branding repository. - - Polls Github API to refresh assets available for current season. - """ - async with ctx.typing(): - await self.refresh() - await self.branding_info(ctx) - - @branding_cmds.command(name="cycle") - async def branding_cycle(self, ctx: commands.Context) -> None: - """ - Apply the next-up guild icon, if multiple are available. - - The order is random. - """ - async with ctx.typing(): - success = await self.cycle() - if not success: - raise BrandingError("Failed to cycle icon") - - response = discord.Embed(description=f"Success {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - @branding_cmds.command(name="apply") - async def branding_apply(self, ctx: commands.Context) -> None: - """ - Apply current season's branding to the guild. - - Use `info` to check which assets will be applied. Shows which assets have - failed to be applied, if any. - """ - async with ctx.typing(): - failed_assets = await self.apply() - if failed_assets: - raise BrandingError(f"Failed to apply following assets: {', '.join(failed_assets)}") - - response = discord.Embed(description=f"All assets applied {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - @branding_cmds.command(name="set") - async def branding_set(self, ctx: commands.Context, *, season_name: t.Optional[str] = None) -> None: - """ - Manually set season, or reset to current if none given. - - Season search is a case-less comparison against both seasonal class name, - and its `season_name` attr. - - This only pre-loads the cog's internal state to the chosen season, but does not - automatically apply the branding. As that is an expensive operation, the `apply` - command must be called explicitly after this command finishes. - - This means that this command can be used to 'preview' a season gathering info - about its available assets, without applying them to the guild. - - If the daemon is running, it will automatically reset the season to current when - it wakes up. The season set via this command can therefore remain 'detached' from - what it should be - the daemon will make sure that it's set back properly. - """ - if season_name is None: - new_season = get_current_season() - else: - new_season = get_season(season_name) - if new_season is None: - raise BrandingError("No such season exists") - - if self.current_season is new_season: - raise BrandingError(f"Season {self.current_season.season_name} already active") - - self.current_season = new_season - async with ctx.typing(): - await self.refresh() - await self.branding_info(ctx) - - @branding_cmds.group(name="daemon", aliases=["d", "task"]) - async def daemon_group(self, ctx: commands.Context) -> None: - """Control the background daemon.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @daemon_group.command(name="status") - async def daemon_status(self, ctx: commands.Context) -> None: - """Check whether daemon is currently active.""" - if self._daemon_running: - remaining_time = (arrow.utcnow() + await time_until_midnight()).humanize() - response = discord.Embed(description=f"Daemon running {Emojis.ok_hand}", colour=Colours.soft_green) - response.set_footer(text=f"Next refresh {remaining_time}") - else: - response = discord.Embed(description="Daemon not running", colour=Colours.soft_red) - - await ctx.send(embed=response) - - @daemon_group.command(name="start") - async def daemon_start(self, ctx: commands.Context) -> None: - """If the daemon isn't running, start it.""" - if self._daemon_running: - raise BrandingError("Daemon already running!") - - self.daemon = self.bot.loop.create_task(self._daemon_func()) - response = discord.Embed(description=f"Daemon started {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - @daemon_group.command(name="stop") - async def daemon_stop(self, ctx: commands.Context) -> None: - """If the daemon is running, stop it.""" - if not self._daemon_running: - raise BrandingError("Daemon not running!") - - self.daemon.cancel() - response = discord.Embed(description=f"Daemon stopped {Emojis.ok_hand}", colour=Colours.soft_green) - await ctx.send(embed=response) - - -def setup(bot: SeasonalBot) -> None: - """Load BrandingManager cog.""" - bot.add_cog(BrandingManager(bot)) - log.info("BrandingManager cog loaded") diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py deleted file mode 100644 index d268dab1..00000000 --- a/bot/seasons/evergreen/error_handler.py +++ /dev/null @@ -1,111 +0,0 @@ -import logging -import math -import random -from typing import Iterable, Union - -from discord import Embed, Message -from discord.ext import commands - -from bot.constants import Colours, ERROR_REPLIES, NEGATIVE_REPLIES -from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure -from bot.utils.exceptions import BrandingError - -log = logging.getLogger(__name__) - - -class CommandErrorHandler(commands.Cog): - """A error handler for the PythonDiscord server.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @staticmethod - def revert_cooldown_counter(command: commands.Command, message: Message) -> None: - """Undoes the last cooldown counter for user-error cases.""" - if command._buckets.valid: - bucket = command._buckets.get_bucket(message) - bucket._tokens = min(bucket.rate, bucket._tokens + 1) - logging.debug("Cooldown counter reverted as the command was not used correctly.") - - @staticmethod - def error_embed(message: str, title: Union[Iterable, str] = ERROR_REPLIES) -> Embed: - """Build a basic embed with red colour and either a random error title or a title provided.""" - embed = Embed(colour=Colours.soft_red) - if isinstance(title, str): - embed.title = title - else: - embed.title = random.choice(title) - embed.description = message - return embed - - @commands.Cog.listener() - async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: - """Activates when a command opens an error.""" - if hasattr(ctx.command, 'on_error'): - logging.debug("A command error occured but the command had it's own error handler.") - return - - error = getattr(error, 'original', error) - logging.debug( - f"Error Encountered: {type(error).__name__} - {str(error)}, " - f"Command: {ctx.command}, " - f"Author: {ctx.author}, " - f"Channel: {ctx.channel}" - ) - - if isinstance(error, commands.CommandNotFound): - return - - if isinstance(error, BrandingError): - await ctx.send(embed=self.error_embed(str(error))) - return - - if isinstance(error, (InChannelCheckFailure, InMonthCheckFailure)): - await ctx.send(embed=self.error_embed(str(error), NEGATIVE_REPLIES), delete_after=7.5) - return - - if isinstance(error, commands.UserInputError): - self.revert_cooldown_counter(ctx.command, ctx.message) - embed = self.error_embed( - f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```" - ) - await ctx.send(embed=embed) - return - - if isinstance(error, commands.CommandOnCooldown): - mins, secs = divmod(math.ceil(error.retry_after), 60) - embed = self.error_embed( - f"This command is on cooldown:\nPlease retry in {mins} minutes {secs} seconds.", - NEGATIVE_REPLIES - ) - await ctx.send(embed=embed, delete_after=7.5) - return - - if isinstance(error, commands.DisabledCommand): - await ctx.send(embed=self.error_embed("This command has been disabled.", NEGATIVE_REPLIES)) - return - - if isinstance(error, commands.NoPrivateMessage): - await ctx.send(embed=self.error_embed("This command can only be used in the server.", NEGATIVE_REPLIES)) - return - - if isinstance(error, commands.BadArgument): - self.revert_cooldown_counter(ctx.command, ctx.message) - embed = self.error_embed( - "The argument you provided was invalid: " - f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```" - ) - await ctx.send(embed=embed) - return - - if isinstance(error, commands.CheckFailure): - await ctx.send(embed=self.error_embed("You are not authorized to use this command.", NEGATIVE_REPLIES)) - return - - log.exception(f"Unhandled command error: {str(error)}", exc_info=error) - - -def setup(bot: commands.Bot) -> None: - """Error handler Cog load.""" - bot.add_cog(CommandErrorHandler(bot)) - log.info("CommandErrorHandler cog loaded") diff --git a/bot/seasons/evergreen/fun.py b/bot/seasons/evergreen/fun.py deleted file mode 100644 index 889ae079..00000000 --- a/bot/seasons/evergreen/fun.py +++ /dev/null @@ -1,148 +0,0 @@ -import functools -import logging -import random -from typing import Callable, Tuple, Union - -from discord import Embed, Message -from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context, MessageConverter - -from bot import utils -from bot.constants import Emojis - -log = logging.getLogger(__name__) - -UWU_WORDS = { - "fi": "fwi", - "l": "w", - "r": "w", - "some": "sum", - "th": "d", - "thing": "fing", - "tho": "fo", - "you're": "yuw'we", - "your": "yur", - "you": "yuw", -} - - -class Fun(Cog): - """A collection of general commands for fun.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @commands.command() - async def roll(self, ctx: Context, num_rolls: int = 1) -> None: - """Outputs a number of random dice emotes (up to 6).""" - output = "" - if num_rolls > 6: - num_rolls = 6 - elif num_rolls < 1: - output = ":no_entry: You must roll at least once." - for _ in range(num_rolls): - terning = f"terning{random.randint(1, 6)}" - output += getattr(Emojis, terning, '') - await ctx.send(output) - - @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) - async def uwu_command(self, ctx: Context, *, text: str) -> None: - """ - Converts a given `text` into it's uwu equivalent. - - Also accepts a valid discord Message ID or link. - """ - conversion_func = functools.partial( - utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True - ) - text, embed = await Fun._get_text_and_embed(ctx, text) - # Convert embed if it exists - if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) - converted_text = conversion_func(text) - # Don't put >>> if only embed present - if converted_text: - converted_text = f">>> {converted_text.lstrip('> ')}" - await ctx.send(content=converted_text, embed=embed) - - @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",)) - async def randomcase_command(self, ctx: Context, *, text: str) -> None: - """ - Randomly converts the casing of a given `text`. - - Also accepts a valid discord Message ID or link. - """ - def conversion_func(text: str) -> str: - """Randomly converts the casing of a given string.""" - return "".join( - char.upper() if round(random.random()) else char.lower() for char in text - ) - text, embed = await Fun._get_text_and_embed(ctx, text) - # Convert embed if it exists - if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) - converted_text = conversion_func(text) - # Don't put >>> if only embed present - if converted_text: - converted_text = f">>> {converted_text.lstrip('> ')}" - await ctx.send(content=converted_text, embed=embed) - - @staticmethod - async def _get_text_and_embed(ctx: Context, text: str) -> Tuple[str, Union[Embed, None]]: - """ - Attempts to extract the text and embed from a possible link to a discord Message. - - Returns a tuple of: - str: If `text` is a valid discord Message, the contents of the message, else `text`. - Union[Embed, None]: The embed if found in the valid Message, else None - """ - embed = None - message = await Fun._get_discord_message(ctx, text) - if isinstance(message, Message): - text = message.content - # Take first embed because we can't send multiple embeds - if message.embeds: - embed = message.embeds[0] - return (text, embed) - - @staticmethod - async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]: - """ - Attempts to convert a given `text` to a discord Message object and return it. - - Conversion will succeed if given a discord Message ID or link. - Returns `text` if the conversion fails. - """ - try: - text = await MessageConverter().convert(ctx, text) - except commands.BadArgument: - log.debug(f"Input '{text:.20}...' is not a valid Discord Message") - return text - - @staticmethod - def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: - """ - Converts the text in an embed using a given conversion function, then return the embed. - - Only modifies the following fields: title, description, footer, fields - """ - embed_dict = embed.to_dict() - - embed_dict["title"] = func(embed_dict.get("title", "")) - embed_dict["description"] = func(embed_dict.get("description", "")) - - if "footer" in embed_dict: - embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) - - if "fields" in embed_dict: - for field in embed_dict["fields"]: - field["name"] = func(field.get("name", "")) - field["value"] = func(field.get("value", "")) - - return Embed.from_dict(embed_dict) - - -def setup(bot: commands.Bot) -> None: - """Fun Cog load.""" - bot.add_cog(Fun(bot)) - log.info("Fun cog loaded") diff --git a/bot/seasons/evergreen/game.py b/bot/seasons/evergreen/game.py deleted file mode 100644 index d43b1ad6..00000000 --- a/bot/seasons/evergreen/game.py +++ /dev/null @@ -1,395 +0,0 @@ -import difflib -import logging -import random -from datetime import datetime as dt -from enum import IntEnum -from typing import Any, Dict, List, Optional, Tuple - -from aiohttp import ClientSession -from discord import Embed -from discord.ext import tasks -from discord.ext.commands import Cog, Context, group - -from bot.bot import SeasonalBot -from bot.constants import STAFF_ROLES, Tokens -from bot.utils.decorators import with_role -from bot.utils.pagination import ImagePaginator, LinePaginator - -# Base URL of IGDB API -BASE_URL = "https://api-v3.igdb.com" - -HEADERS = { - "user-key": Tokens.igdb, - "Accept": "application/json" -} - -logger = logging.getLogger(__name__) - -# --------- -# TEMPLATES -# --------- - -# Body templates -# Request body template for get_games_list -GAMES_LIST_BODY = ( - "fields cover.image_id, first_release_date, total_rating, name, storyline, url, platforms.name, status," - "involved_companies.company.name, summary, age_ratings.category, age_ratings.rating, total_rating_count;" - "{sort} {limit} {offset} {genre} {additional}" -) - -# Request body template for get_companies_list -COMPANIES_LIST_BODY = ( - "fields name, url, start_date, logo.image_id, developed.name, published.name, description;" - "offset {offset}; limit {limit};" -) - -# Request body template for games search -SEARCH_BODY = 'fields name, url, storyline, total_rating, total_rating_count; limit 50; search "{term}";' - -# Pages templates -# Game embed layout -GAME_PAGE = ( - "**[{name}]({url})**\n" - "{description}" - "**Release Date:** {release_date}\n" - "**Rating:** {rating}/100 :star: (based on {rating_count} ratings)\n" - "**Platforms:** {platforms}\n" - "**Status:** {status}\n" - "**Age Ratings:** {age_ratings}\n" - "**Made by:** {made_by}\n\n" - "{storyline}" -) - -# .games company command page layout -COMPANY_PAGE = ( - "**[{name}]({url})**\n" - "{description}" - "**Founded:** {founded}\n" - "**Developed:** {developed}\n" - "**Published:** {published}" -) - -# For .games search command line layout -GAME_SEARCH_LINE = ( - "**[{name}]({url})**\n" - "{rating}/100 :star: (based on {rating_count} ratings)\n" -) - -# URL templates -COVER_URL = "https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg" -LOGO_URL = "https://images.igdb.com/igdb/image/upload/t_logo_med/{image_id}.png" - -# Create aliases for complex genre names -ALIASES = { - "Role-playing (rpg)": ["Role playing", "Rpg"], - "Turn-based strategy (tbs)": ["Turn based strategy", "Tbs"], - "Real time strategy (rts)": ["Real time strategy", "Rts"], - "Hack and slash/beat 'em up": ["Hack and slash"] -} - - -class GameStatus(IntEnum): - """Game statuses in IGDB API.""" - - Released = 0 - Alpha = 2 - Beta = 3 - Early = 4 - Offline = 5 - Cancelled = 6 - Rumored = 7 - - -class AgeRatingCategories(IntEnum): - """IGDB API Age Rating categories IDs.""" - - ESRB = 1 - PEGI = 2 - - -class AgeRatings(IntEnum): - """PEGI/ESRB ratings IGDB API IDs.""" - - Three = 1 - Seven = 2 - Twelve = 3 - Sixteen = 4 - Eighteen = 5 - RP = 6 - EC = 7 - E = 8 - E10 = 9 - T = 10 - M = 11 - AO = 12 - - -class Games(Cog): - """Games Cog contains commands that collect data from IGDB.""" - - def __init__(self, bot: SeasonalBot): - self.bot = bot - self.http_session: ClientSession = bot.http_session - - self.genres: Dict[str, int] = {} - - self.refresh_genres_task.start() - - @tasks.loop(hours=1.0) - async def refresh_genres_task(self) -> None: - """Refresh genres in every hour.""" - try: - await self._get_genres() - except Exception as e: - logger.warning(f"There was error while refreshing genres: {e}") - return - logger.info("Successfully refreshed genres.") - - def cog_unload(self) -> None: - """Cancel genres refreshing start when unloading Cog.""" - self.refresh_genres_task.cancel() - logger.info("Successfully stopped Genres Refreshing task.") - - async def _get_genres(self) -> None: - """Create genres variable for games command.""" - body = "fields name; limit 100;" - async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=HEADERS) as resp: - result = await resp.json() - - genres = {genre["name"].capitalize(): genre["id"] for genre in result} - - # Replace complex names with names from ALIASES - for genre_name, genre in genres.items(): - if genre_name in ALIASES: - for alias in ALIASES[genre_name]: - self.genres[alias] = genre - else: - self.genres[genre_name] = genre - - @group(name="games", aliases=["game"], invoke_without_command=True) - async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str] = None) -> None: - """ - Get random game(s) by genre from IGDB. Use .games genres command to get all available genres. - - Also support amount parameter, what max is 25 and min 1, default 5. Supported formats: - - .games - - .games - """ - # When user didn't specified genre, send help message - if genre is None: - await ctx.send_help("games") - return - - # Capitalize genre for check - genre = "".join(genre).capitalize() - - # Check for amounts, max is 25 and min 1 - if not 1 <= amount <= 25: - await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") - return - - # Get games listing, if genre don't exist, show error message with possibilities. - # Offset must be random, due otherwise we will get always same result (offset show in which position should - # API start returning result) - try: - games = await self.get_games_list(amount, self.genres[genre], offset=random.randint(0, 150)) - except KeyError: - possibilities = "`, `".join(difflib.get_close_matches(genre, self.genres)) - await ctx.send(f"Invalid genre `{genre}`. {f'Maybe you meant `{possibilities}`?' if possibilities else ''}") - return - - # Create pages and paginate - pages = [await self.create_page(game) for game in games] - - await ImagePaginator.paginate(pages, ctx, Embed(title=f"Random {genre.title()} Games")) - - @games.command(name="top", aliases=["t"]) - async def top(self, ctx: Context, amount: int = 10) -> None: - """ - Get current Top games in IGDB. - - Support amount parameter. Max is 25, min is 1. - """ - if not 1 <= amount <= 25: - await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") - return - - games = await self.get_games_list(amount, sort="total_rating desc", - additional_body="where total_rating >= 90; sort total_rating_count desc;") - - pages = [await self.create_page(game) for game in games] - await ImagePaginator.paginate(pages, ctx, Embed(title=f"Top {amount} Games")) - - @games.command(name="genres", aliases=["genre", "g"]) - async def genres(self, ctx: Context) -> None: - """Get all available genres.""" - await ctx.send(f"Currently available genres: {', '.join(f'`{genre}`' for genre in self.genres)}") - - @games.command(name="search", aliases=["s"]) - async def search(self, ctx: Context, *, search_term: str) -> None: - """Find games by name.""" - lines = await self.search_games(search_term) - - await LinePaginator.paginate(lines, ctx, Embed(title=f"Game Search Results: {search_term}"), empty=False) - - @games.command(name="company", aliases=["companies"]) - async def company(self, ctx: Context, amount: int = 5) -> None: - """ - Get random Game Companies companies from IGDB API. - - Support amount parameter. Max is 25, min is 1. - """ - if not 1 <= amount <= 25: - await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") - return - - # Get companies listing. Provide limit for limiting how much companies will be returned. Get random offset to - # get (almost) every time different companies (offset show in which position should API start returning result) - companies = await self.get_companies_list(limit=amount, offset=random.randint(0, 150)) - pages = [await self.create_company_page(co) for co in companies] - - await ImagePaginator.paginate(pages, ctx, Embed(title="Random Game Companies")) - - @with_role(*STAFF_ROLES) - @games.command(name="refresh", aliases=["r"]) - async def refresh_genres_command(self, ctx: Context) -> None: - """Refresh .games command genres.""" - try: - await self._get_genres() - except Exception as e: - await ctx.send(f"There was error while refreshing genres: `{e}`") - return - await ctx.send("Successfully refreshed genres.") - - async def get_games_list(self, - amount: int, - genre: Optional[str] = None, - sort: Optional[str] = None, - additional_body: str = "", - offset: int = 0 - ) -> List[Dict[str, Any]]: - """ - Get list of games from IGDB API by parameters that is provided. - - Amount param show how much games this get, genre is genre ID and at least one genre in game must this when - provided. Sort is sorting by specific field and direction, ex. total_rating desc/asc (total_rating is field, - desc/asc is direction). Additional_body is field where you can pass extra search parameters. Offset show start - position in API. - """ - # Create body of IGDB API request, define fields, sorting, offset, limit and genre - params = { - "sort": f"sort {sort};" if sort else "", - "limit": f"limit {amount};", - "offset": f"offset {offset};" if offset else "", - "genre": f"where genres = ({genre});" if genre else "", - "additional": additional_body - } - body = GAMES_LIST_BODY.format(**params) - - # Do request to IGDB API, create headers, URL, define body, return result - async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: - return await resp.json() - - async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]: - """Create content of Game Page.""" - # Create cover image URL from template - url = COVER_URL.format(**{"image_id": data["cover"]["image_id"] if "cover" in data else ""}) - - # Get release date separately with checking - release_date = dt.utcfromtimestamp(data["first_release_date"]).date() if "first_release_date" in data else "?" - - # Create Age Ratings value - rating = ", ".join(f"{AgeRatingCategories(age['category']).name} {AgeRatings(age['rating']).name}" - for age in data["age_ratings"]) if "age_ratings" in data else "?" - - companies = [c["company"]["name"] for c in data["involved_companies"]] if "involved_companies" in data else "?" - - # Create formatting for template page - formatting = { - "name": data["name"], - "url": data["url"], - "description": f"{data['summary']}\n\n" if "summary" in data else "\n", - "release_date": release_date, - "rating": round(data["total_rating"] if "total_rating" in data else 0, 2), - "rating_count": data["total_rating_count"] if "total_rating_count" in data else "?", - "platforms": ", ".join(platform["name"] for platform in data["platforms"]) if "platforms" in data else "?", - "status": GameStatus(data["status"]).name if "status" in data else "?", - "age_ratings": rating, - "made_by": ", ".join(companies), - "storyline": data["storyline"] if "storyline" in data else "" - } - page = GAME_PAGE.format(**formatting) - - return page, url - - async def search_games(self, search_term: str) -> List[str]: - """Search game from IGDB API by string, return listing of pages.""" - lines = [] - - # Define request body of IGDB API request and do request - body = SEARCH_BODY.format(**{"term": search_term}) - - async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: - data = await resp.json() - - # Loop over games, format them to good format, make line and append this to total lines - for game in data: - formatting = { - "name": game["name"], - "url": game["url"], - "rating": round(game["total_rating"] if "total_rating" in game else 0, 2), - "rating_count": game["total_rating_count"] if "total_rating" in game else "?" - } - line = GAME_SEARCH_LINE.format(**formatting) - lines.append(line) - - return lines - - async def get_companies_list(self, limit: int, offset: int = 0) -> List[Dict[str, Any]]: - """ - Get random Game Companies from IGDB API. - - Limit is parameter, that show how much movies this should return, offset show in which position should API start - returning results. - """ - # Create request body from template - body = COMPANIES_LIST_BODY.format(**{ - "limit": limit, - "offset": offset - }) - - async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=HEADERS) as resp: - return await resp.json() - - async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]: - """Create good formatted Game Company page.""" - # Generate URL of company logo - url = LOGO_URL.format(**{"image_id": data["logo"]["image_id"] if "logo" in data else ""}) - - # Try to get found date of company - founded = dt.utcfromtimestamp(data["start_date"]).date() if "start_date" in data else "?" - - # Generate list of games, that company have developed or published - developed = ", ".join(game["name"] for game in data["developed"]) if "developed" in data else "?" - published = ", ".join(game["name"] for game in data["published"]) if "published" in data else "?" - - formatting = { - "name": data["name"], - "url": data["url"], - "description": f"{data['description']}\n\n" if "description" in data else "\n", - "founded": founded, - "developed": developed, - "published": published - } - page = COMPANY_PAGE.format(**formatting) - - return page, url - - -def setup(bot: SeasonalBot) -> None: - """Add/Load Games cog.""" - # Check does IGDB API key exist, if not, log warning and don't load cog - if not Tokens.igdb: - logger.warning("No IGDB API key. Not loading Games cog.") - return - bot.add_cog(Games(bot)) diff --git a/bot/seasons/evergreen/help.py b/bot/seasons/evergreen/help.py deleted file mode 100644 index f4d76402..00000000 --- a/bot/seasons/evergreen/help.py +++ /dev/null @@ -1,554 +0,0 @@ -# Help command from Python bot. All commands that will be added to there in futures should be added to here too. -import asyncio -import itertools -import logging -from collections import namedtuple -from contextlib import suppress -from typing import Union - -from discord import Colour, Embed, HTTPException, Message, Reaction, User -from discord.ext import commands -from discord.ext.commands import CheckFailure, Cog as DiscordCog, Command, Context -from fuzzywuzzy import fuzz, process - -from bot import constants -from bot.bot import SeasonalBot -from bot.constants import Emojis -from bot.utils.pagination import ( - FIRST_EMOJI, LAST_EMOJI, - LEFT_EMOJI, LinePaginator, RIGHT_EMOJI, -) - -DELETE_EMOJI = Emojis.trashcan - -REACTIONS = { - FIRST_EMOJI: 'first', - LEFT_EMOJI: 'back', - RIGHT_EMOJI: 'next', - LAST_EMOJI: 'end', - DELETE_EMOJI: 'stop', -} - -Cog = namedtuple('Cog', ['name', 'description', 'commands']) - -log = logging.getLogger(__name__) - - -class HelpQueryNotFound(ValueError): - """ - Raised when a HelpSession Query doesn't match a command or cog. - - Contains the custom attribute of ``possible_matches``. - Instances of this object contain a dictionary of any command(s) that were close to matching the - query, where keys are the possible matched command names and values are the likeness match scores. - """ - - def __init__(self, arg: str, possible_matches: dict = None): - super().__init__(arg) - self.possible_matches = possible_matches - - -class HelpSession: - """ - An interactive session for bot and command help output. - - Expected attributes include: - * title: str - The title of the help message. - * query: Union[discord.ext.commands.Bot, discord.ext.commands.Command] - * description: str - The description of the query. - * pages: list[str] - A list of the help content split into manageable pages. - * message: `discord.Message` - The message object that's showing the help contents. - * destination: `discord.abc.Messageable` - Where the help message is to be sent to. - Cogs can be grouped into custom categories. All cogs with the same category will be displayed - under a single category name in the help output. Custom categories are defined inside the cogs - as a class attribute named `category`. A description can also be specified with the attribute - `category_description`. If a description is not found in at least one cog, the default will be - the regular description (class docstring) of the first cog found in the category. - """ - - def __init__( - self, - ctx: Context, - *command, - cleanup: bool = False, - only_can_run: bool = True, - show_hidden: bool = False, - max_lines: int = 15 - ): - """Creates an instance of the HelpSession class.""" - self._ctx = ctx - self._bot = ctx.bot - self.title = "Command Help" - - # set the query details for the session - if command: - query_str = ' '.join(command) - self.query = self._get_query(query_str) - self.description = self.query.description or self.query.help - else: - self.query = ctx.bot - self.description = self.query.description - self.author = ctx.author - self.destination = ctx.channel - - # set the config for the session - self._cleanup = cleanup - self._only_can_run = only_can_run - self._show_hidden = show_hidden - self._max_lines = max_lines - - # init session states - self._pages = None - self._current_page = 0 - self.message = None - self._timeout_task = None - self.reset_timeout() - - def _get_query(self, query: str) -> Union[Command, Cog]: - """Attempts to match the provided query with a valid command or cog.""" - command = self._bot.get_command(query) - if command: - return command - - # Find all cog categories that match. - cog_matches = [] - description = None - for cog in self._bot.cogs.values(): - if hasattr(cog, "category") and cog.category == query: - cog_matches.append(cog) - if hasattr(cog, "category_description"): - description = cog.category_description - - # Try to search by cog name if no categories match. - if not cog_matches: - cog = self._bot.cogs.get(query) - - # Don't consider it a match if the cog has a category. - if cog and not hasattr(cog, "category"): - cog_matches = [cog] - - if cog_matches: - cog = cog_matches[0] - cmds = (cog.get_commands() for cog in cog_matches) # Commands of all cogs - - return Cog( - name=cog.category if hasattr(cog, "category") else cog.qualified_name, - description=description or cog.description, - commands=tuple(itertools.chain.from_iterable(cmds)) # Flatten the list - ) - - self._handle_not_found(query) - - def _handle_not_found(self, query: str) -> None: - """ - Handles when a query does not match a valid command or cog. - - Will pass on possible close matches along with the `HelpQueryNotFound` exception. - """ - # Combine command and cog names - choices = list(self._bot.all_commands) + list(self._bot.cogs) - - result = process.extractBests(query, choices, scorer=fuzz.ratio, score_cutoff=90) - - raise HelpQueryNotFound(f'Query "{query}" not found.', dict(result)) - - async def timeout(self, seconds: int = 30) -> None: - """Waits for a set number of seconds, then stops the help session.""" - await asyncio.sleep(seconds) - await self.stop() - - def reset_timeout(self) -> None: - """Cancels the original timeout task and sets it again from the start.""" - # cancel original if it exists - if self._timeout_task: - if not self._timeout_task.cancelled(): - self._timeout_task.cancel() - - # recreate the timeout task - self._timeout_task = self._bot.loop.create_task(self.timeout()) - - async def on_reaction_add(self, reaction: Reaction, user: User) -> None: - """Event handler for when reactions are added on the help message.""" - # ensure it was the relevant session message - if reaction.message.id != self.message.id: - return - - # ensure it was the session author who reacted - if user.id != self.author.id: - return - - emoji = str(reaction.emoji) - - # check if valid action - if emoji not in REACTIONS: - return - - self.reset_timeout() - - # Run relevant action method - action = getattr(self, f'do_{REACTIONS[emoji]}', None) - if action: - await action() - - # remove the added reaction to prep for re-use - with suppress(HTTPException): - await self.message.remove_reaction(reaction, user) - - async def on_message_delete(self, message: Message) -> None: - """Closes the help session when the help message is deleted.""" - if message.id == self.message.id: - await self.stop() - - async def prepare(self) -> None: - """Sets up the help session pages, events, message and reactions.""" - await self.build_pages() - - self._bot.add_listener(self.on_reaction_add) - self._bot.add_listener(self.on_message_delete) - - await self.update_page() - self.add_reactions() - - def add_reactions(self) -> None: - """Adds the relevant reactions to the help message based on if pagination is required.""" - # if paginating - if len(self._pages) > 1: - for reaction in REACTIONS: - self._bot.loop.create_task(self.message.add_reaction(reaction)) - - # if single-page - else: - self._bot.loop.create_task(self.message.add_reaction(DELETE_EMOJI)) - - def _category_key(self, cmd: Command) -> str: - """ - Returns a cog name of a given command for use as a key for `sorted` and `groupby`. - - A zero width space is used as a prefix for results with no cogs to force them last in ordering. - """ - if cmd.cog: - try: - if cmd.cog.category: - return f'**{cmd.cog.category}**' - except AttributeError: - pass - - return f'**{cmd.cog_name}**' - else: - return "**\u200bNo Category:**" - - def _get_command_params(self, cmd: Command) -> str: - """ - Returns the command usage signature. - - This is a custom implementation of `command.signature` in order to format the command - signature without aliases. - """ - results = [] - for name, param in cmd.clean_params.items(): - - # if argument has a default value - if param.default is not param.empty: - - if isinstance(param.default, str): - show_default = param.default - else: - show_default = param.default is not None - - # if default is not an empty string or None - if show_default: - results.append(f'[{name}={param.default}]') - else: - results.append(f'[{name}]') - - # if variable length argument - elif param.kind == param.VAR_POSITIONAL: - results.append(f'[{name}...]') - - # if required - else: - results.append(f'<{name}>') - - return f"{cmd.name} {' '.join(results)}" - - async def build_pages(self) -> None: - """Builds the list of content pages to be paginated through in the help message, as a list of str.""" - # Use LinePaginator to restrict embed line height - paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) - - prefix = constants.Client.prefix - - # show signature if query is a command - if isinstance(self.query, commands.Command): - signature = self._get_command_params(self.query) - parent = self.query.full_parent_name + ' ' if self.query.parent else '' - paginator.add_line(f'**```{prefix}{parent}{signature}```**') - - aliases = ', '.join(f'`{a}`' for a in self.query.aliases) - if aliases: - paginator.add_line(f'**Can also use:** {aliases}\n') - - if not await self.query.can_run(self._ctx): - paginator.add_line('***You cannot run this command.***\n') - - if isinstance(self.query, Cog): - paginator.add_line(f'**{self.query.name}**') - - if self.description: - paginator.add_line(f'*{self.description}*') - - # list all children commands of the queried object - if isinstance(self.query, (commands.GroupMixin, Cog)): - - # remove hidden commands if session is not wanting hiddens - if not self._show_hidden: - filtered = [c for c in self.query.commands if not c.hidden] - else: - filtered = self.query.commands - - # if after filter there are no commands, finish up - if not filtered: - self._pages = paginator.pages - return - - if isinstance(self.query, Cog): - grouped = (('**Commands:**', self.query.commands),) - - elif isinstance(self.query, commands.Command): - grouped = (('**Subcommands:**', self.query.commands),) - - # don't show prefix for subcommands - prefix = '' - - # otherwise sort and organise all commands into categories - else: - cat_sort = sorted(filtered, key=self._category_key) - grouped = itertools.groupby(cat_sort, key=self._category_key) - - for category, cmds in grouped: - cmds = sorted(cmds, key=lambda c: c.name) - - if len(cmds) == 0: - continue - - cat_cmds = [] - - for command in cmds: - - # skip if hidden and hide if session is set to - if command.hidden and not self._show_hidden: - continue - - # see if the user can run the command - strikeout = '' - - # Patch to make the !help command work outside of #bot-commands again - # This probably needs a proper rewrite, but this will make it work in - # the mean time. - try: - can_run = await command.can_run(self._ctx) - except CheckFailure: - can_run = False - - if not can_run: - # skip if we don't show commands they can't run - if self._only_can_run: - continue - strikeout = '~~' - - signature = self._get_command_params(command) - info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" - - # handle if the command has no docstring - if command.short_doc: - cat_cmds.append(f'{info}\n*{command.short_doc}*') - else: - cat_cmds.append(f'{info}\n*No details provided.*') - - # state var for if the category should be added next - print_cat = 1 - new_page = True - - for details in cat_cmds: - - # keep details together, paginating early if it won't fit - lines_adding = len(details.split('\n')) + print_cat - if paginator._linecount + lines_adding > self._max_lines: - paginator._linecount = 0 - new_page = True - paginator.close_page() - - # new page so print category title again - print_cat = 1 - - if print_cat: - if new_page: - paginator.add_line('') - paginator.add_line(category) - print_cat = 0 - - paginator.add_line(details) - - self._pages = paginator.pages - - def embed_page(self, page_number: int = 0) -> Embed: - """Returns an Embed with the requested page formatted within.""" - embed = Embed() - - if isinstance(self.query, (commands.Command, Cog)) and page_number > 0: - title = f'Command Help | "{self.query.name}"' - else: - title = self.title - - embed.set_author(name=title, icon_url=constants.Icons.questionmark) - embed.description = self._pages[page_number] - - page_count = len(self._pages) - if page_count > 1: - embed.set_footer(text=f'Page {self._current_page+1} / {page_count}') - - return embed - - async def update_page(self, page_number: int = 0) -> None: - """Sends the intial message, or changes the existing one to the given page number.""" - self._current_page = page_number - embed_page = self.embed_page(page_number) - - if not self.message: - self.message = await self.destination.send(embed=embed_page) - else: - await self.message.edit(embed=embed_page) - - @classmethod - async def start(cls, ctx: Context, *command, **options) -> "HelpSession": - """ - Create and begin a help session based on the given command context. - - Available options kwargs: - * cleanup: Optional[bool] - Set to `True` to have the message deleted on session end. Defaults to `False`. - * only_can_run: Optional[bool] - Set to `True` to hide commands the user can't run. Defaults to `False`. - * show_hidden: Optional[bool] - Set to `True` to include hidden commands. Defaults to `False`. - * max_lines: Optional[int] - Sets the max number of lines the paginator will add to a single page. Defaults to 20. - """ - session = cls(ctx, *command, **options) - await session.prepare() - - return session - - async def stop(self) -> None: - """Stops the help session, removes event listeners and attempts to delete the help message.""" - self._bot.remove_listener(self.on_reaction_add) - self._bot.remove_listener(self.on_message_delete) - - # ignore if permission issue, or the message doesn't exist - with suppress(HTTPException, AttributeError): - if self._cleanup: - await self.message.delete() - else: - await self.message.clear_reactions() - - @property - def is_first_page(self) -> bool: - """Check if session is currently showing the first page.""" - return self._current_page == 0 - - @property - def is_last_page(self) -> bool: - """Check if the session is currently showing the last page.""" - return self._current_page == (len(self._pages)-1) - - async def do_first(self) -> None: - """Event that is called when the user requests the first page.""" - if not self.is_first_page: - await self.update_page(0) - - async def do_back(self) -> None: - """Event that is called when the user requests the previous page.""" - if not self.is_first_page: - await self.update_page(self._current_page-1) - - async def do_next(self) -> None: - """Event that is called when the user requests the next page.""" - if not self.is_last_page: - await self.update_page(self._current_page+1) - - async def do_end(self) -> None: - """Event that is called when the user requests the last page.""" - if not self.is_last_page: - await self.update_page(len(self._pages)-1) - - async def do_stop(self) -> None: - """Event that is called when the user requests to stop the help session.""" - await self.message.delete() - - -class Help(DiscordCog): - """Custom Embed Pagination Help feature.""" - - @commands.command('help') - async def new_help(self, ctx: Context, *commands) -> None: - """Shows Command Help.""" - try: - await HelpSession.start(ctx, *commands) - except HelpQueryNotFound as error: - embed = Embed() - embed.colour = Colour.red() - embed.title = str(error) - - if error.possible_matches: - matches = '\n'.join(error.possible_matches.keys()) - embed.description = f'**Did you mean:**\n`{matches}`' - - await ctx.send(embed=embed) - - -def unload(bot: SeasonalBot) -> None: - """ - Reinstates the original help command. - - This is run if the cog raises an exception on load, or if the extension is unloaded. - """ - bot.remove_command('help') - bot.add_command(bot._old_help) - - -def setup(bot: SeasonalBot) -> None: - """ - The setup for the help extension. - - This is called automatically on `bot.load_extension` being run. - Stores the original help command instance on the `bot._old_help` attribute for later - reinstatement, before removing it from the command registry so the new help command can be - loaded successfully. - If an exception is raised during the loading of the cog, `unload` will be called in order to - reinstate the original help command. - """ - bot._old_help = bot.get_command('help') - bot.remove_command('help') - - try: - bot.add_cog(Help()) - except Exception: - unload(bot) - raise - else: - log.info("Help cog loaded") - - -def teardown(bot: SeasonalBot) -> None: - """ - The teardown for the help extension. - - This is called automatically on `bot.unload_extension` being run. - Calls `unload` in order to reinstate the original help command. - """ - unload(bot) diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py deleted file mode 100644 index fb18b62a..00000000 --- a/bot/seasons/evergreen/issues.py +++ /dev/null @@ -1,77 +0,0 @@ -import logging - -import discord -from discord.ext import commands - -from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS -from bot.utils.decorators import override_in_channel - -log = logging.getLogger(__name__) - -BAD_RESPONSE = { - 404: "Issue/pull request not located! Please enter a valid number!", - 403: "Rate limit has been hit! Please try again later!" -} - - -class Issues(commands.Cog): - """Cog that allows users to retrieve issues from GitHub.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(aliases=("pr",)) - @override_in_channel(WHITELISTED_CHANNELS) - async def issue( - self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord" - ) -> None: - """Command to retrieve issues from a GitHub repository.""" - url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}" - merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge" - - log.trace(f"Querying GH issues API: {url}") - async with self.bot.http_session.get(url) as r: - json_data = await r.json() - - if r.status in BAD_RESPONSE: - log.warning(f"Received response {r.status} from: {url}") - return await ctx.send(f"[{str(r.status)}] {BAD_RESPONSE.get(r.status)}") - - # The initial API request is made to the issues API endpoint, which will return information - # if the issue or PR is present. However, the scope of information returned for PRs differs - # from issues: if the 'issues' key is present in the response then we can pull the data we - # need from the initial API call. - if "issues" in json_data.get("html_url"): - if json_data.get("state") == "open": - icon_url = Emojis.issue - else: - icon_url = Emojis.issue_closed - - # If the 'issues' key is not contained in the API response and there is no error code, then - # we know that a PR has been requested and a call to the pulls API endpoint is necessary - # to get the desired information for the PR. - else: - log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}") - async with self.bot.http_session.get(merge_url) as m: - if json_data.get("state") == "open": - icon_url = Emojis.pull_request - # When the status is 204 this means that the state of the PR is merged - elif m.status == 204: - icon_url = Emojis.merge - else: - icon_url = Emojis.pull_request_closed - - issue_url = json_data.get("html_url") - description_text = f"[{repository}] #{number} {json_data.get('title')}" - resp = discord.Embed( - colour=Colours.bright_green, - description=f"{icon_url} [{description_text}]({issue_url})" - ) - resp.set_author(name="GitHub", url=issue_url) - await ctx.send(embed=resp) - - -def setup(bot: commands.Bot) -> None: - """Cog Retrieves Issues From Github.""" - bot.add_cog(Issues(bot)) - log.info("Issues cog loaded") diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/seasons/evergreen/magic_8ball.py deleted file mode 100644 index e47ef454..00000000 --- a/bot/seasons/evergreen/magic_8ball.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -log = logging.getLogger(__name__) - - -class Magic8ball(commands.Cog): - """A Magic 8ball command to respond to a user's question.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - with open(Path("bot/resources/evergreen/magic8ball.json"), "r") as file: - self.answers = json.load(file) - - @commands.command(name="8ball") - async def output_answer(self, ctx: commands.Context, *, question: str) -> None: - """Return a Magic 8ball answer from answers list.""" - if len(question.split()) >= 3: - answer = random.choice(self.answers) - await ctx.send(answer) - else: - await ctx.send("Usage: .8ball (minimum length of 3 eg: `will I win?`)") - - -def setup(bot: commands.Bot) -> None: - """Magic 8ball Cog load.""" - bot.add_cog(Magic8ball(bot)) - log.info("Magic8ball cog loaded") diff --git a/bot/seasons/evergreen/minesweeper.py b/bot/seasons/evergreen/minesweeper.py deleted file mode 100644 index b0ba8145..00000000 --- a/bot/seasons/evergreen/minesweeper.py +++ /dev/null @@ -1,285 +0,0 @@ -import logging -import typing -from dataclasses import dataclass -from random import randint, random - -import discord -from discord.ext import commands - -from bot.constants import Client - -MESSAGE_MAPPING = { - 0: ":stop_button:", - 1: ":one:", - 2: ":two:", - 3: ":three:", - 4: ":four:", - 5: ":five:", - 6: ":six:", - 7: ":seven:", - 8: ":eight:", - 9: ":nine:", - 10: ":keycap_ten:", - "bomb": ":bomb:", - "hidden": ":grey_question:", - "flag": ":flag_black:", - "x": ":x:" -} - -log = logging.getLogger(__name__) - - -class CoordinateConverter(commands.Converter): - """Converter for Coordinates.""" - - async def convert(self, ctx: commands.Context, coordinate: str) -> typing.Tuple[int, int]: - """Take in a coordinate string and turn it into an (x, y) tuple.""" - if not 2 <= len(coordinate) <= 3: - raise commands.BadArgument('Invalid co-ordinate provided') - - coordinate = coordinate.lower() - if coordinate[0].isalpha(): - digit = coordinate[1:] - letter = coordinate[0] - else: - digit = coordinate[:-1] - letter = coordinate[-1] - - if not digit.isdigit(): - raise commands.BadArgument - - x = ord(letter) - ord('a') - y = int(digit) - 1 - - if (not 0 <= x <= 9) or (not 0 <= y <= 9): - raise commands.BadArgument - return x, y - - -GameBoard = typing.List[typing.List[typing.Union[str, int]]] - - -@dataclass -class Game: - """The data for a game.""" - - board: GameBoard - revealed: GameBoard - dm_msg: discord.Message - chat_msg: discord.Message - activated_on_server: bool - - -GamesDict = typing.Dict[int, Game] - - -class Minesweeper(commands.Cog): - """Play a game of Minesweeper.""" - - def __init__(self, bot: commands.Bot) -> None: - self.games: GamesDict = {} # Store the currently running games - - @commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True) - async def minesweeper_group(self, ctx: commands.Context) -> None: - """Commands for Playing Minesweeper.""" - await ctx.send_help(ctx.command) - - @staticmethod - def get_neighbours(x: int, y: int) -> typing.Generator[typing.Tuple[int, int], None, None]: - """Get all the neighbouring x and y including it self.""" - for x_ in [x - 1, x, x + 1]: - for y_ in [y - 1, y, y + 1]: - if x_ != -1 and x_ != 10 and y_ != -1 and y_ != 10: - yield x_, y_ - - def generate_board(self, bomb_chance: float) -> GameBoard: - """Generate a 2d array for the board.""" - board: GameBoard = [ - [ - "bomb" if random() <= bomb_chance else "number" - for _ in range(10) - ] for _ in range(10) - ] - - # make sure there is always a free cell - board[randint(0, 9)][randint(0, 9)] = "number" - - for y, row in enumerate(board): - for x, cell in enumerate(row): - if cell == "number": - # calculate bombs near it - bombs = 0 - for x_, y_ in self.get_neighbours(x, y): - if board[y_][x_] == "bomb": - bombs += 1 - board[y][x] = bombs - return board - - @staticmethod - def format_for_discord(board: GameBoard) -> str: - """Format the board as a string for Discord.""" - discord_msg = ( - ":stop_button: :regional_indicator_a::regional_indicator_b::regional_indicator_c:" - ":regional_indicator_d::regional_indicator_e::regional_indicator_f::regional_indicator_g:" - ":regional_indicator_h::regional_indicator_i::regional_indicator_j:\n\n" - ) - rows = [] - for row_number, row in enumerate(board): - new_row = f"{MESSAGE_MAPPING[row_number + 1]} " - new_row += "".join(MESSAGE_MAPPING[cell] for cell in row) - rows.append(new_row) - - discord_msg += "\n".join(rows) - return discord_msg - - @minesweeper_group.command(name="start") - async def start_command(self, ctx: commands.Context, bomb_chance: float = .2) -> None: - """Start a game of Minesweeper.""" - if ctx.author.id in self.games: # Player is already playing - await ctx.send(f"{ctx.author.mention} you already have a game running!", delete_after=2) - await ctx.message.delete(delay=2) - return - - # Add game to list - board: GameBoard = self.generate_board(bomb_chance) - revealed_board: GameBoard = [["hidden"] * 10 for _ in range(10)] - - if ctx.guild: - await ctx.send(f"{ctx.author.mention} is playing Minesweeper") - chat_msg = await ctx.send(f"Here's there board!\n{self.format_for_discord(revealed_board)}") - else: - chat_msg = None - - await ctx.author.send( - f"Play by typing: `{Client.prefix}ms reveal xy [xy]` or `{Client.prefix}ms flag xy [xy]` \n" - f"Close the game with `{Client.prefix}ms end`\n" - ) - dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}") - - self.games[ctx.author.id] = Game( - board=board, - revealed=revealed_board, - dm_msg=dm_msg, - chat_msg=chat_msg, - activated_on_server=ctx.guild is not None - ) - - async def update_boards(self, ctx: commands.Context) -> None: - """Update both playing boards.""" - game = self.games[ctx.author.id] - await game.dm_msg.delete() - game.dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(game.revealed)}") - if game.activated_on_server: - await game.chat_msg.edit(content=f"Here's there board!\n{self.format_for_discord(game.revealed)}") - - @commands.dm_only() - @minesweeper_group.command(name="flag") - async def flag_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: - """Place multiple flags on the board.""" - board: GameBoard = self.games[ctx.author.id].revealed - for x, y in coordinates: - if board[y][x] == "hidden": - board[y][x] = "flag" - - await self.update_boards(ctx) - - @staticmethod - def reveal_bombs(revealed: GameBoard, board: GameBoard) -> None: - """Reveals all the bombs.""" - for y, row in enumerate(board): - for x, cell in enumerate(row): - if cell == "bomb": - revealed[y][x] = cell - - async def lost(self, ctx: commands.Context) -> None: - """The player lost the game.""" - game = self.games[ctx.author.id] - self.reveal_bombs(game.revealed, game.board) - await ctx.author.send(":fire: You lost! :fire:") - if game.activated_on_server: - await game.chat_msg.channel.send(f":fire: {ctx.author.mention} just lost Minesweeper! :fire:") - - async def won(self, ctx: commands.Context) -> None: - """The player won the game.""" - game = self.games[ctx.author.id] - await ctx.author.send(":tada: You won! :tada:") - if game.activated_on_server: - await game.chat_msg.channel.send(f":tada: {ctx.author.mention} just won Minesweeper! :tada:") - - def reveal_zeros(self, revealed: GameBoard, board: GameBoard, x: int, y: int) -> None: - """Recursively reveal adjacent cells when a 0 cell is encountered.""" - for x_, y_ in self.get_neighbours(x, y): - if revealed[y_][x_] != "hidden": - continue - revealed[y_][x_] = board[y_][x_] - if board[y_][x_] == 0: - self.reveal_zeros(revealed, board, x_, y_) - - async def check_if_won(self, ctx: commands.Context, revealed: GameBoard, board: GameBoard) -> bool: - """Checks if a player has won.""" - if any( - revealed[y][x] in ["hidden", "flag"] and board[y][x] != "bomb" - for x in range(10) - for y in range(10) - ): - return False - else: - await self.won(ctx) - return True - - async def reveal_one( - self, - ctx: commands.Context, - revealed: GameBoard, - board: GameBoard, - x: int, - y: int - ) -> bool: - """ - Reveal one square. - - return is True if the game ended, breaking the loop in `reveal_command` and deleting the game - """ - revealed[y][x] = board[y][x] - if board[y][x] == "bomb": - await self.lost(ctx) - revealed[y][x] = "x" # mark bomb that made you lose with a x - return True - elif board[y][x] == 0: - self.reveal_zeros(revealed, board, x, y) - return await self.check_if_won(ctx, revealed, board) - - @commands.dm_only() - @minesweeper_group.command(name="reveal") - async def reveal_command(self, ctx: commands.Context, *coordinates: CoordinateConverter) -> None: - """Reveal multiple cells.""" - game = self.games[ctx.author.id] - revealed: GameBoard = game.revealed - board: GameBoard = game.board - - for x, y in coordinates: - # reveal_one returns True if the revealed cell is a bomb or the player won, ending the game - if await self.reveal_one(ctx, revealed, board, x, y): - await self.update_boards(ctx) - del self.games[ctx.author.id] - break - else: - await self.update_boards(ctx) - - @minesweeper_group.command(name="end") - async def end_command(self, ctx: commands.Context) -> None: - """End your current game.""" - game = self.games[ctx.author.id] - game.revealed = game.board - await self.update_boards(ctx) - new_msg = f":no_entry: Game canceled :no_entry:\n{game.dm_msg.content}" - await game.dm_msg.edit(content=new_msg) - if game.activated_on_server: - await game.chat_msg.edit(content=new_msg) - del self.games[ctx.author.id] - - -def setup(bot: commands.Bot) -> None: - """Load the Minesweeper cog.""" - bot.add_cog(Minesweeper(bot)) - log.info("Minesweeper cog loaded") diff --git a/bot/seasons/evergreen/movie.py b/bot/seasons/evergreen/movie.py deleted file mode 100644 index 93aeef30..00000000 --- a/bot/seasons/evergreen/movie.py +++ /dev/null @@ -1,198 +0,0 @@ -import logging -import random -from enum import Enum -from typing import Any, Dict, List, Tuple -from urllib.parse import urlencode - -from aiohttp import ClientSession -from discord import Embed -from discord.ext.commands import Bot, Cog, Context, group - -from bot.constants import Tokens -from bot.utils.pagination import ImagePaginator - -# Define base URL of TMDB -BASE_URL = "https://api.themoviedb.org/3/" - -logger = logging.getLogger(__name__) - -# Define movie params, that will be used for every movie request -MOVIE_PARAMS = { - "api_key": Tokens.tmdb, - "language": "en-US" -} - - -class MovieGenres(Enum): - """Movies Genre names and IDs.""" - - Action = "28" - Adventure = "12" - Animation = "16" - Comedy = "35" - Crime = "80" - Documentary = "99" - Drama = "18" - Family = "10751" - Fantasy = "14" - History = "36" - Horror = "27" - Music = "10402" - Mystery = "9648" - Romance = "10749" - Science = "878" - Thriller = "53" - Western = "37" - - -class Movie(Cog): - """Movie Cog contains movies command that grab random movies from TMDB.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.http_session: ClientSession = bot.http_session - - @group(name='movies', aliases=['movie'], invoke_without_command=True) - async def movies(self, ctx: Context, genre: str = "", amount: int = 5) -> None: - """ - Get random movies by specifying genre. Also support amount parameter, that define how much movies will be shown. - - Default 5. Use .movies genres to get all available genres. - """ - # Check is there more than 20 movies specified, due TMDB return 20 movies - # per page, so this is max. Also you can't get less movies than 1, just logic - if amount > 20: - await ctx.send("You can't get more than 20 movies at once. (TMDB limits)") - return - elif amount < 1: - await ctx.send("You can't get less than 1 movie.") - return - - # Capitalize genre for getting data from Enum, get random page, send help when genre don't exist. - genre = genre.capitalize() - try: - result = await self.get_movies_list(self.http_session, MovieGenres[genre].value, 1) - except KeyError: - await ctx.send_help('movies') - return - - # Check if "results" is in result. If not, throw error. - if "results" not in result.keys(): - err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \ - f"{result['status_message']}." - await ctx.send(err_msg) - logger.warning(err_msg) - - # Get random page. Max page is last page where is movies with this genre. - page = random.randint(1, result["total_pages"]) - - # Get movies list from TMDB, check if results key in result. When not, raise error. - movies = await self.get_movies_list(self.http_session, MovieGenres[genre].value, page) - if 'results' not in movies.keys(): - err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \ - f"{result['status_message']}." - await ctx.send(err_msg) - logger.warning(err_msg) - - # Get all pages and embed - pages = await self.get_pages(self.http_session, movies, amount) - embed = await self.get_embed(genre) - - await ImagePaginator.paginate(pages, ctx, embed) - - @movies.command(name='genres', aliases=['genre', 'g']) - async def genres(self, ctx: Context) -> None: - """Show all currently available genres for .movies command.""" - await ctx.send(f"Current available genres: {', '.join('`' + genre.name + '`' for genre in MovieGenres)}") - - async def get_movies_list(self, client: ClientSession, genre_id: str, page: int) -> Dict[str, Any]: - """Return JSON of TMDB discover request.""" - # Define params of request - params = { - "api_key": Tokens.tmdb, - "language": "en-US", - "sort_by": "popularity.desc", - "include_adult": "false", - "include_video": "false", - "page": page, - "with_genres": genre_id - } - - url = BASE_URL + "discover/movie?" + urlencode(params) - - # Make discover request to TMDB, return result - async with client.get(url) as resp: - return await resp.json() - - async def get_pages(self, client: ClientSession, movies: Dict[str, Any], amount: int) -> List[Tuple[str, str]]: - """Fetch all movie pages from movies dictionary. Return list of pages.""" - pages = [] - - for i in range(amount): - movie_id = movies['results'][i]['id'] - movie = await self.get_movie(client, movie_id) - - page, img = await self.create_page(movie) - pages.append((page, img)) - - return pages - - async def get_movie(self, client: ClientSession, movie: int) -> Dict: - """Get Movie by movie ID from TMDB. Return result dictionary.""" - url = BASE_URL + f"movie/{movie}?" + urlencode(MOVIE_PARAMS) - - async with client.get(url) as resp: - return await resp.json() - - async def create_page(self, movie: Dict[str, Any]) -> Tuple[str, str]: - """Create page from TMDB movie request result. Return formatted page + image.""" - text = "" - - # Add title + tagline (if not empty) - text += f"**{movie['title']}**\n" - if movie['tagline']: - text += f"{movie['tagline']}\n\n" - else: - text += "\n" - - # Add other information - text += f"**Rating:** {movie['vote_average']}/10 :star:\n" - text += f"**Release Date:** {movie['release_date']}\n\n" - - text += "__**Production Information**__\n" - - companies = movie['production_companies'] - countries = movie['production_countries'] - - text += f"**Made by:** {', '.join(company['name'] for company in companies)}\n" - text += f"**Made in:** {', '.join(country['name'] for country in countries)}\n\n" - - text += "__**Some Numbers**__\n" - - budget = f"{movie['budget']:,d}" if movie['budget'] else "?" - revenue = f"{movie['revenue']:,d}" if movie['revenue'] else "?" - - if movie['runtime'] is not None: - duration = divmod(movie['runtime'], 60) - else: - duration = ("?", "?") - - text += f"**Budget:** ${budget}\n" - text += f"**Revenue:** ${revenue}\n" - text += f"**Duration:** {f'{duration[0]} hour(s) {duration[1]} minute(s)'}\n\n" - - text += movie['overview'] - - img = f"http://image.tmdb.org/t/p/w200{movie['poster_path']}" - - # Return page content and image - return text, img - - async def get_embed(self, name: str) -> Embed: - """Return embed of random movies. Uses name in title.""" - return Embed(title=f'Random {name} Movies').set_footer(text='Powered by TMDB (themoviedb.org)') - - -def setup(bot: Bot) -> None: - """Load Movie Cog.""" - bot.add_cog(Movie(bot)) diff --git a/bot/seasons/evergreen/recommend_game.py b/bot/seasons/evergreen/recommend_game.py deleted file mode 100644 index 835a4e53..00000000 --- a/bot/seasons/evergreen/recommend_game.py +++ /dev/null @@ -1,51 +0,0 @@ -import json -import logging -from pathlib import Path -from random import shuffle - -import discord -from discord.ext import commands - -log = logging.getLogger(__name__) -game_recs = [] - -# Populate the list `game_recs` with resource files -for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"): - with rec_path.open(encoding='utf-8') as file: - data = json.load(file) - game_recs.append(data) -shuffle(game_recs) - - -class RecommendGame(commands.Cog): - """Commands related to recommending games.""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.index = 0 - - @commands.command(name="recommendgame", aliases=['gamerec']) - async def recommend_game(self, ctx: commands.Context) -> None: - """Sends an Embed of a random game recommendation.""" - if self.index >= len(game_recs): - self.index = 0 - shuffle(game_recs) - game = game_recs[self.index] - self.index += 1 - - author = self.bot.get_user(int(game['author'])) - - # Creating and formatting Embed - embed = discord.Embed(color=discord.Colour.blue()) - if author is not None: - embed.set_author(name=author.name, icon_url=author.avatar_url) - embed.set_image(url=game['image']) - embed.add_field(name='Recommendation: ' + game['title'] + '\n' + game['link'], value=game['description']) - - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Loads the RecommendGame cog.""" - bot.add_cog(RecommendGame(bot)) - log.info("RecommendGame cog loaded") diff --git a/bot/seasons/evergreen/reddit.py b/bot/seasons/evergreen/reddit.py deleted file mode 100644 index a07e591f..00000000 --- a/bot/seasons/evergreen/reddit.py +++ /dev/null @@ -1,129 +0,0 @@ -import logging -import random - -import discord -from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType - -from bot.utils.pagination import ImagePaginator - -log = logging.getLogger(__name__) - - -class Reddit(commands.Cog): - """Fetches reddit posts.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - async def fetch(self, url: str) -> dict: - """Send a get request to the reddit API and get json response.""" - session = self.bot.http_session - params = { - 'limit': 50 - } - headers = { - 'User-Agent': 'Iceman' - } - - async with session.get(url=url, params=params, headers=headers) as response: - return await response.json() - - @commands.command(name='reddit') - @commands.cooldown(1, 10, BucketType.user) - async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None: - """ - Fetch reddit posts by using this command. - - Gets a post from r/python by default. - Usage: - --> .reddit [subreddit_name] [hot/top/new] - """ - pages = [] - sort_list = ["hot", "new", "top", "rising"] - if sort.lower() not in sort_list: - await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`") - sort = "hot" - - data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json') - - try: - posts = data["data"]["children"] - except KeyError: - return await ctx.send('Subreddit not found!') - if not posts: - return await ctx.send('No posts available!') - - if posts[1]["data"]["over_18"] is True: - return await ctx.send( - "You cannot access this Subreddit as it is ment for those who " - "are 18 years or older." - ) - - embed_titles = "" - - # Chooses k unique random elements from a population sequence or set. - random_posts = random.sample(posts, k=5) - - # ----------------------------------------------------------- - # This code below is bound of change when the emojis are added. - - upvote_emoji = self.bot.get_emoji(638729835245731840) - comment_emoji = self.bot.get_emoji(638729835073765387) - user_emoji = self.bot.get_emoji(638729835442602003) - text_emoji = self.bot.get_emoji(676030265910493204) - video_emoji = self.bot.get_emoji(676030265839190047) - image_emoji = self.bot.get_emoji(676030265734201344) - reddit_emoji = self.bot.get_emoji(676030265734332427) - - # ------------------------------------------------------------ - - for i, post in enumerate(random_posts, start=1): - post_title = post["data"]["title"][0:50] - post_url = post['data']['url'] - if post_title == "": - post_title = "No Title." - elif post_title == post_url: - post_title = "Title is itself a link." - - # ------------------------------------------------------------------ - # Embed building. - - embed_titles += f"**{i}.[{post_title}]({post_url})**\n" - image_url = " " - post_stats = f"{text_emoji}" # Set default content type to text. - - if post["data"]["is_video"] is True or "youtube" in post_url.split("."): - # This means the content type in the post is a video. - post_stats = f"{video_emoji} " - - elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"): - # This means the content type in the post is an image. - post_stats = f"{image_emoji} " - image_url = post_url - - votes = f'{upvote_emoji}{post["data"]["ups"]}' - comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}' - post_stats += ( - f"\u2002{votes}\u2003" - f"{comments}" - f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n' - ) - embed_titles += f"{post_stats}\n" - page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}" - - embed = discord.Embed() - page_tuple = (page_text, image_url) - pages.append(page_tuple) - - # ------------------------------------------------------------------ - - pages.insert(0, (embed_titles, " ")) - embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url) - await ImagePaginator.paginate(pages, ctx, embed) - - -def setup(bot: commands.Bot) -> None: - """Load the Cog.""" - bot.add_cog(Reddit(bot)) - log.debug('Loaded') diff --git a/bot/seasons/evergreen/showprojects.py b/bot/seasons/evergreen/showprojects.py deleted file mode 100644 index a943e548..00000000 --- a/bot/seasons/evergreen/showprojects.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging - -from discord import Message -from discord.ext import commands - -from bot.constants import Channels - -log = logging.getLogger(__name__) - - -class ShowProjects(commands.Cog): - """Cog that reacts to posts in the #show-your-projects.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.lastPoster = 0 # Given 0 as the default last poster ID as no user can actually have 0 assigned to them - - @commands.Cog.listener() - async def on_message(self, message: Message) -> None: - """Adds reactions to posts in #show-your-projects.""" - reactions = ["\U0001f44d", "\U00002764", "\U0001f440", "\U0001f389", "\U0001f680", "\U00002b50", "\U0001f6a9"] - if (message.channel.id == Channels.show_your_projects - and message.author.bot is False - and message.author.id != self.lastPoster): - for reaction in reactions: - await message.add_reaction(reaction) - - self.lastPoster = message.author.id - - -def setup(bot: commands.Bot) -> None: - """Show Projects Reaction Cog.""" - bot.add_cog(ShowProjects(bot)) - log.info("ShowProjects cog loaded") diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py deleted file mode 100644 index d7f9f20c..00000000 --- a/bot/seasons/evergreen/snakes/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -import logging - -from discord.ext import commands - -from bot.seasons.evergreen.snakes.snakes_cog import Snakes - -log = logging.getLogger(__name__) - - -def setup(bot: commands.Bot) -> None: - """Snakes Cog load.""" - bot.add_cog(Snakes(bot)) - log.info("Snakes cog loaded") diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py deleted file mode 100644 index 57103b57..00000000 --- a/bot/seasons/evergreen/snakes/converter.py +++ /dev/null @@ -1,85 +0,0 @@ -import json -import logging -import random -from typing import Iterable, List - -import discord -from discord.ext.commands import Context, Converter -from fuzzywuzzy import fuzz - -from bot.seasons.evergreen.snakes.utils import SNAKE_RESOURCES -from bot.utils import disambiguate - -log = logging.getLogger(__name__) - - -class Snake(Converter): - """Snake converter for the Snakes Cog.""" - - snakes = None - special_cases = None - - async def convert(self, ctx: Context, name: str) -> str: - """Convert the input snake name to the closest matching Snake object.""" - await self.build_list() - name = name.lower() - - if name == 'python': - return 'Python (programming language)' - - def get_potential(iterable: Iterable, *, threshold: int = 80) -> List[str]: - nonlocal name - potential = [] - - for item in iterable: - original, item = item, item.lower() - - if name == item: - return [original] - - a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item) - if a >= threshold or b >= threshold: - potential.append(original) - - return potential - - # Handle special cases - if name.lower() in self.special_cases: - return self.special_cases.get(name.lower(), name.lower()) - - names = {snake['name']: snake['scientific'] for snake in self.snakes} - all_names = names.keys() | names.values() - timeout = len(all_names) * (3 / 4) - - embed = discord.Embed( - title='Found multiple choices. Please choose the correct one.', colour=0x59982F) - embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url) - - name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed) - return names.get(name, name) - - @classmethod - async def build_list(cls) -> None: - """Build list of snakes from the static snake resources.""" - # Get all the snakes - if cls.snakes is None: - with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: - cls.snakes = json.load(snakefile) - - # Get the special cases - if cls.special_cases is None: - with (SNAKE_RESOURCES / "special_snakes.json").open() as snakefile: - special_cases = json.load(snakefile) - cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} - - @classmethod - async def random(cls) -> str: - """ - Get a random Snake from the loaded resources. - - This is stupid. We should find a way to somehow get the global session into a global context, - so I can get it from here. - """ - await cls.build_list() - names = [snake['scientific'] for snake in cls.snakes] - return random.choice(names) diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py deleted file mode 100644 index e5a03a20..00000000 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ /dev/null @@ -1,1149 +0,0 @@ -import asyncio -import colorsys -import logging -import os -import random -import re -import string -import textwrap -import urllib -from functools import partial -from io import BytesIO -from typing import Any, Dict, List - -import aiohttp -import async_timeout -from PIL import Image, ImageDraw, ImageFont -from discord import Colour, Embed, File, Member, Message, Reaction -from discord.ext.commands import BadArgument, Bot, Cog, CommandError, Context, bot_has_permissions, group - -from bot.constants import ERROR_REPLIES, Tokens -from bot.seasons.evergreen.snakes import utils -from bot.seasons.evergreen.snakes.converter import Snake -from bot.utils.decorators import locked - -log = logging.getLogger(__name__) - - -# region: Constants -# Color -SNAKE_COLOR = 0x399600 - -# Antidote constants -SYRINGE_EMOJI = "\U0001F489" # :syringe: -PILL_EMOJI = "\U0001F48A" # :pill: -HOURGLASS_EMOJI = "\u231B" # :hourglass: -CROSSBONES_EMOJI = "\u2620" # :skull_crossbones: -ALEMBIC_EMOJI = "\u2697" # :alembic: -TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole -CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole -BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole -HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses -EMPTY_UNICODE = "\u200b" # literally just an empty space - -ANTIDOTE_EMOJI = ( - SYRINGE_EMOJI, - PILL_EMOJI, - HOURGLASS_EMOJI, - CROSSBONES_EMOJI, - ALEMBIC_EMOJI, -) - -# Quiz constants -ANSWERS_EMOJI = { - "a": "\U0001F1E6", # :regional_indicator_a: 🇦 - "b": "\U0001F1E7", # :regional_indicator_b: 🇧 - "c": "\U0001F1E8", # :regional_indicator_c: 🇨 - "d": "\U0001F1E9", # :regional_indicator_d: 🇩 -} - -ANSWERS_EMOJI_REVERSE = { - "\U0001F1E6": "A", # :regional_indicator_a: 🇦 - "\U0001F1E7": "B", # :regional_indicator_b: 🇧 - "\U0001F1E8": "C", # :regional_indicator_c: 🇨 - "\U0001F1E9": "D", # :regional_indicator_d: 🇩 -} - -# Zzzen of pythhhon constant -ZEN = """ -Beautiful is better than ugly. -Explicit is better than implicit. -Simple is better than complex. -Complex is better than complicated. -Flat is better than nested. -Sparse is better than dense. -Readability counts. -Special cases aren't special enough to break the rules. -Although practicality beats purity. -Errors should never pass silently. -Unless explicitly silenced. -In the face of ambiguity, refuse the temptation to guess. -There should be one-- and preferably only one --obvious way to do it. -Now is better than never. -Although never is often better than *right* now. -If the implementation is hard to explain, it's a bad idea. -If the implementation is easy to explain, it may be a good idea. -""" - -# Max messages to train snake_chat on -MSG_MAX = 100 - -# get_snek constants -URL = "https://en.wikipedia.org/w/api.php?" - -# snake guess responses -INCORRECT_GUESS = ( - "Nope, that's not what it is.", - "Not quite.", - "Not even close.", - "Terrible guess.", - "Nnnno.", - "Dude. No.", - "I thought everyone knew this one.", - "Guess you suck at snakes.", - "Bet you feel stupid now.", - "Hahahaha, no.", - "Did you hit the wrong key?" -) - -CORRECT_GUESS = ( - "**WRONG**. Wait, no, actually you're right.", - "Yeah, you got it!", - "Yep, that's exactly what it is.", - "Uh-huh. Yep yep yep.", - "Yeah that's right.", - "Yup. How did you know that?", - "Are you a herpetologist?", - "Sure, okay, but I bet you can't pronounce it.", - "Are you cheating?" -) - -# snake card consts -CARD = { - "top": Image.open("bot/resources/snakes/snake_cards/card_top.png"), - "frame": Image.open("bot/resources/snakes/snake_cards/card_frame.png"), - "bottom": Image.open("bot/resources/snakes/snake_cards/card_bottom.png"), - "backs": [ - Image.open(f"bot/resources/snakes/snake_cards/backs/{file}") - for file in os.listdir("bot/resources/snakes/snake_cards/backs") - ], - "font": ImageFont.truetype("bot/resources/snakes/snake_cards/expressway.ttf", 20) -} -# endregion - - -class Snakes(Cog): - """ - Commands related to snakes, created by our community during the first code jam. - - More information can be found in the code-jam-1 repo. - - https://github.com/python-discord/code-jam-1 - """ - - wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) - valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp') - - def __init__(self, bot: Bot): - self.active_sal = {} - self.bot = bot - self.snake_names = utils.get_resource("snake_names") - self.snake_idioms = utils.get_resource("snake_idioms") - self.snake_quizzes = utils.get_resource("snake_quiz") - self.snake_facts = utils.get_resource("snake_facts") - - # region: Helper methods - @staticmethod - def _beautiful_pastel(hue: float) -> int: - """Returns random bright pastels.""" - light = random.uniform(0.7, 0.85) - saturation = 1 - - rgb = colorsys.hls_to_rgb(hue, light, saturation) - hex_rgb = "" - - for part in rgb: - value = int(part * 0xFF) - hex_rgb += f"{value:02x}" - - return int(hex_rgb, 16) - - @staticmethod - def _generate_card(buffer: BytesIO, content: dict) -> BytesIO: - """ - Generate a card from snake information. - - Written by juan and Someone during the first code jam. - """ - snake = Image.open(buffer) - - # Get the size of the snake icon, configure the height of the image box (yes, it changes) - icon_width = 347 # Hardcoded, not much i can do about that - icon_height = int((icon_width / snake.width) * snake.height) - frame_copies = icon_height // CARD['frame'].height + 1 - snake.thumbnail((icon_width, icon_height)) - - # Get the dimensions of the final image - main_height = icon_height + CARD['top'].height + CARD['bottom'].height - main_width = CARD['frame'].width - - # Start creating the foreground - foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) - foreground.paste(CARD['top'], (0, 0)) - - # Generate the frame borders to the correct height - for offset in range(frame_copies): - position = (0, CARD['top'].height + offset * CARD['frame'].height) - foreground.paste(CARD['frame'], position) - - # Add the image and bottom part of the image - foreground.paste(snake, (36, CARD['top'].height)) # Also hardcoded :( - foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height)) - - # Setup the background - back = random.choice(CARD['backs']) - back_copies = main_height // back.height + 1 - full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) - - # Generate the tiled background - for offset in range(back_copies): - full_image.paste(back, (16, 16 + offset * back.height)) - - # Place the foreground onto the final image - full_image.paste(foreground, (0, 0), foreground) - - # Get the first two sentences of the info - description = '.'.join(content['info'].split(".")[:2]) + '.' - - # Setup positioning variables - margin = 36 - offset = CARD['top'].height + icon_height + margin - - # Create blank rectangle image which will be behind the text - rectangle = Image.new( - "RGBA", - (main_width, main_height), - (0, 0, 0, 0) - ) - - # Draw a semi-transparent rectangle on it - rect = ImageDraw.Draw(rectangle) - rect.rectangle( - (margin, offset, main_width - margin, main_height - margin), - fill=(63, 63, 63, 128) - ) - - # Paste it onto the final image - full_image.paste(rectangle, (0, 0), mask=rectangle) - - # Draw the text onto the final image - draw = ImageDraw.Draw(full_image) - for line in textwrap.wrap(description, 36): - draw.text([margin + 4, offset], line, font=CARD['font']) - offset += CARD['font'].getsize(line)[1] - - # Get the image contents as a BufferIO object - buffer = BytesIO() - full_image.save(buffer, 'PNG') - buffer.seek(0) - - return buffer - - @staticmethod - def _snakify(message: str) -> str: - """Sssnakifffiesss a sstring.""" - # Replace fricatives with exaggerated snake fricatives. - simple_fricatives = [ - "f", "s", "z", "h", - "F", "S", "Z", "H", - ] - complex_fricatives = [ - "th", "sh", "Th", "Sh" - ] - - for letter in simple_fricatives: - if letter.islower(): - message = message.replace(letter, letter * random.randint(2, 4)) - else: - message = message.replace(letter, (letter * random.randint(2, 4)).title()) - - for fricative in complex_fricatives: - message = message.replace(fricative, fricative[0] + fricative[1] * random.randint(2, 4)) - - return message - - async def _fetch(self, session: aiohttp.ClientSession, url: str, params: dict = None) -> dict: - """Asynchronous web request helper method.""" - if params is None: - params = {} - - async with async_timeout.timeout(10): - async with session.get(url, params=params) as response: - return await response.json() - - def _get_random_long_message(self, messages: List[str], retries: int = 10) -> str: - """ - Fetch a message that's at least 3 words long, if possible to do so in retries attempts. - - Else, just return whatever the last message is. - """ - long_message = random.choice(messages) - if len(long_message.split()) < 3 and retries > 0: - return self._get_random_long_message( - messages, - retries=retries - 1 - ) - - return long_message - - async def _get_snek(self, name: str) -> Dict[str, Any]: - """ - Fetches all the data from a wikipedia article about a snake. - - Builds a dict that the .get() method can use. - - Created by Ava and eivl. - """ - snake_info = {} - - async with aiohttp.ClientSession() as session: - params = { - 'format': 'json', - 'action': 'query', - 'list': 'search', - 'srsearch': name, - 'utf8': '', - 'srlimit': '1', - } - - json = await self._fetch(session, URL, params=params) - - # Wikipedia does have a error page - try: - pageid = json["query"]["search"][0]["pageid"] - except KeyError: - # Wikipedia error page ID(?) - pageid = 41118 - except IndexError: - return None - - params = { - 'format': 'json', - 'action': 'query', - 'prop': 'extracts|images|info', - 'exlimit': 'max', - 'explaintext': '', - 'inprop': 'url', - 'pageids': pageid - } - - json = await self._fetch(session, URL, params=params) - - # Constructing dict - handle exceptions later - try: - snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] - snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] - snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"] - snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"] - snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"] - except KeyError: - snake_info["error"] = True - - if snake_info["images"]: - i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/' - image_list = [] - map_list = [] - thumb_list = [] - - # Wikipedia has arbitrary images that are not snakes - banned = [ - 'Commons-logo.svg', - 'Red%20Pencil%20Icon.png', - 'distribution', - 'The%20Death%20of%20Cleopatra%20arthur.jpg', - 'Head%20of%20holotype', - 'locator', - 'Woma.png', - '-map.', - '.svg', - 'ange.', - 'Adder%20(PSF).png' - ] - - for image in snake_info["images"]: - # Images come in the format of `File:filename.extension` - file, sep, filename = image["title"].partition(':') - filename = filename.replace(" ", "%20") # Wikipedia returns good data! - - if not filename.startswith('Map'): - if any(ban in filename for ban in banned): - pass - else: - image_list.append(f"{i_url}{filename}") - thumb_list.append(f"{i_url}{filename}?width=100") - else: - map_list.append(f"{i_url}{filename}") - - snake_info["image_list"] = image_list - snake_info["map_list"] = map_list - snake_info["thumb_list"] = thumb_list - snake_info["name"] = name - - match = self.wiki_brief.match(snake_info['extract']) - info = match.group(1) if match else None - - if info: - info = info.replace("\n", "\n\n") # Give us some proper paragraphs. - - snake_info["info"] = info - - return snake_info - - async def _get_snake_name(self) -> Dict[str, str]: - """Gets a random snake name.""" - return random.choice(self.snake_names) - - async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list) -> None: - """Validate the answer using a reaction event loop.""" - def predicate(reaction: Reaction, user: Member) -> bool: - """Test if the the answer is valid and can be evaluated.""" - return ( - reaction.message.id == message.id # The reaction is attached to the question we asked. - and user == ctx.author # It's the user who triggered the quiz. - and str(reaction.emoji) in ANSWERS_EMOJI.values() # The reaction is one of the options. - ) - - for emoji in ANSWERS_EMOJI.values(): - await message.add_reaction(emoji) - - # Validate the answer - try: - reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate) - except asyncio.TimeoutError: - await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.") - await message.clear_reactions() - return - - if str(reaction.emoji) == ANSWERS_EMOJI[answer]: - await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") - else: - await ctx.send( - f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**." - ) - - await message.clear_reactions() - # endregion - - # region: Commands - @group(name='snakes', aliases=('snake',), invoke_without_command=True) - async def snakes_group(self, ctx: Context) -> None: - """Commands from our first code jam.""" - await ctx.send_help(ctx.command) - - @bot_has_permissions(manage_messages=True) - @snakes_group.command(name='antidote') - @locked() - async def antidote_command(self, ctx: Context) -> None: - """ - Antidote! Can you create the antivenom before the patient dies? - - Rules: You have 4 ingredients for each antidote, you only have 10 attempts - Once you synthesize the antidote, you will be presented with 4 markers - Tick: This means you have a CORRECT ingredient in the CORRECT position - Circle: This means you have a CORRECT ingredient in the WRONG position - Cross: This means you have a WRONG ingredient in the WRONG position - - Info: The game automatically ends after 5 minutes inactivity. - You should only use each ingredient once. - - This game was created by Lord Bisk and Runew0lf. - """ - def predicate(reaction_: Reaction, user_: Member) -> bool: - """Make sure that this reaction is what we want to operate on.""" - return ( - all(( - # Reaction is on this message - reaction_.message.id == board_id.id, - # Reaction is one of the pagination emotes - reaction_.emoji in ANTIDOTE_EMOJI, - # Reaction was not made by the Bot - user_.id != self.bot.user.id, - # Reaction was made by author - user_.id == ctx.author.id - )) - ) - - # Initialize variables - antidote_tries = 0 - antidote_guess_count = 0 - antidote_guess_list = [] - guess_result = [] - board = [] - page_guess_list = [] - page_result_list = [] - win = False - - antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") - antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) - - # Generate answer - antidote_answer = list(ANTIDOTE_EMOJI) # Duplicate list, not reference it - random.shuffle(antidote_answer) - antidote_answer.pop() - - # Begin initial board building - for i in range(0, 10): - page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}") - page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}") - board.append(f"`{i+1:02d}` " - f"{page_guess_list[i]} - " - f"{page_result_list[i]}") - board.append(EMPTY_UNICODE) - antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board)) - board_id = await ctx.send(embed=antidote_embed) # Display board - - # Add our player reactions - for emoji in ANTIDOTE_EMOJI: - await board_id.add_reaction(emoji) - - # Begin main game loop - while not win and antidote_tries < 10: - try: - reaction, user = await ctx.bot.wait_for( - "reaction_add", timeout=300, check=predicate) - except asyncio.TimeoutError: - log.debug("Antidote timed out waiting for a reaction") - break # We're done, no reactions for the last 5 minutes - - if antidote_tries < 10: - if antidote_guess_count < 4: - if reaction.emoji in ANTIDOTE_EMOJI: - antidote_guess_list.append(reaction.emoji) - antidote_guess_count += 1 - - if antidote_guess_count == 4: # Guesses complete - antidote_guess_count = 0 - page_guess_list[antidote_tries] = " ".join(antidote_guess_list) - - # Now check guess - for i in range(0, len(antidote_answer)): - if antidote_guess_list[i] == antidote_answer[i]: - guess_result.append(TICK_EMOJI) - elif antidote_guess_list[i] in antidote_answer: - guess_result.append(BLANK_EMOJI) - else: - guess_result.append(CROSS_EMOJI) - guess_result.sort() - page_result_list[antidote_tries] = " ".join(guess_result) - - # Rebuild the board - board = [] - for i in range(0, 10): - board.append(f"`{i+1:02d}` " - f"{page_guess_list[i]} - " - f"{page_result_list[i]}") - board.append(EMPTY_UNICODE) - - # Remove Reactions - for emoji in antidote_guess_list: - await board_id.remove_reaction(emoji, user) - - if antidote_guess_list == antidote_answer: - win = True - - antidote_tries += 1 - guess_result = [] - antidote_guess_list = [] - - antidote_embed.clear_fields() - antidote_embed.add_field(name=f"{10 - antidote_tries} " - f"guesses remaining", - value="\n".join(board)) - # Redisplay the board - await board_id.edit(embed=antidote_embed) - - # Winning / Ending Screen - if win is True: - antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") - antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) - antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") - antidote_embed.add_field(name=f"You have created the snake antidote!", - value=f"The solution was: {' '.join(antidote_answer)}\n" - f"You had {10 - antidote_tries} tries remaining.") - await board_id.edit(embed=antidote_embed) - else: - antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") - antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) - antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif") - antidote_embed.add_field(name=EMPTY_UNICODE, - value=f"Sorry you didnt make the antidote in time.\n" - f"The formula was {' '.join(antidote_answer)}") - await board_id.edit(embed=antidote_embed) - - log.debug("Ending pagination and removing all reactions...") - await board_id.clear_reactions() - - @snakes_group.command(name='draw') - async def draw_command(self, ctx: Context) -> None: - """ - Draws a random snek using Perlin noise. - - Written by Momo and kel. - Modified by juan and lemon. - """ - with ctx.typing(): - - # Generate random snake attributes - width = random.randint(6, 10) - length = random.randint(15, 22) - random_hue = random.random() - snek_color = self._beautiful_pastel(random_hue) - text_color = self._beautiful_pastel((random_hue + 0.5) % 1) - bg_color = ( - random.randint(32, 50), - random.randint(32, 50), - random.randint(50, 70), - ) - - # Build and send the snek - text = random.choice(self.snake_idioms)["idiom"] - factory = utils.PerlinNoiseFactory(dimension=1, octaves=2) - image_frame = utils.create_snek_frame( - factory, - snake_width=width, - snake_length=length, - snake_color=snek_color, - text=text, - text_color=text_color, - bg_color=bg_color - ) - png_bytes = utils.frame_to_png_bytes(image_frame) - file = File(png_bytes, filename='snek.png') - await ctx.send(file=file) - - @snakes_group.command(name='get') - @bot_has_permissions(manage_messages=True) - @locked() - async def get_command(self, ctx: Context, *, name: Snake = None) -> None: - """ - Fetches information about a snake from Wikipedia. - - Created by Ava and eivl. - """ - with ctx.typing(): - if name is None: - name = await Snake.random() - - if isinstance(name, dict): - data = name - else: - data = await self._get_snek(name) - - if data.get('error'): - return await ctx.send('Could not fetch data from Wikipedia.') - - description = data["info"] - - # Shorten the description if needed - if len(description) > 1000: - description = description[:1000] - last_newline = description.rfind("\n") - if last_newline > 0: - description = description[:last_newline] - - # Strip and add the Wiki link. - if "fullurl" in data: - description = description.strip("\n") - description += f"\n\nRead more on [Wikipedia]({data['fullurl']})" - - # Build and send the embed. - embed = Embed( - title=data.get("title", data.get('name')), - description=description, - colour=0x59982F, - ) - - emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png' - image = next((url for url in data['image_list'] - if url.endswith(self.valid_image_extensions)), emoji) - embed.set_image(url=image) - - await ctx.send(embed=embed) - - @snakes_group.command(name='guess', aliases=('identify',)) - @locked() - async def guess_command(self, ctx: Context) -> None: - """ - Snake identifying game. - - Made by Ava and eivl. - Modified by lemon. - """ - with ctx.typing(): - - image = None - - while image is None: - snakes = [await Snake.random() for _ in range(4)] - snake = random.choice(snakes) - answer = "abcd"[snakes.index(snake)] - - data = await self._get_snek(snake) - - image = next((url for url in data['image_list'] - if url.endswith(self.valid_image_extensions)), None) - - embed = Embed( - title='Which of the following is the snake in the image?', - description="\n".join( - f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes), - colour=SNAKE_COLOR - ) - embed.set_image(url=image) - - guess = await ctx.send(embed=embed) - options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes} - await self._validate_answer(ctx, guess, answer, options) - - @snakes_group.command(name='hatch') - async def hatch_command(self, ctx: Context) -> None: - """ - Hatches your personal snake. - - Written by Momo and kel. - """ - # Pick a random snake to hatch. - snake_name = random.choice(list(utils.snakes.keys())) - snake_image = utils.snakes[snake_name] - - # Hatch the snake - message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:...")) - await asyncio.sleep(1) - - for stage in utils.stages: - hatch_embed = Embed(description=stage) - await message.edit(embed=hatch_embed) - await asyncio.sleep(1) - await asyncio.sleep(1) - await message.delete() - - # Build and send the embed. - my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name)) - my_snake_embed.set_thumbnail(url=snake_image) - my_snake_embed.set_footer( - text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator) - ) - - await ctx.channel.send(embed=my_snake_embed) - - @snakes_group.command(name='movie') - async def movie_command(self, ctx: Context) -> None: - """ - Gets a random snake-related movie from OMDB. - - Written by Samuel. - Modified by gdude. - """ - url = "http://www.omdbapi.com/" - page = random.randint(1, 27) - - response = await self.bot.http_session.get( - url, - params={ - "s": "snake", - "page": page, - "type": "movie", - "apikey": Tokens.omdb - } - ) - data = await response.json() - movie = random.choice(data["Search"])["imdbID"] - - response = await self.bot.http_session.get( - url, - params={ - "i": movie, - "apikey": Tokens.omdb - } - ) - data = await response.json() - - embed = Embed( - title=data["Title"], - color=SNAKE_COLOR - ) - - del data["Response"], data["imdbID"], data["Title"] - - for key, value in data.items(): - if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"): - continue - - if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}] - rating = random.choice(value) - - if rating["Source"] != "Internet Movie Database": - embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"]) - - continue - - if key == "Poster": - embed.set_image(url=value) - continue - - elif key == "imdbRating": - key = "IMDB Rating" - - elif key == "imdbVotes": - key = "IMDB Votes" - - embed.add_field(name=key, value=value, inline=True) - - embed.set_footer(text="Data provided by the OMDB API") - - await ctx.channel.send( - embed=embed - ) - - @snakes_group.command(name='quiz') - @locked() - async def quiz_command(self, ctx: Context) -> None: - """ - Asks a snake-related question in the chat and validates the user's guess. - - This was created by Mushy and Cardium, - and modified by Urthas and lemon. - """ - # Prepare a question. - question = random.choice(self.snake_quizzes) - answer = question["answerkey"] - options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()} - - # Build and send the embed. - embed = Embed( - color=SNAKE_COLOR, - title=question["question"], - description="\n".join( - [f"**{key.upper()}**: {answer}" for key, answer in options.items()] - ) - ) - - quiz = await ctx.channel.send("", embed=embed) - await self._validate_answer(ctx, quiz, answer, options) - - @snakes_group.command(name='name', aliases=('name_gen',)) - async def name_command(self, ctx: Context, *, name: str = None) -> None: - """ - Snakifies a username. - - Slices the users name at the last vowel (or second last if the name - ends with a vowel), and then combines it with a random snake name, - which is sliced at the first vowel (or second if the name starts with - a vowel). - - If the name contains no vowels, it just appends the snakename - to the end of the name. - - Examples: - lemon + anaconda = lemoconda - krzsn + anaconda = krzsnconda - gdude + anaconda = gduconda - aperture + anaconda = apertuconda - lucy + python = luthon - joseph + taipan = joseipan - - This was written by Iceman, and modified for inclusion into the bot by lemon. - """ - snake_name = await self._get_snake_name() - snake_name = snake_name['name'] - snake_prefix = "" - - # Set aside every word in the snake name except the last. - if " " in snake_name: - snake_prefix = " ".join(snake_name.split()[:-1]) - snake_name = snake_name.split()[-1] - - # If no name is provided, use whoever called the command. - if name: - user_name = name - else: - user_name = ctx.author.display_name - - # Get the index of the vowel to slice the username at - user_slice_index = len(user_name) - for index, char in enumerate(reversed(user_name)): - if index == 0: - continue - if char.lower() in "aeiouy": - user_slice_index -= index - break - - # Now, get the index of the vowel to slice the snake_name at - snake_slice_index = 0 - for index, char in enumerate(snake_name): - if index == 0: - continue - if char.lower() in "aeiouy": - snake_slice_index = index + 1 - break - - # Combine! - snake_name = snake_name[snake_slice_index:] - user_name = user_name[:user_slice_index] - result = f"{snake_prefix} {user_name}{snake_name}" - result = string.capwords(result) - - # Embed and send - embed = Embed( - title="Snake name", - description=f"Your snake-name is **{result}**", - color=SNAKE_COLOR - ) - - return await ctx.send(embed=embed) - - @snakes_group.command(name='sal') - @locked() - async def sal_command(self, ctx: Context) -> None: - """ - Play a game of Snakes and Ladders. - - Written by Momo and kel. - Modified by lemon. - """ - # Check if there is already a game in this channel - if ctx.channel in self.active_sal: - await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") - return - - game = utils.SnakeAndLaddersGame(snakes=self, context=ctx) - self.active_sal[ctx.channel] = game - - await game.open_game() - - @snakes_group.command(name='about') - async def about_command(self, ctx: Context) -> None: - """Show an embed with information about the event, its participants, and its winners.""" - contributors = [ - "<@!245270749919576066>", - "<@!396290259907903491>", - "<@!172395097705414656>", - "<@!361708843425726474>", - "<@!300302216663793665>", - "<@!210248051430916096>", - "<@!174588005745557505>", - "<@!87793066227822592>", - "<@!211619754039967744>", - "<@!97347867923976192>", - "<@!136081839474343936>", - "<@!263560579770220554>", - "<@!104749643715387392>", - "<@!303940835005825024>", - ] - - embed = Embed( - title="About the snake cog", - description=( - "The features in this cog were created by members of the community " - "during our first ever [code jam event](https://gitlab.com/discord-python/code-jams/code-jam-1). \n\n" - "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over " - "48 hours. The staff then selected the best features from all the best teams, and made modifications " - "to ensure they would all work together before integrating them into the community bot.\n\n" - "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> " - "walked away as grand champions. Make sure you check out `!snakes sal`, `!snakes draw` " - "and `!snakes hatch` to see what they came up with." - ) - ) - - embed.add_field( - name="Contributors", - value=( - ", ".join(contributors) - ) - ) - - await ctx.channel.send(embed=embed) - - @snakes_group.command(name='card') - async def card_command(self, ctx: Context, *, name: Snake = None) -> None: - """ - Create an interesting little card from a snake. - - Created by juan and Someone during the first code jam. - """ - # Get the snake data we need - if not name: - name_obj = await self._get_snake_name() - name = name_obj['scientific'] - content = await self._get_snek(name) - - elif isinstance(name, dict): - content = name - - else: - content = await self._get_snek(name) - - # Make the card - async with ctx.typing(): - - stream = BytesIO() - async with async_timeout.timeout(10): - async with self.bot.http_session.get(content['image_list'][0]) as response: - stream.write(await response.read()) - - stream.seek(0) - - func = partial(self._generate_card, stream, content) - final_buffer = await self.bot.loop.run_in_executor(None, func) - - # Send it! - await ctx.send( - f"A wild {content['name'].title()} appears!", - file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png") - ) - - @snakes_group.command(name='fact') - async def fact_command(self, ctx: Context) -> None: - """ - Gets a snake-related fact. - - Written by Andrew and Prithaj. - Modified by lemon. - """ - question = random.choice(self.snake_facts)["fact"] - embed = Embed( - title="Snake fact", - color=SNAKE_COLOR, - description=question - ) - await ctx.channel.send(embed=embed) - - @snakes_group.command(name='snakify') - async def snakify_command(self, ctx: Context, *, message: str = None) -> None: - """ - How would I talk if I were a snake? - - If `message` is passed, the bot will snakify the message. - Otherwise, a random message from the user's history is snakified. - - Written by Momo and kel. - Modified by lemon. - """ - with ctx.typing(): - embed = Embed() - user = ctx.message.author - - if not message: - - # Get a random message from the users history - messages = [] - async for message in ctx.channel.history(limit=500).filter( - lambda msg: msg.author == ctx.message.author # Message was sent by author. - ): - messages.append(message.content) - - message = self._get_random_long_message(messages) - - # Set the avatar - if user.avatar is not None: - avatar = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}" - else: - avatar = ctx.author.default_avatar_url - - # Build and send the embed - embed.set_author( - name=f"{user.name}#{user.discriminator}", - icon_url=avatar, - ) - embed.description = f"*{self._snakify(message)}*" - - await ctx.channel.send(embed=embed) - - @snakes_group.command(name='video', aliases=('get_video',)) - async def video_command(self, ctx: Context, *, search: str = None) -> None: - """ - Gets a YouTube video about snakes. - - If `search` is given, a snake with that name will be searched on Youtube. - - Written by Andrew and Prithaj. - """ - # Are we searching for anything specific? - if search: - query = search + ' snake' - else: - snake = await self._get_snake_name() - query = snake['name'] - - # Build the URL and make the request - url = f'https://www.googleapis.com/youtube/v3/search' - response = await self.bot.http_session.get( - url, - params={ - "part": "snippet", - "q": urllib.parse.quote(query), - "type": "video", - "key": Tokens.youtube - } - ) - response = await response.json() - data = response['items'] - - # Send the user a video - if len(data) > 0: - num = random.randint(0, len(data) - 1) - youtube_base_url = 'https://www.youtube.com/watch?v=' - await ctx.channel.send( - content=f"{youtube_base_url}{data[num]['id']['videoId']}" - ) - else: - log.warning(f"YouTube API error. Full response looks like {response}") - - @snakes_group.command(name='zen') - async def zen_command(self, ctx: Context) -> None: - """ - Gets a random quote from the Zen of Python, except as if spoken by a snake. - - Written by Prithaj and Andrew. - Modified by lemon. - """ - embed = Embed( - title="Zzzen of Pythhon", - color=SNAKE_COLOR - ) - - # Get the zen quote and snakify it - zen_quote = random.choice(ZEN.splitlines()) - zen_quote = self._snakify(zen_quote) - - # Embed and send - embed.description = zen_quote - await ctx.channel.send( - embed=embed - ) - # endregion - - # region: Error handlers - @get_command.error - @card_command.error - @video_command.error - async def command_error(self, ctx: Context, error: CommandError) -> None: - """Local error handler for the Snake Cog.""" - embed = Embed() - embed.colour = Colour.red() - - if isinstance(error, BadArgument): - embed.description = str(error) - embed.title = random.choice(ERROR_REPLIES) - - elif isinstance(error, OSError): - log.error(f"snake_card encountered an OSError: {error} ({error.original})") - embed.description = "Could not generate the snake card! Please try again." - embed.title = random.choice(ERROR_REPLIES) - - else: - log.error(f"Unhandled tag command error: {error} ({error.original})") - return - - await ctx.send(embed=embed) - # endregion diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py deleted file mode 100644 index 7d6caf04..00000000 --- a/bot/seasons/evergreen/snakes/utils.py +++ /dev/null @@ -1,716 +0,0 @@ -import asyncio -import io -import json -import logging -import math -import random -from itertools import product -from pathlib import Path -from typing import List, Tuple - -from PIL import Image -from PIL.ImageDraw import ImageDraw -from discord import File, Member, Reaction -from discord.ext.commands import Cog, Context - -from bot.constants import Roles - -SNAKE_RESOURCES = Path("bot/resources/snakes").absolute() - -h1 = r'''``` - ---- - ------ - /--------\ - |--------| - |--------| - \------/ - ----```''' -h2 = r'''``` - ---- - ------ - /---\-/--\ - |-----\--| - |--------| - \------/ - ----```''' -h3 = r'''``` - ---- - ------ - /---\-/--\ - |-----\--| - |-----/--| - \----\-/ - ----```''' -h4 = r'''``` - ----- - ----- \ - /--| /---\ - |--\ -\---| - |--\--/-- / - \------- / - ------```''' -stages = [h1, h2, h3, h4] -snakes = { - "Baby Python": "https://i.imgur.com/SYOcmSa.png", - "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png", - "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png", - "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png", - "Baby Cobra": "https://i.imgur.com/jk14ryt.png" -} - -BOARD_TILE_SIZE = 56 # the size of each board tile -BOARD_PLAYER_SIZE = 20 # the size of each player icon -BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons) -# The size of the image to download -# Should a power of 2 and higher than BOARD_PLAYER_SIZE -PLAYER_ICON_IMAGE_SIZE = 32 -MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board - -# board definition (from, to) -BOARD = { - # ladders - 2: 38, - 7: 14, - 8: 31, - 15: 26, - 21: 42, - 28: 84, - 36: 44, - 51: 67, - 71: 91, - 78: 98, - 87: 94, - - # snakes - 99: 80, - 95: 75, - 92: 88, - 89: 68, - 74: 53, - 64: 60, - 62: 19, - 49: 11, - 46: 25, - 16: 6 -} - -DEFAULT_SNAKE_COLOR: int = 0x15c7ea -DEFAULT_BACKGROUND_COLOR: int = 0 -DEFAULT_IMAGE_DIMENSIONS: Tuple[int] = (200, 200) -DEFAULT_SNAKE_LENGTH: int = 22 -DEFAULT_SNAKE_WIDTH: int = 8 -DEFAULT_SEGMENT_LENGTH_RANGE: Tuple[int] = (7, 10) -DEFAULT_IMAGE_MARGINS: Tuple[int] = (50, 50) -DEFAULT_TEXT: str = "snek\nit\nup" -DEFAULT_TEXT_POSITION: Tuple[int] = ( - 10, - 10 -) -DEFAULT_TEXT_COLOR: int = 0xf2ea15 -X = 0 -Y = 1 -ANGLE_RANGE = math.pi * 2 - - -def get_resource(file: str) -> List[dict]: - """Load Snake resources JSON.""" - with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile: - return json.load(snakefile) - - -def smoothstep(t: float) -> float: - """Smooth curve with a zero derivative at 0 and 1, making it useful for interpolating.""" - return t * t * (3. - 2. * t) - - -def lerp(t: float, a: float, b: float) -> float: - """Linear interpolation between a and b, given a fraction t.""" - return a + t * (b - a) - - -class PerlinNoiseFactory(object): - """ - Callable that produces Perlin noise for an arbitrary point in an arbitrary number of dimensions. - - The underlying grid is aligned with the integers. - - There is no limit to the coordinates used; new gradients are generated on the fly as necessary. - - Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 - Licensed under ISC - """ - - def __init__(self, dimension: int, octaves: int = 1, tile: Tuple[int] = (), unbias: bool = False): - """ - Create a new Perlin noise factory in the given number of dimensions. - - dimension should be an integer and at least 1. - - More octaves create a foggier and more-detailed noise pattern. More than 4 octaves is rather excessive. - - ``tile`` can be used to make a seamlessly tiling pattern. - For example: - pnf = PerlinNoiseFactory(2, tile=(0, 3)) - - This will produce noise that tiles every 3 units vertically, but never tiles horizontally. - - If ``unbias`` is True, the smoothstep function will be applied to the output before returning - it, to counteract some of Perlin noise's significant bias towards the center of its output range. - """ - self.dimension = dimension - self.octaves = octaves - self.tile = tile + (0,) * dimension - self.unbias = unbias - - # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply - # by this to scale to ±1 - self.scale_factor = 2 * dimension ** -0.5 - - self.gradient = {} - - def _generate_gradient(self) -> Tuple[float, ...]: - """ - Generate a random unit vector at each grid point. - - This is the "gradient" vector, in that the grid tile slopes towards it - """ - # 1 dimension is special, since the only unit vector is trivial; - # instead, use a slope between -1 and 1 - if self.dimension == 1: - return (random.uniform(-1, 1),) - - # Generate a random point on the surface of the unit n-hypersphere; - # this is the same as a random unit vector in n dimensions. Thanks - # to: http://mathworld.wolfram.com/SpherePointPicking.html - # Pick n normal random variables with stddev 1 - random_point = [random.gauss(0, 1) for _ in range(self.dimension)] - # Then scale the result to a unit vector - scale = sum(n * n for n in random_point) ** -0.5 - return tuple(coord * scale for coord in random_point) - - def get_plain_noise(self, *point) -> float: - """Get plain noise for a single point, without taking into account either octaves or tiling.""" - if len(point) != self.dimension: - raise ValueError("Expected {0} values, got {1}".format( - self.dimension, len(point))) - - # Build a list of the (min, max) bounds in each dimension - grid_coords = [] - for coord in point: - min_coord = math.floor(coord) - max_coord = min_coord + 1 - grid_coords.append((min_coord, max_coord)) - - # Compute the dot product of each gradient vector and the point's - # distance from the corresponding grid point. This gives you each - # gradient's "influence" on the chosen point. - dots = [] - for grid_point in product(*grid_coords): - if grid_point not in self.gradient: - self.gradient[grid_point] = self._generate_gradient() - gradient = self.gradient[grid_point] - - dot = 0 - for i in range(self.dimension): - dot += gradient[i] * (point[i] - grid_point[i]) - dots.append(dot) - - # Interpolate all those dot products together. The interpolation is - # done with smoothstep to smooth out the slope as you pass from one - # grid cell into the next. - # Due to the way product() works, dot products are ordered such that - # the last dimension alternates: (..., min), (..., max), etc. So we - # can interpolate adjacent pairs to "collapse" that last dimension. Then - # the results will alternate in their second-to-last dimension, and so - # forth, until we only have a single value left. - dim = self.dimension - while len(dots) > 1: - dim -= 1 - s = smoothstep(point[dim] - grid_coords[dim][0]) - - next_dots = [] - while dots: - next_dots.append(lerp(s, dots.pop(0), dots.pop(0))) - - dots = next_dots - - return dots[0] * self.scale_factor - - def __call__(self, *point) -> float: - """ - Get the value of this Perlin noise function at the given point. - - The number of values given should match the number of dimensions. - """ - ret = 0 - for o in range(self.octaves): - o2 = 1 << o - new_point = [] - for i, coord in enumerate(point): - coord *= o2 - if self.tile[i]: - coord %= self.tile[i] * o2 - new_point.append(coord) - ret += self.get_plain_noise(*new_point) / o2 - - # Need to scale n back down since adding all those extra octaves has - # probably expanded it beyond ±1 - # 1 octave: ±1 - # 2 octaves: ±1½ - # 3 octaves: ±1¾ - ret /= 2 - 2 ** (1 - self.octaves) - - if self.unbias: - # The output of the plain Perlin noise algorithm has a fairly - # strong bias towards the center due to the central limit theorem - # -- in fact the top and bottom 1/8 virtually never happen. That's - # a quarter of our entire output range! If only we had a function - # in [0..1] that could introduce a bias towards the endpoints... - r = (ret + 1) / 2 - # Doing it this many times is a completely made-up heuristic. - for _ in range(int(self.octaves / 2 + 0.5)): - r = smoothstep(r) - ret = r * 2 - 1 - - return ret - - -def create_snek_frame( - perlin_factory: PerlinNoiseFactory, perlin_lookup_vertical_shift: float = 0, - image_dimensions: Tuple[int] = DEFAULT_IMAGE_DIMENSIONS, image_margins: Tuple[int] = DEFAULT_IMAGE_MARGINS, - snake_length: int = DEFAULT_SNAKE_LENGTH, - snake_color: int = DEFAULT_SNAKE_COLOR, bg_color: int = DEFAULT_BACKGROUND_COLOR, - segment_length_range: Tuple[int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH, - text: str = DEFAULT_TEXT, text_position: Tuple[int] = DEFAULT_TEXT_POSITION, - text_color: Tuple[int] = DEFAULT_TEXT_COLOR -) -> Image: - """ - Creates a single random snek frame using Perlin noise. - - `perlin_lookup_vertical_shift` represents the Perlin noise shift in the Y-dimension for this frame. - If `text` is given, display the given text with the snek. - """ - start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) - start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) - points = [(start_x, start_y)] - - for index in range(0, snake_length): - angle = perlin_factory.get_plain_noise( - ((1 / (snake_length + 1)) * (index + 1)) + perlin_lookup_vertical_shift - ) * ANGLE_RANGE - current_point = points[index] - segment_length = random.randint(segment_length_range[0], segment_length_range[1]) - points.append(( - current_point[X] + segment_length * math.cos(angle), - current_point[Y] + segment_length * math.sin(angle) - )) - - # normalize bounds - min_dimensions = [start_x, start_y] - max_dimensions = [start_x, start_y] - for point in points: - min_dimensions[X] = min(point[X], min_dimensions[X]) - min_dimensions[Y] = min(point[Y], min_dimensions[Y]) - max_dimensions[X] = max(point[X], max_dimensions[X]) - max_dimensions[Y] = max(point[Y], max_dimensions[Y]) - - # shift towards middle - dimension_range = (max_dimensions[X] - min_dimensions[X], max_dimensions[Y] - min_dimensions[Y]) - shift = ( - image_dimensions[X] / 2 - (dimension_range[X] / 2 + min_dimensions[X]), - image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y]) - ) - - image = Image.new(mode='RGB', size=image_dimensions, color=bg_color) - draw = ImageDraw(image) - for index in range(1, len(points)): - point = points[index] - previous = points[index - 1] - draw.line( - ( - shift[X] + previous[X], - shift[Y] + previous[Y], - shift[X] + point[X], - shift[Y] + point[Y] - ), - width=snake_width, - fill=snake_color - ) - if text is not None: - draw.multiline_text(text_position, text, fill=text_color) - del draw - return image - - -def frame_to_png_bytes(image: Image) -> io.BytesIO: - """Convert image to byte stream.""" - stream = io.BytesIO() - image.save(stream, format='PNG') - stream.seek(0) - return stream - - -log = logging.getLogger(__name__) -START_EMOJI = "\u2611" # :ballot_box_with_check: - Start the game -CANCEL_EMOJI = "\u274C" # :x: - Cancel or leave the game -ROLL_EMOJI = "\U0001F3B2" # :game_die: - Roll the die! -JOIN_EMOJI = "\U0001F64B" # :raising_hand: - Join the game. -STARTUP_SCREEN_EMOJI = [ - JOIN_EMOJI, - START_EMOJI, - CANCEL_EMOJI -] -GAME_SCREEN_EMOJI = [ - ROLL_EMOJI, - CANCEL_EMOJI -] - - -class SnakeAndLaddersGame: - """Snakes and Ladders game Cog.""" - - def __init__(self, snakes: Cog, context: Context): - self.snakes = snakes - self.ctx = context - self.channel = self.ctx.channel - self.state = 'booting' - self.started = False - self.author = self.ctx.author - self.players = [] - self.player_tiles = {} - self.round_has_rolled = {} - self.avatar_images = {} - self.board = None - self.positions = None - self.rolls = [] - - async def open_game(self) -> None: - """ - Create a new Snakes and Ladders game. - - Listen for reactions until players have joined, and the game has been started. - """ - def startup_event_check(reaction_: Reaction, user_: Member) -> bool: - """Make sure that this reaction is what we want to operate on.""" - return ( - all(( - reaction_.message.id == startup.id, # Reaction is on startup message - reaction_.emoji in STARTUP_SCREEN_EMOJI, # Reaction is one of the startup emotes - user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot - )) - ) - - # Check to see if the bot can remove reactions - if not self.channel.permissions_for(self.ctx.guild.me).manage_messages: - log.warning( - "Unable to start Snakes and Ladders - " - f"Missing manage_messages permissions in {self.channel}" - ) - return - - await self._add_player(self.author) - await self.channel.send( - "**Snakes and Ladders**: A new game is about to start!", - file=File( - str(SNAKE_RESOURCES / "snakes_and_ladders" / "banner.jpg"), - filename='Snakes and Ladders.jpg' - ) - ) - startup = await self.channel.send( - f"Press {JOIN_EMOJI} to participate, and press " - f"{START_EMOJI} to start the game" - ) - for emoji in STARTUP_SCREEN_EMOJI: - await startup.add_reaction(emoji) - - self.state = 'waiting' - - while not self.started: - try: - reaction, user = await self.ctx.bot.wait_for( - "reaction_add", - timeout=300, - check=startup_event_check - ) - if reaction.emoji == JOIN_EMOJI: - await self.player_join(user) - elif reaction.emoji == CANCEL_EMOJI: - if user == self.author or (self._is_moderator(user) and user not in self.players): - # Allow game author or non-playing moderation staff to cancel a waiting game - await self.cancel_game() - return - else: - await self.player_leave(user) - elif reaction.emoji == START_EMOJI: - if self.ctx.author == user: - self.started = True - await self.start_game(user) - await startup.delete() - break - - await startup.remove_reaction(reaction.emoji, user) - - except asyncio.TimeoutError: - log.debug("Snakes and Ladders timed out waiting for a reaction") - await self.cancel_game() - return # We're done, no reactions for the last 5 minutes - - async def _add_player(self, user: Member) -> None: - """Add player to game.""" - self.players.append(user) - self.player_tiles[user.id] = 1 - - avatar_bytes = await user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE).read() - im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) - self.avatar_images[user.id] = im - - async def player_join(self, user: Member) -> None: - """ - Handle players joining the game. - - Prevent player joining if they have already joined, if the game is full, or if the game is - in a waiting state. - """ - for p in self.players: - if user == p: - await self.channel.send(user.mention + " You are already in the game.", delete_after=10) - return - if self.state != 'waiting': - await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10) - return - if len(self.players) is MAX_PLAYERS: - await self.channel.send(user.mention + " The game is full!", delete_after=10) - return - - await self._add_player(user) - - await self.channel.send( - f"**Snakes and Ladders**: {user.mention} has joined the game.\n" - f"There are now {str(len(self.players))} players in the game.", - delete_after=10 - ) - - async def player_leave(self, user: Member) -> bool: - """ - Handle players leaving the game. - - Leaving is prevented if the user wasn't part of the game. - - If the number of players reaches 0, the game is terminated. In this case, a sentinel boolean - is returned True to prevent a game from continuing after it's destroyed. - """ - is_surrendered = False # Sentinel value to assist with stopping a surrendered game - for p in self.players: - if user == p: - self.players.remove(p) - self.player_tiles.pop(p.id, None) - self.round_has_rolled.pop(p.id, None) - await self.channel.send( - "**Snakes and Ladders**: " + user.mention + " has left the game.", - delete_after=10 - ) - - if self.state != 'waiting' and len(self.players) == 0: - await self.channel.send("**Snakes and Ladders**: The game has been surrendered!") - is_surrendered = True - self._destruct() - - return is_surrendered - else: - await self.channel.send(user.mention + " You are not in the match.", delete_after=10) - return is_surrendered - - async def cancel_game(self) -> None: - """Cancel the running game.""" - await self.channel.send("**Snakes and Ladders**: Game has been canceled.") - self._destruct() - - async def start_game(self, user: Member) -> None: - """ - Allow the game author to begin the game. - - The game cannot be started if the game is in a waiting state. - """ - if not user == self.author: - await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) - return - - if not self.state == 'waiting': - await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10) - return - - self.state = 'starting' - player_list = ', '.join(user.mention for user in self.players) - await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list) - await self.start_round() - - async def start_round(self) -> None: - """Begin the round.""" - def game_event_check(reaction_: Reaction, user_: Member) -> bool: - """Make sure that this reaction is what we want to operate on.""" - return ( - all(( - reaction_.message.id == self.positions.id, # Reaction is on positions message - reaction_.emoji in GAME_SCREEN_EMOJI, # Reaction is one of the game emotes - user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot - )) - ) - - self.state = 'roll' - for user in self.players: - self.round_has_rolled[user.id] = False - board_img = Image.open(str(SNAKE_RESOURCES / "snakes_and_ladders" / "board.jpg")) - player_row_size = math.ceil(MAX_PLAYERS / 2) - - for i, player in enumerate(self.players): - tile = self.player_tiles[player.id] - tile_coordinates = self._board_coordinate_from_index(tile) - x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE - y_offset = \ - BOARD_MARGIN[1] + ( - (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE) - x_offset += BOARD_PLAYER_SIZE * (i % player_row_size) - y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size) - board_img.paste(self.avatar_images[player.id], - box=(x_offset, y_offset)) - - board_file = File(frame_to_png_bytes(board_img), filename='Board.jpg') - player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) - - # Store and send new messages - temp_board = await self.channel.send( - "**Snakes and Ladders**: A new round has started! Current board:", - file=board_file - ) - temp_positions = await self.channel.send( - f"**Current positions**:\n{player_list}\n\nUse {ROLL_EMOJI} to roll the dice!" - ) - - # Delete the previous messages - if self.board and self.positions: - await self.board.delete() - await self.positions.delete() - - # remove the roll messages - for roll in self.rolls: - await roll.delete() - self.rolls = [] - - # Save new messages - self.board = temp_board - self.positions = temp_positions - - # Wait for rolls - for emoji in GAME_SCREEN_EMOJI: - await self.positions.add_reaction(emoji) - - is_surrendered = False - while True: - try: - reaction, user = await self.ctx.bot.wait_for( - "reaction_add", - timeout=300, - check=game_event_check - ) - - if reaction.emoji == ROLL_EMOJI: - await self.player_roll(user) - elif reaction.emoji == CANCEL_EMOJI: - if self._is_moderator(user) and user not in self.players: - # Only allow non-playing moderation staff to cancel a running game - await self.cancel_game() - return - else: - is_surrendered = await self.player_leave(user) - - await self.positions.remove_reaction(reaction.emoji, user) - - if self._check_all_rolled(): - break - - except asyncio.TimeoutError: - log.debug("Snakes and Ladders timed out waiting for a reaction") - await self.cancel_game() - return # We're done, no reactions for the last 5 minutes - - # Round completed - # Check to see if the game was surrendered before completing the round, without this - # sentinel, the game object would be deleted but the next round still posted into purgatory - if not is_surrendered: - await self._complete_round() - - async def player_roll(self, user: Member) -> None: - """Handle the player's roll.""" - if user.id not in self.player_tiles: - await self.channel.send(user.mention + " You are not in the match.", delete_after=10) - return - if self.state != 'roll': - await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10) - return - if self.round_has_rolled[user.id]: - return - roll = random.randint(1, 6) - self.rolls.append(await self.channel.send(f"{user.mention} rolled a **{roll}**!")) - next_tile = self.player_tiles[user.id] + roll - - # apply snakes and ladders - if next_tile in BOARD: - target = BOARD[next_tile] - if target < next_tile: - await self.channel.send( - f"{user.mention} slips on a snake and falls back to **{target}**", - delete_after=15 - ) - else: - await self.channel.send( - f"{user.mention} climbs a ladder to **{target}**", - delete_after=15 - ) - next_tile = target - - self.player_tiles[user.id] = min(100, next_tile) - self.round_has_rolled[user.id] = True - - async def _complete_round(self) -> None: - """At the conclusion of a round check to see if there's been a winner.""" - self.state = 'post_round' - - # check for winner - winner = self._check_winner() - if winner is None: - # there is no winner, start the next round - await self.start_round() - return - - # announce winner and exit - await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") - self._destruct() - - def _check_winner(self) -> Member: - """Return a winning member if we're in the post-round state and there's a winner.""" - if self.state != 'post_round': - return None - return next((player for player in self.players if self.player_tiles[player.id] == 100), - None) - - def _check_all_rolled(self) -> bool: - """Check if all members have made their roll.""" - return all(rolled for rolled in self.round_has_rolled.values()) - - def _destruct(self) -> None: - """Clean up the finished game object.""" - del self.snakes.active_sal[self.channel] - - def _board_coordinate_from_index(self, index: int) -> Tuple[int, int]: - """Convert the tile number to the x/y coordinates for graphical purposes.""" - y_level = 9 - math.floor((index - 1) / 10) - is_reversed = math.floor((index - 1) / 10) % 2 != 0 - x_level = (index - 1) % 10 - if is_reversed: - x_level = 9 - x_level - return x_level, y_level - - @staticmethod - def _is_moderator(user: Member) -> bool: - """Return True if the user is a Moderator.""" - return any(Roles.moderator == role.id for role in user.roles) diff --git a/bot/seasons/evergreen/speedrun.py b/bot/seasons/evergreen/speedrun.py deleted file mode 100644 index 76c5e8d3..00000000 --- a/bot/seasons/evergreen/speedrun.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -import logging -from pathlib import Path -from random import choice - -from discord.ext import commands - -log = logging.getLogger(__name__) -with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf-8") as file: - LINKS = json.load(file) - - -class Speedrun(commands.Cog): - """Commands about the video game speedrunning community.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(name="speedrun") - async def get_speedrun(self, ctx: commands.Context) -> None: - """Sends a link to a video of a random speedrun.""" - await ctx.send(choice(LINKS)) - - -def setup(bot: commands.Bot) -> None: - """Load the Speedrun cog.""" - bot.add_cog(Speedrun(bot)) - log.info("Speedrun cog loaded") diff --git a/bot/seasons/evergreen/trivia_quiz.py b/bot/seasons/evergreen/trivia_quiz.py deleted file mode 100644 index 99b64497..00000000 --- a/bot/seasons/evergreen/trivia_quiz.py +++ /dev/null @@ -1,303 +0,0 @@ -import asyncio -import json -import logging -import random -from pathlib import Path - -import discord -from discord.ext import commands -from fuzzywuzzy import fuzz - -from bot.constants import Roles - - -logger = logging.getLogger(__name__) - - -WRONG_ANS_RESPONSE = [ - "No one answered correctly!", - "Better luck next time" -] - - -class TriviaQuiz(commands.Cog): - """A cog for all quiz commands.""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.questions = self.load_questions() - self.game_status = {} # A variable to store the game status: either running or not running. - self.game_owners = {} # A variable to store the person's ID who started the quiz game in a channel. - self.question_limit = 4 - self.player_scores = {} # A variable to store all player's scores for a bot session. - self.game_player_scores = {} # A variable to store temporary game player's scores. - self.categories = { - "general": "Test your general knowledge" - # "retro": "Questions related to retro gaming." - } - - @staticmethod - def load_questions() -> dict: - """Load the questions from the JSON file.""" - p = Path("bot", "resources", "evergreen", "trivia_quiz.json") - with p.open() as json_data: - questions = json.load(json_data) - return questions - - @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) - async def quiz_game(self, ctx: commands.Context, category: str = None) -> None: - """ - Start a quiz! - - Questions for the quiz can be selected from the following categories: - - general : Test your general knowledge. (default) - (More to come!) - """ - if ctx.channel.id not in self.game_status: - self.game_status[ctx.channel.id] = False - - if ctx.channel.id not in self.game_player_scores: - self.game_player_scores[ctx.channel.id] = {} - - # Stop game if running. - if self.game_status[ctx.channel.id] is True: - return await ctx.send( - f"Game is already running..." - f"do `{self.bot.command_prefix}quiz stop`" - ) - - # Send embed showing available categories if inputted category is invalid. - if category is None: - category = random.choice(list(self.categories)) - - category = category.lower() - if category not in self.categories: - embed = self.category_embed() - await ctx.send(embed=embed) - return - - # Start game if not running. - if self.game_status[ctx.channel.id] is False: - self.game_owners[ctx.channel.id] = ctx.author - self.game_status[ctx.channel.id] = True - start_embed = self.make_start_embed(category) - - await ctx.send(embed=start_embed) # send an embed with the rules - await asyncio.sleep(1) - - topic = self.questions[category] - - done_question = [] - hint_no = 0 - answer = None - while self.game_status[ctx.channel.id]: - # Exit quiz if number of questions for a round are already sent. - if len(done_question) > self.question_limit and hint_no == 0: - await ctx.send("The round has ended.") - await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) - - self.game_status[ctx.channel.id] = False - del self.game_owners[ctx.channel.id] - self.game_player_scores[ctx.channel.id] = {} - - break - - # If no hint has been sent or any time alert. Basically if hint_no = 0 means it is a new question. - if hint_no == 0: - # Select a random question which has not been used yet. - while True: - question_dict = random.choice(topic) - if question_dict["id"] not in done_question: - done_question.append(question_dict["id"]) - break - - q = question_dict["question"] - answer = question_dict["answer"] - - embed = discord.Embed(colour=discord.Colour.gold()) - embed.title = f"Question #{len(done_question)}" - embed.description = q - await ctx.send(embed=embed) # Send question embed. - - # A function to check whether user input is the correct answer(close to the right answer) - def check(m: discord.Message) -> bool: - ratio = fuzz.ratio(answer.lower(), m.content.lower()) - return ratio > 85 and m.channel == ctx.channel - - try: - msg = await self.bot.wait_for('message', check=check, timeout=10) - except asyncio.TimeoutError: - # In case of TimeoutError and the game has been stopped, then do nothing. - if self.game_status[ctx.channel.id] is False: - break - - # if number of hints sent or time alerts sent is less than 2, then send one. - if hint_no < 2: - hint_no += 1 - if "hints" in question_dict: - hints = question_dict["hints"] - await ctx.send(f"**Hint #{hint_no+1}\n**{hints[hint_no]}") - else: - await ctx.send(f"{30 - hint_no * 10}s left!") - - # Once hint or time alerts has been sent 2 times, the hint_no value will be 3 - # If hint_no > 2, then it means that all hints/time alerts have been sent. - # Also means that the answer is not yet given and the bot sends the answer and the next question. - else: - if self.game_status[ctx.channel.id] is False: - break - - response = random.choice(WRONG_ANS_RESPONSE) - await ctx.send(response) - await self.send_answer(ctx.channel, question_dict) - await asyncio.sleep(1) - - hint_no = 0 # init hint_no = 0 so that 2 hints/time alerts can be sent for the new question. - - await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) - await asyncio.sleep(2) - else: - if self.game_status[ctx.channel.id] is False: - break - - # Reduce points by 25 for every hint/time alert that has been sent. - points = 100 - 25*hint_no - if msg.author in self.game_player_scores[ctx.channel.id]: - self.game_player_scores[ctx.channel.id][msg.author] += points - else: - self.game_player_scores[ctx.channel.id][msg.author] = points - - # Also updating the overall scoreboard. - if msg.author in self.player_scores: - self.player_scores[msg.author] += points - else: - self.player_scores[msg.author] = points - - hint_no = 0 - - await ctx.send(f"{msg.author.mention} got the correct answer :tada: {points} points!") - await self.send_answer(ctx.channel, question_dict) - await self.send_score(ctx.channel, self.game_player_scores[ctx.channel.id]) - await asyncio.sleep(2) - - @staticmethod - def make_start_embed(category: str) -> discord.Embed: - """Generate a starting/introduction embed for the quiz.""" - start_embed = discord.Embed(colour=discord.Colour.red()) - start_embed.title = "Quiz game Starting!!" - start_embed.description = "Each game consists of 5 questions.\n" - start_embed.description += "**Rules :**\nNo cheating and have fun!" - start_embed.description += f"\n **Category** : {category}" - start_embed.set_footer( - text="Points for each question reduces by 25 after 10s or after a hint. Total time is 30s per question" - ) - return start_embed - - @quiz_game.command(name="stop") - async def stop_quiz(self, ctx: commands.Context) -> None: - """ - Stop a quiz game if its running in the channel. - - Note: Only mods or the owner of the quiz can stop it. - """ - if self.game_status[ctx.channel.id] is True: - # Check if the author is the game starter or a moderator. - if ( - ctx.author == self.game_owners[ctx.channel.id] - or any(Roles.moderator == role.id for role in ctx.author.roles) - ): - await ctx.send("Quiz stopped.") - await self.declare_winner(ctx.channel, self.game_player_scores[ctx.channel.id]) - - self.game_status[ctx.channel.id] = False - del self.game_owners[ctx.channel.id] - self.game_player_scores[ctx.channel.id] = {} - else: - await ctx.send(f"{ctx.author.mention}, you are not authorised to stop this game :ghost:!") - else: - await ctx.send("No quiz running.") - - @quiz_game.command(name="leaderboard") - async def leaderboard(self, ctx: commands.Context) -> None: - """View everyone's score for this bot session.""" - await self.send_score(ctx.channel, self.player_scores) - - @staticmethod - async def send_score(channel: discord.TextChannel, player_data: dict) -> None: - """A function which sends the score.""" - if len(player_data) == 0: - await channel.send("No one has made it onto the leaderboard yet.") - return - - embed = discord.Embed(colour=discord.Colour.blue()) - embed.title = "Score Board" - embed.description = "" - - sorted_dict = sorted(player_data.items(), key=lambda a: a[1], reverse=True) - for item in sorted_dict: - embed.description += f"{item[0]} : {item[1]}\n" - - await channel.send(embed=embed) - - @staticmethod - async def declare_winner(channel: discord.TextChannel, player_data: dict) -> None: - """Announce the winner of the quiz in the game channel.""" - if player_data: - highest_points = max(list(player_data.values())) - no_of_winners = list(player_data.values()).count(highest_points) - - # Check if more than 1 player has highest points. - if no_of_winners > 1: - word = "You guys" - winners = [] - points_copy = list(player_data.values()).copy() - - for _ in range(no_of_winners): - index = points_copy.index(highest_points) - winners.append(list(player_data.keys())[index]) - points_copy[index] = 0 - - winners_mention = " ".join(winner.mention for winner in winners) - else: - word = "You" - author_index = list(player_data.values()).index(highest_points) - winner = list(player_data.keys())[author_index] - winners_mention = winner.mention - - await channel.send( - f"Congratulations {winners_mention} :tada: " - f"{word} have won this quiz game with a grand total of {highest_points} points!" - ) - - def category_embed(self) -> discord.Embed: - """Build an embed showing all available trivia categories.""" - embed = discord.Embed(colour=discord.Colour.blue()) - embed.title = "The available question categories are:" - embed.set_footer(text="If a category is not chosen, a random one will be selected.") - embed.description = "" - - for cat, description in self.categories.items(): - embed.description += f"**- {cat.capitalize()}**\n{description.capitalize()}\n" - - return embed - - @staticmethod - async def send_answer(channel: discord.TextChannel, question_dict: dict) -> None: - """Send the correct answer of a question to the game channel.""" - answer = question_dict["answer"] - info = question_dict["info"] - embed = discord.Embed(color=discord.Colour.red()) - embed.title = f"The correct answer is **{answer}**\n" - embed.description = "" - - if info != "": - embed.description += f"**Information**\n{info}\n\n" - - embed.description += "Let's move to the next question.\nRemaining questions: " - await channel.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Load the cog.""" - bot.add_cog(TriviaQuiz(bot)) - logger.debug("TriviaQuiz cog loaded") diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py deleted file mode 100644 index 6f24f545..00000000 --- a/bot/seasons/evergreen/uptime.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging - -import arrow -from dateutil.relativedelta import relativedelta -from discord.ext import commands - -from bot import start_time - -log = logging.getLogger(__name__) - - -class Uptime(commands.Cog): - """A cog for posting the bot's uptime.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(name="uptime") - async def uptime(self, ctx: commands.Context) -> None: - """Responds with the uptime of the bot.""" - difference = relativedelta(start_time - arrow.utcnow()) - uptime_string = start_time.shift( - seconds=-difference.seconds, - minutes=-difference.minutes, - hours=-difference.hours, - days=-difference.days - ).humanize() - await ctx.send(f"I started up {uptime_string}.") - - -def setup(bot: commands.Bot) -> None: - """Uptime Cog load.""" - bot.add_cog(Uptime(bot)) - log.info("Uptime cog loaded") diff --git a/bot/seasons/halloween/8ball.py b/bot/seasons/halloween/8ball.py deleted file mode 100644 index 2e1c2804..00000000 --- a/bot/seasons/halloween/8ball.py +++ /dev/null @@ -1,34 +0,0 @@ -import asyncio -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/halloween/responses.json"), "r", encoding="utf8") as f: - responses = json.load(f) - - -class SpookyEightBall(commands.Cog): - """Spooky Eightball answers.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(aliases=('spooky8ball',)) - async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None: - """Responds with a random response to a question.""" - choice = random.choice(responses['responses']) - msg = await ctx.send(choice[0]) - if len(choice) > 1: - await asyncio.sleep(random.randint(2, 5)) - await msg.edit(content=f"{choice[0]} \n{choice[1]}") - - -def setup(bot: commands.Bot) -> None: - """Spooky Eight Ball Cog Load.""" - bot.add_cog(SpookyEightBall(bot)) - log.info("SpookyEightBall cog loaded") diff --git a/bot/seasons/halloween/__init__.py b/bot/seasons/halloween/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py deleted file mode 100644 index 3f2b895e..00000000 --- a/bot/seasons/halloween/candy_collection.py +++ /dev/null @@ -1,225 +0,0 @@ -import functools -import json -import logging -import os -import random -from typing import List, Union - -import discord -from discord.ext import commands - -from bot.constants import Channels, Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -json_location = os.path.join("bot", "resources", "halloween", "candy_collection.json") - -# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy) -ADD_CANDY_REACTION_CHANCE = 20 # 5% -ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10% -ADD_SKULL_REACTION_CHANCE = 50 # 2% -ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% - - -class CandyCollection(commands.Cog): - """Candy collection game Cog.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - with open(json_location) as candy: - self.candy_json = json.load(candy) - self.msg_reacted = self.candy_json['msg_reacted'] - self.get_candyinfo = dict() - for userinfo in self.candy_json['records']: - userid = userinfo['userid'] - self.get_candyinfo[userid] = userinfo - - @in_month(Month.october) - @commands.Cog.listener() - async def on_message(self, message: discord.Message) -> None: - """Randomly adds candy or skull reaction to non-bot messages in the Event channel.""" - # make sure its a human message - if message.author.bot: - return - # ensure it's hacktober channel - if message.channel.id != Channels.seasonalbot_commands: - return - - # do random check for skull first as it has the lower chance - if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1: - d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False} - self.msg_reacted.append(d) - return await message.add_reaction('\N{SKULL}') - # check for the candy chance next - if random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1: - d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False} - self.msg_reacted.append(d) - return await message.add_reaction('\N{CANDY}') - - @in_month(Month.october) - @commands.Cog.listener() - async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None: - """Add/remove candies from a person if the reaction satisfies criteria.""" - message = reaction.message - # check to ensure the reactor is human - if user.bot: - return - - # check to ensure it is in correct channel - if message.channel.id != Channels.seasonalbot_commands: - return - - # if its not a candy or skull, and it is one of 10 most recent messages, - # proceed to add a skull/candy with higher chance - if str(reaction.emoji) not in ('\N{SKULL}', '\N{CANDY}'): - if message.id in await self.ten_recent_msg(): - await self.reacted_msg_chance(message) - return - - for react in self.msg_reacted: - # check to see if the message id of a message we added a - # reaction to is in json file, and if nobody has won/claimed it yet - if react['msg_id'] == message.id and react['won'] is False: - react['user_reacted'] = user.id - react['won'] = True - try: - # if they have record/candies in json already it will do this - user_records = self.get_candyinfo[user.id] - if str(reaction.emoji) == '\N{CANDY}': - user_records['record'] += 1 - if str(reaction.emoji) == '\N{SKULL}': - if user_records['record'] <= 3: - user_records['record'] = 0 - lost = 'all of your' - else: - lost = random.randint(1, 3) - user_records['record'] -= lost - await self.send_spook_msg(message.author, message.channel, lost) - - except KeyError: - # otherwise it will raise KeyError so we need to add them to file - if str(reaction.emoji) == '\N{CANDY}': - print('ok') - d = {"userid": user.id, "record": 1} - self.candy_json['records'].append(d) - await self.remove_reactions(reaction) - - async def reacted_msg_chance(self, message: discord.Message) -> None: - """ - Randomly add a skull or candy reaction to a message if there is a reaction there already. - - This event has a higher probability of occurring than a reaction add to a message without an - existing reaction. - """ - if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1: - d = {"reaction": '\N{SKULL}', "msg_id": message.id, "won": False} - self.msg_reacted.append(d) - return await message.add_reaction('\N{SKULL}') - - if random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1: - d = {"reaction": '\N{CANDY}', "msg_id": message.id, "won": False} - self.msg_reacted.append(d) - return await message.add_reaction('\N{CANDY}') - - async def ten_recent_msg(self) -> List[int]: - """Get the last 10 messages sent in the channel.""" - ten_recent = [] - recent_msg_id = max( - message.id for message in self.bot._connection._messages - if message.channel.id == Channels.seasonalbot_commands - ) - - channel = await self.hacktober_channel() - ten_recent.append(recent_msg_id) - - for i in range(9): - o = discord.Object(id=recent_msg_id + i) - msg = await next(channel.history(limit=1, before=o)) - ten_recent.append(msg.id) - - return ten_recent - - async def get_message(self, msg_id: int) -> Union[discord.Message, None]: - """Get the message from its ID.""" - try: - o = discord.Object(id=msg_id + 1) - # Use history rather than get_message due to - # poor ratelimit (50/1s vs 1/1s) - msg = await next(self.hacktober_channel.history(limit=1, before=o)) - - if msg.id != msg_id: - return None - - return msg - - except Exception: - return None - - async def hacktober_channel(self) -> discord.TextChannel: - """Get #hacktoberbot channel from its ID.""" - return self.bot.get_channel(id=Channels.seasonalbot_commands) - - async def remove_reactions(self, reaction: discord.Reaction) -> None: - """Remove all candy/skull reactions.""" - try: - async for user in reaction.users(): - await reaction.message.remove_reaction(reaction.emoji, user) - - except discord.HTTPException: - pass - - async def send_spook_msg(self, author: discord.Member, channel: discord.TextChannel, candies: int) -> None: - """Send a spooky message.""" - e = discord.Embed(colour=author.colour) - e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; " - f"I took {candies} candies and quickly took flight.") - await channel.send(embed=e) - - def save_to_json(self) -> None: - """Save JSON to a local file.""" - with open(json_location, 'w') as outfile: - json.dump(self.candy_json, outfile) - - @in_month(Month.october) - @commands.command() - async def candy(self, ctx: commands.Context) -> None: - """Get the candy leaderboard and save to JSON.""" - # Use run_in_executor to prevent blocking - thing = functools.partial(self.save_to_json) - await self.bot.loop.run_in_executor(None, thing) - - emoji = ( - '\N{FIRST PLACE MEDAL}', - '\N{SECOND PLACE MEDAL}', - '\N{THIRD PLACE MEDAL}', - '\N{SPORTS MEDAL}', - '\N{SPORTS MEDAL}' - ) - - top_sorted = sorted(self.candy_json['records'], key=lambda k: k.get('record', 0), reverse=True) - top_five = top_sorted[:5] - - usersid = [] - records = [] - for record in top_five: - usersid.append(record['userid']) - records.append(record['record']) - - value = '\n'.join(f'{emoji[index]} <@{usersid[index]}>: {records[index]}' - for index in range(0, len(usersid))) or 'No Candies' - - e = discord.Embed(colour=discord.Colour.blurple()) - e.add_field(name="Top Candy Records", value=value, inline=False) - e.add_field(name='\u200b', - value=f"Candies will randomly appear on messages sent. " - f"\nHit the candy when it appears as fast as possible to get the candy! " - f"\nBut beware the ghosts...", - inline=False) - await ctx.send(embed=e) - - -def setup(bot: commands.Bot) -> None: - """Candy Collection game Cog load.""" - bot.add_cog(CandyCollection(bot)) - log.info("CandyCollection cog loaded") diff --git a/bot/seasons/halloween/hacktober-issue-finder.py b/bot/seasons/halloween/hacktober-issue-finder.py deleted file mode 100644 index f15a665a..00000000 --- a/bot/seasons/halloween/hacktober-issue-finder.py +++ /dev/null @@ -1,111 +0,0 @@ -import datetime -import logging -import random -from typing import Dict, Optional - -import aiohttp -import discord -from discord.ext import commands - -from bot.constants import Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -URL = "https://api.github.com/search/issues?per_page=100&q=is:issue+label:hacktoberfest+language:python+state:open" -HEADERS = {"Accept": "application / vnd.github.v3 + json"} - - -class HacktoberIssues(commands.Cog): - """Find a random hacktober python issue on GitHub.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.cache_normal = None - self.cache_timer_normal = datetime.datetime(1, 1, 1) - self.cache_beginner = None - self.cache_timer_beginner = datetime.datetime(1, 1, 1) - - @in_month(Month.october) - @commands.command() - async def hacktoberissues(self, ctx: commands.Context, option: str = "") -> None: - """ - Get a random python hacktober issue from Github. - - If the command is run with beginner (`.hacktoberissues beginner`): - It will also narrow it down to the "first good issue" label. - """ - with ctx.typing(): - issues = await self.get_issues(ctx, option) - if issues is None: - return - issue = random.choice(issues["items"]) - embed = self.format_embed(issue) - await ctx.send(embed=embed) - - async def get_issues(self, ctx: commands.Context, option: str) -> Optional[Dict]: - """Get a list of the python issues with the label 'hacktoberfest' from the Github api.""" - if option == "beginner": - if (ctx.message.created_at - self.cache_timer_beginner).seconds <= 60: - log.debug("using cache") - return self.cache_beginner - elif (ctx.message.created_at - self.cache_timer_normal).seconds <= 60: - log.debug("using cache") - return self.cache_normal - - async with aiohttp.ClientSession() as session: - if option == "beginner": - url = URL + '+label:"good first issue"' - if self.cache_beginner is not None: - page = random.randint(1, min(1000, self.cache_beginner["total_count"]) // 100) - url += f"&page={page}" - else: - url = URL - if self.cache_normal is not None: - page = random.randint(1, min(1000, self.cache_normal["total_count"]) // 100) - url += f"&page={page}" - - log.debug(f"making api request to url: {url}") - async with session.get(url, headers=HEADERS) as response: - if response.status != 200: - log.error(f"expected 200 status (got {response.status}) from the GitHub api.") - await ctx.send(f"ERROR: expected 200 status (got {response.status}) from the GitHub api.") - await ctx.send(await response.text()) - return None - data = await response.json() - - if len(data["items"]) == 0: - log.error(f"no issues returned from GitHub api. with url: {response.url}") - await ctx.send(f"ERROR: no issues returned from GitHub api. with url: {response.url}") - return None - - if option == "beginner": - self.cache_beginner = data - self.cache_timer_beginner = ctx.message.created_at - else: - self.cache_normal = data - self.cache_timer_normal = ctx.message.created_at - - return data - - @staticmethod - def format_embed(issue: Dict) -> discord.Embed: - """Format the issue data into a embed.""" - title = issue["title"] - issue_url = issue["url"].replace("api.", "").replace("/repos/", "/") - body = issue["body"] - labels = [label["name"] for label in issue["labels"]] - - embed = discord.Embed(title=title) - embed.description = body - embed.add_field(name="labels", value="\n".join(labels)) - embed.url = issue_url - embed.set_footer(text=issue_url) - - return embed - - -def setup(bot: commands.Bot) -> None: - """Hacktober issue finder Cog Load.""" - bot.add_cog(HacktoberIssues(bot)) - log.info("hacktober-issue-finder cog loaded") diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py deleted file mode 100644 index 5dfa2f51..00000000 --- a/bot/seasons/halloween/hacktoberstats.py +++ /dev/null @@ -1,342 +0,0 @@ -import json -import logging -import re -from collections import Counter -from datetime import datetime -from pathlib import Path -from typing import List, Tuple - -import aiohttp -import discord -from discord.ext import commands - -from bot.constants import Channels, Month, WHITELISTED_CHANNELS -from bot.utils.decorators import in_month, override_in_channel -from bot.utils.persist import make_persistent - -log = logging.getLogger(__name__) - -CURRENT_YEAR = datetime.now().year # Used to construct GH API query -PRS_FOR_SHIRT = 4 # Minimum number of PRs before a shirt is awarded -HACKTOBER_WHITELIST = WHITELISTED_CHANNELS + (Channels.hacktoberfest_2019,) - - -class HacktoberStats(commands.Cog): - """Hacktoberfest statistics Cog.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.link_json = make_persistent(Path("bot", "resources", "halloween", "github_links.json")) - self.linked_accounts = self.load_linked_users() - - @in_month(Month.october) - @commands.group(name="hacktoberstats", aliases=("hackstats",), invoke_without_command=True) - @override_in_channel(HACKTOBER_WHITELIST) - async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None) -> None: - """ - Display an embed for a user's Hacktoberfest contributions. - - If invoked without a subcommand or github_username, get the invoking user's stats if they've - linked their Discord name to GitHub using .stats link. If invoked with a github_username, - get that user's contributions - """ - if not github_username: - author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) - - if str(author_id) in self.linked_accounts.keys(): - github_username = self.linked_accounts[author_id]["github_username"] - logging.info(f"Getting stats for {author_id} linked GitHub account '{github_username}'") - else: - msg = ( - f"{author_mention}, you have not linked a GitHub account\n\n" - f"You can link your GitHub account using:\n```{ctx.prefix}hackstats link github_username```\n" - f"Or query GitHub stats directly using:\n```{ctx.prefix}hackstats github_username```" - ) - await ctx.send(msg) - return - - await self.get_stats(ctx, github_username) - - @in_month(Month.october) - @hacktoberstats_group.command(name="link") - @override_in_channel(HACKTOBER_WHITELIST) - async def link_user(self, ctx: commands.Context, github_username: str = None) -> None: - """ - Link the invoking user's Github github_username to their Discord ID. - - Linked users are stored as a nested dict: - { - Discord_ID: { - "github_username": str - "date_added": datetime - } - } - """ - author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) - if github_username: - if str(author_id) in self.linked_accounts.keys(): - old_username = self.linked_accounts[author_id]["github_username"] - logging.info(f"{author_id} has changed their github link from '{old_username}' to '{github_username}'") - await ctx.send(f"{author_mention}, your GitHub username has been updated to: '{github_username}'") - else: - logging.info(f"{author_id} has added a github link to '{github_username}'") - await ctx.send(f"{author_mention}, your GitHub username has been added") - - self.linked_accounts[author_id] = { - "github_username": github_username, - "date_added": datetime.now() - } - - self.save_linked_users() - else: - logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username") - await ctx.send(f"{author_mention}, a GitHub username is required to link your account") - - @in_month(Month.october) - @hacktoberstats_group.command(name="unlink") - @override_in_channel(HACKTOBER_WHITELIST) - async def unlink_user(self, ctx: commands.Context) -> None: - """Remove the invoking user's account link from the log.""" - author_id, author_mention = HacktoberStats._author_mention_from_context(ctx) - - stored_user = self.linked_accounts.pop(author_id, None) - if stored_user: - await ctx.send(f"{author_mention}, your GitHub profile has been unlinked") - logging.info(f"{author_id} has unlinked their GitHub account") - else: - await ctx.send(f"{author_mention}, you do not currently have a linked GitHub account") - logging.info(f"{author_id} tried to unlink their GitHub account but no account was linked") - - self.save_linked_users() - - def load_linked_users(self) -> dict: - """ - Load list of linked users from local JSON file. - - Linked users are stored as a nested dict: - { - Discord_ID: { - "github_username": str - "date_added": datetime - } - } - """ - if self.link_json.exists(): - logging.info(f"Loading linked GitHub accounts from '{self.link_json}'") - with open(self.link_json, 'r') as file: - linked_accounts = json.load(file) - - logging.info(f"Loaded {len(linked_accounts)} linked GitHub accounts from '{self.link_json}'") - return linked_accounts - else: - logging.info(f"Linked account log: '{self.link_json}' does not exist") - return {} - - def save_linked_users(self) -> None: - """ - Save list of linked users to local JSON file. - - Linked users are stored as a nested dict: - { - Discord_ID: { - "github_username": str - "date_added": datetime - } - } - """ - logging.info(f"Saving linked_accounts to '{self.link_json}'") - with open(self.link_json, 'w') as file: - json.dump(self.linked_accounts, file, default=str) - logging.info(f"linked_accounts saved to '{self.link_json}'") - - async def get_stats(self, ctx: commands.Context, github_username: str) -> None: - """ - Query GitHub's API for PRs created by a GitHub user during the month of October. - - PRs with the 'invalid' tag are ignored - - If a valid github_username is provided, an embed is generated and posted to the channel - - Otherwise, post a helpful error message - """ - async with ctx.typing(): - prs = await self.get_october_prs(github_username) - - if prs: - stats_embed = self.build_embed(github_username, prs) - await ctx.send('Here are some stats!', embed=stats_embed) - else: - await ctx.send(f"No October GitHub contributions found for '{github_username}'") - - def build_embed(self, github_username: str, prs: List[dict]) -> discord.Embed: - """Return a stats embed built from github_username's PRs.""" - logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'") - pr_stats = self._summarize_prs(prs) - - n = pr_stats['n_prs'] - if n >= PRS_FOR_SHIRT: - shirtstr = f"**{github_username} has earned a tshirt!**" - elif n == PRS_FOR_SHIRT - 1: - shirtstr = f"**{github_username} is 1 PR away from a tshirt!**" - else: - shirtstr = f"**{github_username} is {PRS_FOR_SHIRT - n} PRs away from a tshirt!**" - - stats_embed = discord.Embed( - title=f"{github_username}'s Hacktoberfest", - color=discord.Color(0x9c4af7), - description=( - f"{github_username} has made {n} " - f"{HacktoberStats._contributionator(n)} in " - f"October\n\n" - f"{shirtstr}\n\n" - ) - ) - - stats_embed.set_thumbnail(url=f"https://www.github.com/{github_username}.png") - stats_embed.set_author( - name="Hacktoberfest", - url="https://hacktoberfest.digitalocean.com", - icon_url="https://hacktoberfest.digitalocean.com/pretty_logo.png" - ) - stats_embed.add_field( - name="Top 5 Repositories:", - value=self._build_top5str(pr_stats) - ) - - logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'") - return stats_embed - - @staticmethod - async def get_october_prs(github_username: str) -> List[dict]: - """ - Query GitHub's API for PRs created during the month of October by github_username. - - PRs with an 'invalid' tag are ignored - - If PRs are found, return a list of dicts with basic PR information - - For each PR: - { - "repo_url": str - "repo_shortname": str (e.g. "python-discord/seasonalbot") - "created_at": datetime.datetime - } - - Otherwise, return None - """ - logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'") - base_url = "https://api.github.com/search/issues?q=" - not_label = "invalid" - action_type = "pr" - is_query = f"public+author:{github_username}" - not_query = "draft" - date_range = f"{CURRENT_YEAR}-10-01T00:00:00%2B14:00..{CURRENT_YEAR}-10-31T23:59:59-11:00" - per_page = "300" - query_url = ( - f"{base_url}" - f"-label:{not_label}" - f"+type:{action_type}" - f"+is:{is_query}" - f"+-is:{not_query}" - f"+created:{date_range}" - f"&per_page={per_page}" - ) - - headers = {"user-agent": "Discord Python Hacktoberbot"} - async with aiohttp.ClientSession() as session: - async with session.get(query_url, headers=headers) as resp: - jsonresp = await resp.json() - - if "message" in jsonresp.keys(): - # One of the parameters is invalid, short circuit for now - api_message = jsonresp["errors"][0]["message"] - logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") - return - else: - if jsonresp["total_count"] == 0: - # Short circuit if there aren't any PRs - logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'") - return - else: - logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") - outlist = [] - for item in jsonresp["items"]: - shortname = HacktoberStats._get_shortname(item["repository_url"]) - itemdict = { - "repo_url": f"https://www.github.com/{shortname}", - "repo_shortname": shortname, - "created_at": datetime.strptime( - item["created_at"], r"%Y-%m-%dT%H:%M:%SZ" - ), - } - outlist.append(itemdict) - return outlist - - @staticmethod - def _get_shortname(in_url: str) -> str: - """ - Extract shortname from https://api.github.com/repos/* URL. - - e.g. "https://api.github.com/repos/python-discord/seasonalbot" - | - V - "python-discord/seasonalbot" - """ - exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)" - return re.findall(exp, in_url)[0] - - @staticmethod - def _summarize_prs(prs: List[dict]) -> dict: - """ - Generate statistics from an input list of PR dictionaries, as output by get_october_prs. - - Return a dictionary containing: - { - "n_prs": int - "top5": [(repo_shortname, ncontributions), ...] - } - """ - contributed_repos = [pr["repo_shortname"] for pr in prs] - return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)} - - @staticmethod - def _build_top5str(stats: List[tuple]) -> str: - """ - Build a string from the Top 5 contributions that is compatible with a discord.Embed field. - - Top 5 contributions should be a list of tuples, as output in the stats dictionary by - _summarize_prs - - String is of the form: - n contribution(s) to [shortname](url) - ... - """ - base_url = "https://www.github.com/" - contributionstrs = [] - for repo in stats['top5']: - n = repo[1] - contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({base_url}{repo[0]})") - - return "\n".join(contributionstrs) - - @staticmethod - def _contributionator(n: int) -> str: - """Return "contribution" or "contributions" based on the value of n.""" - if n == 1: - return "contribution" - else: - return "contributions" - - @staticmethod - def _author_mention_from_context(ctx: commands.Context) -> Tuple: - """Return stringified Message author ID and mentionable string from commands.Context.""" - author_id = str(ctx.message.author.id) - author_mention = ctx.message.author.mention - - return author_id, author_mention - - -def setup(bot: commands.Bot) -> None: - """Hacktoberstats Cog load.""" - bot.add_cog(HacktoberStats(bot)) - log.info("HacktoberStats cog loaded") diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py deleted file mode 100644 index 222768f4..00000000 --- a/bot/seasons/halloween/halloween_facts.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -import logging -import random -from datetime import timedelta -from pathlib import Path -from typing import Tuple - -import discord -from discord.ext import commands - -log = logging.getLogger(__name__) - -SPOOKY_EMOJIS = [ - "\N{BAT}", - "\N{DERELICT HOUSE BUILDING}", - "\N{EXTRATERRESTRIAL ALIEN}", - "\N{GHOST}", - "\N{JACK-O-LANTERN}", - "\N{SKULL}", - "\N{SKULL AND CROSSBONES}", - "\N{SPIDER WEB}", -] -PUMPKIN_ORANGE = discord.Color(0xFF7518) -INTERVAL = timedelta(hours=6).total_seconds() - - -class HalloweenFacts(commands.Cog): - """A Cog for displaying interesting facts about Halloween.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - with open(Path("bot/resources/halloween/halloween_facts.json"), "r") as file: - self.halloween_facts = json.load(file) - self.facts = list(enumerate(self.halloween_facts)) - random.shuffle(self.facts) - - def random_fact(self) -> Tuple[int, str]: - """Return a random fact from the loaded facts.""" - return random.choice(self.facts) - - @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact") - async def get_random_fact(self, ctx: commands.Context) -> None: - """Reply with the most recent Halloween fact.""" - index, fact = self.random_fact() - embed = self._build_embed(index, fact) - await ctx.send(embed=embed) - - @staticmethod - def _build_embed(index: int, fact: str) -> discord.Embed: - """Builds a Discord embed from the given fact and its index.""" - emoji = random.choice(SPOOKY_EMOJIS) - title = f"{emoji} Halloween Fact #{index + 1}" - return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) - - -def setup(bot: commands.Bot) -> None: - """Halloween facts Cog load.""" - bot.add_cog(HalloweenFacts(bot)) - log.info("HalloweenFacts cog loaded") diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py deleted file mode 100644 index dfcc2b1e..00000000 --- a/bot/seasons/halloween/halloweenify.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from json import load -from pathlib import Path -from random import choice - -import discord -from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType - -log = logging.getLogger(__name__) - - -class Halloweenify(commands.Cog): - """A cog to change a invokers nickname to a spooky one!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.cooldown(1, 300, BucketType.user) - @commands.command() - async def halloweenify(self, ctx: commands.Context) -> None: - """Change your nickname into a much spookier one!""" - async with ctx.typing(): - with open(Path("bot/resources/halloween/halloweenify.json"), "r") as f: - data = load(f) - - # Choose a random character from our list we loaded above and set apart the nickname and image url. - character = choice(data["characters"]) - nickname = ''.join([nickname for nickname in character]) - image = ''.join([character[nickname] for nickname in character]) - - # Build up a Embed - embed = discord.Embed() - embed.colour = discord.Colour.dark_orange() - embed.title = "Not spooky enough?" - embed.description = ( - f"**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, " - f"{ctx.author.display_name} isn\'t scary at all! " - "Let me think of something better. Hmm... I got it!\n\n " - f"Your new nickname will be: \n :ghost: **{nickname}** :jack_o_lantern:" - ) - embed.set_image(url=image) - - await ctx.author.edit(nick=nickname) - - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Halloweenify Cog load.""" - bot.add_cog(Halloweenify(bot)) - log.info("Halloweenify cog loaded") diff --git a/bot/seasons/halloween/monsterbio.py b/bot/seasons/halloween/monsterbio.py deleted file mode 100644 index bfa8a026..00000000 --- a/bot/seasons/halloween/monsterbio.py +++ /dev/null @@ -1,56 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/halloween/monster.json"), "r", encoding="utf8") as f: - TEXT_OPTIONS = json.load(f) # Data for a mad-lib style generation of text - - -class MonsterBio(commands.Cog): - """A cog that generates a spooky monster biography.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - def generate_name(self, seeded_random: random.Random) -> str: - """Generates a name (for either monster species or monster name).""" - n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"])) - return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings)) - - @commands.command(brief="Sends your monster bio!") - async def monsterbio(self, ctx: commands.Context) -> None: - """Sends a description of a monster.""" - seeded_random = random.Random(ctx.message.author.id) # Seed a local Random instance rather than the system one - - name = self.generate_name(seeded_random) - species = self.generate_name(seeded_random) - biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"]) - words = {"monster_name": name, "monster_species": species} - for key, value in biography_text.items(): - if key == "text": - continue - - options = seeded_random.sample(TEXT_OPTIONS[key], value) - words[key] = ' '.join(options) - - embed = discord.Embed( - title=f"{name}'s Biography", - color=seeded_random.choice([Colours.orange, Colours.purple]), - description=biography_text["text"].format_map(words), - ) - - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Monster bio Cog load.""" - bot.add_cog(MonsterBio(bot)) - log.info("MonsterBio cog loaded.") diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py deleted file mode 100644 index 12e1d022..00000000 --- a/bot/seasons/halloween/monstersurvey.py +++ /dev/null @@ -1,206 +0,0 @@ -import json -import logging -import os - -from discord import Embed -from discord.ext import commands -from discord.ext.commands import Bot, Cog, Context - -log = logging.getLogger(__name__) - -EMOJIS = { - 'SUCCESS': u'\u2705', - 'ERROR': u'\u274C' -} - - -class MonsterSurvey(Cog): - """ - Vote for your favorite monster. - - This Cog allows users to vote for their favorite listed monster. - - Users may change their vote, but only their current vote will be counted. - """ - - def __init__(self, bot: Bot): - """Initializes values for the bot to use within the voting commands.""" - self.bot = bot - self.registry_location = os.path.join(os.getcwd(), 'bot', 'resources', 'halloween', 'monstersurvey.json') - with open(self.registry_location, 'r') as jason: - self.voter_registry = json.load(jason) - - def json_write(self) -> None: - """Write voting results to a local JSON file.""" - log.info("Saved Monster Survey Results") - with open(self.registry_location, 'w') as jason: - json.dump(self.voter_registry, jason, indent=2) - - def cast_vote(self, id: int, monster: str) -> None: - """ - Cast a user's vote for the specified monster. - - If the user has already voted, their existing vote is removed. - """ - vr = self.voter_registry - for m in vr.keys(): - if id not in vr[m]['votes'] and m == monster: - vr[m]['votes'].append(id) - else: - if id in vr[m]['votes'] and m != monster: - vr[m]['votes'].remove(id) - - def get_name_by_leaderboard_index(self, n: int) -> str: - """Return the monster at the specified leaderboard index.""" - n = n - 1 - vr = self.voter_registry - top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) - name = top[n] if n >= 0 else None - return name - - @commands.group( - name='monster', - aliases=('mon',) - ) - async def monster_group(self, ctx: Context) -> None: - """The base voting command. If nothing is called, then it will return an embed.""" - if ctx.invoked_subcommand is None: - async with ctx.typing(): - default_embed = Embed( - title='Monster Voting', - color=0xFF6800, - description='Vote for your favorite monster!' - ) - default_embed.add_field( - name='.monster show monster_name(optional)', - value='Show a specific monster. If none is listed, it will give you an error with valid choices.', - inline=False) - default_embed.add_field( - name='.monster vote monster_name', - value='Vote for a specific monster. You get one vote, but can change it at any time.', - inline=False - ) - default_embed.add_field( - name='.monster leaderboard', - value='Which monster has the most votes? This command will tell you.', - inline=False - ) - default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry.keys())}") - - await ctx.send(embed=default_embed) - - @monster_group.command( - name='vote' - ) - async def monster_vote(self, ctx: Context, name: str = None) -> None: - """ - Cast a vote for a particular monster. - - Displays a list of monsters that can be voted for if one is not specified. - """ - if name is None: - await ctx.invoke(self.monster_leaderboard) - return - - async with ctx.typing(): - # Check to see if user used a numeric (leaderboard) index to vote - try: - idx = int(name) - name = self.get_name_by_leaderboard_index(idx) - except ValueError: - name = name.lower() - - vote_embed = Embed( - name='Monster Voting', - color=0xFF6800 - ) - - m = self.voter_registry.get(name) - if m is None: - vote_embed.description = f'You cannot vote for {name} because it\'s not in the running.' - vote_embed.add_field( - name='Use `.monster show {monster_name}` for more information on a specific monster', - value='or use `.monster vote {monster}` to cast your vote for said monster.', - inline=False - ) - vote_embed.add_field( - name='You may vote for or show the following monsters:', - value=f"{', '.join(self.voter_registry.keys())}" - ) - else: - self.cast_vote(ctx.author.id, name) - vote_embed.add_field( - name='Vote successful!', - value=f'You have successfully voted for {m["full_name"]}!', - inline=False - ) - vote_embed.set_thumbnail(url=m['image']) - vote_embed.set_footer(text="Please note that any previous votes have been removed.") - self.json_write() - - await ctx.send(embed=vote_embed) - - @monster_group.command( - name='show' - ) - async def monster_show(self, ctx: Context, name: str = None) -> None: - """Shows the named monster. If one is not named, it sends the default voting embed instead.""" - if name is None: - await ctx.invoke(self.monster_leaderboard) - return - - async with ctx.typing(): - # Check to see if user used a numeric (leaderboard) index to vote - try: - idx = int(name) - name = self.get_name_by_leaderboard_index(idx) - except ValueError: - name = name.lower() - - m = self.voter_registry.get(name) - if not m: - await ctx.send('That monster does not exist.') - await ctx.invoke(self.monster_vote) - return - - embed = Embed(title=m['full_name'], color=0xFF6800) - embed.add_field(name='Summary', value=m['summary']) - embed.set_image(url=m['image']) - embed.set_footer(text=f'To vote for this monster, type .monster vote {name}') - - await ctx.send(embed=embed) - - @monster_group.command( - name='leaderboard', - aliases=('lb',) - ) - async def monster_leaderboard(self, ctx: Context) -> None: - """Shows the current standings.""" - async with ctx.typing(): - vr = self.voter_registry - top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) - total_votes = sum(len(m['votes']) for m in self.voter_registry.values()) - - embed = Embed(title="Monster Survey Leader Board", color=0xFF6800) - for rank, m in enumerate(top): - votes = len(vr[m]['votes']) - percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0 - embed.add_field(name=f"{rank+1}. {vr[m]['full_name']}", - value=( - f"{votes} votes. {percentage:.1f}% of total votes.\n" - f"Vote for this monster by typing " - f"'.monster vote {m}'\n" - f"Get more information on this monster by typing " - f"'.monster show {m}'" - ), - inline=False) - - embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ") - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Monster survey Cog load.""" - bot.add_cog(MonsterSurvey(bot)) - log.info("MonsterSurvey cog loaded") diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py deleted file mode 100644 index 3823a3e4..00000000 --- a/bot/seasons/halloween/scarymovie.py +++ /dev/null @@ -1,132 +0,0 @@ -import logging -import random -from os import environ - -import aiohttp -from discord import Embed -from discord.ext import commands - -log = logging.getLogger(__name__) - - -TMDB_API_KEY = environ.get('TMDB_API_KEY') -TMDB_TOKEN = environ.get('TMDB_TOKEN') - - -class ScaryMovie(commands.Cog): - """Selects a random scary movie and embeds info into Discord chat.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(name='scarymovie', alias=['smovie']) - async def random_movie(self, ctx: commands.Context) -> None: - """Randomly select a scary movie and display information about it.""" - async with ctx.typing(): - selection = await self.select_movie() - movie_details = await self.format_metadata(selection) - - await ctx.send(embed=movie_details) - - @staticmethod - async def select_movie() -> dict: - """Selects a random movie and returns a JSON of movie details from TMDb.""" - url = 'https://api.themoviedb.org/4/discover/movie' - params = { - 'with_genres': '27', - 'vote_count.gte': '5' - } - headers = { - 'Authorization': 'Bearer ' + TMDB_TOKEN, - 'Content-Type': 'application/json;charset=utf-8' - } - - # Get total page count of horror movies - async with aiohttp.ClientSession() as session: - response = await session.get(url=url, params=params, headers=headers) - total_pages = await response.json() - total_pages = total_pages.get('total_pages') - - # Get movie details from one random result on a random page - params['page'] = random.randint(1, total_pages) - response = await session.get(url=url, params=params, headers=headers) - response = await response.json() - selection_id = random.choice(response.get('results')).get('id') - - # Get full details and credits - selection = await session.get( - url='https://api.themoviedb.org/3/movie/' + str(selection_id), - params={'api_key': TMDB_API_KEY, 'append_to_response': 'credits'} - ) - - return await selection.json() - - @staticmethod - async def format_metadata(movie: dict) -> Embed: - """Formats raw TMDb data to be embedded in Discord chat.""" - # Build the relevant URLs. - movie_id = movie.get("id") - poster_path = movie.get("poster_path") - tmdb_url = f'https://www.themoviedb.org/movie/{movie_id}' if movie_id else None - poster = f'https://image.tmdb.org/t/p/original{poster_path}' if poster_path else None - - # Get cast names - cast = [] - for actor in movie.get('credits', {}).get('cast', [])[:3]: - cast.append(actor.get('name')) - - # Get director name - director = movie.get('credits', {}).get('crew', []) - if director: - director = director[0].get('name') - - # Determine the spookiness rating - rating = '' - rating_count = movie.get('vote_average', 0) - - if rating_count: - rating_count /= 2 - - for _ in range(int(rating_count)): - rating += ':skull:' - if (rating_count % 1) >= .5: - rating += ':bat:' - - # Try to get year of release and runtime - year = movie.get('release_date', [])[:4] - runtime = movie.get('runtime') - runtime = f"{runtime} minutes" if runtime else None - - # Not all these attributes will always be present - movie_attributes = { - "Directed by": director, - "Starring": ', '.join(cast), - "Running time": runtime, - "Release year": year, - "Spookiness rating": rating, - } - - embed = Embed( - colour=0x01d277, - title='**' + movie.get('title') + '**', - url=tmdb_url, - description=movie.get('overview') - ) - - if poster: - embed.set_image(url=poster) - - # Add the attributes that we actually have data for, but not the others. - for name, value in movie_attributes.items(): - if value: - embed.add_field(name=name, value=value) - - embed.set_footer(text='powered by themoviedb.org') - - return embed - - -def setup(bot: commands.Bot) -> None: - """Scary movie Cog load.""" - bot.add_cog(ScaryMovie(bot)) - log.info("ScaryMovie cog loaded") diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py deleted file mode 100644 index 268de3fb..00000000 --- a/bot/seasons/halloween/spookyavatar.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging -import os -from io import BytesIO - -import aiohttp -import discord -from PIL import Image -from discord.ext import commands - -from bot.utils.halloween import spookifications - -log = logging.getLogger(__name__) - - -class SpookyAvatar(commands.Cog): - """A cog that spookifies an avatar.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - async def get(self, url: str) -> bytes: - """Returns the contents of the supplied URL.""" - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - return await resp.read() - - @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), - brief='Spookify an user\'s avatar.') - async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: - """A command to print the user's spookified avatar.""" - if user is None: - user = ctx.message.author - - async with ctx.typing(): - embed = discord.Embed(colour=0xFF0000) - embed.title = "Is this you or am I just really paranoid?" - embed.set_author(name=str(user.name), icon_url=user.avatar_url) - - image_bytes = await ctx.author.avatar_url.read() - im = Image.open(BytesIO(image_bytes)) - modified_im = spookifications.get_random_effect(im) - modified_im.save(str(ctx.message.id)+'.png') - f = discord.File(str(ctx.message.id)+'.png') - embed.set_image(url='attachment://'+str(ctx.message.id)+'.png') - - await ctx.send(file=f, embed=embed) - os.remove(str(ctx.message.id)+'.png') - - -def setup(bot: commands.Bot) -> None: - """Spooky avatar Cog load.""" - bot.add_cog(SpookyAvatar(bot)) - log.info("SpookyAvatar cog loaded") diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py deleted file mode 100644 index 818de8cd..00000000 --- a/bot/seasons/halloween/spookygif.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging - -import aiohttp -import discord -from discord.ext import commands - -from bot.constants import Tokens - -log = logging.getLogger(__name__) - - -class SpookyGif(commands.Cog): - """A cog to fetch a random spooky gif from the web!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(name="spookygif", aliases=("sgif", "scarygif")) - async def spookygif(self, ctx: commands.Context) -> None: - """Fetches a random gif from the GIPHY API and responds with it.""" - async with ctx.typing(): - async with aiohttp.ClientSession() as session: - params = {'api_key': Tokens.giphy, 'tag': 'halloween', 'rating': 'g'} - # Make a GET request to the Giphy API to get a random halloween gif. - async with session.get('http://api.giphy.com/v1/gifs/random', params=params) as resp: - data = await resp.json() - url = data['data']['image_url'] - - embed = discord.Embed(colour=0x9b59b6) - embed.title = "A spooooky gif!" - embed.set_image(url=url) - - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Spooky GIF Cog load.""" - bot.add_cog(SpookyGif(bot)) - log.info("SpookyGif cog loaded") diff --git a/bot/seasons/halloween/spookyrating.py b/bot/seasons/halloween/spookyrating.py deleted file mode 100644 index 7f78f536..00000000 --- a/bot/seasons/halloween/spookyrating.py +++ /dev/null @@ -1,67 +0,0 @@ -import bisect -import json -import logging -import random -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -with Path("bot/resources/halloween/spooky_rating.json").open() as file: - SPOOKY_DATA = json.load(file) - SPOOKY_DATA = sorted((int(key), value) for key, value in SPOOKY_DATA.items()) - - -class SpookyRating(commands.Cog): - """A cog for calculating one's spooky rating.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.local_random = random.Random() - - @commands.command() - @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) - async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None: - """ - Calculates the spooky rating of someone. - - Any user will always yield the same result, no matter who calls the command - """ - if who is None: - who = ctx.author - - # This ensures that the same result over multiple runtimes - self.local_random.seed(who.id) - spooky_percent = self.local_random.randint(1, 101) - - # We need the -1 due to how bisect returns the point - # see the documentation for further detail - # https://docs.python.org/3/library/bisect.html#bisect.bisect - index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1 - - _, data = SPOOKY_DATA[index] - - embed = discord.Embed( - title=data['title'], - description=f'{who} scored {spooky_percent}%!', - color=Colours.orange - ) - embed.add_field( - name='A whisper from Satan', - value=data['text'] - ) - embed.set_thumbnail( - url=data['image'] - ) - - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Spooky Rating Cog load.""" - bot.add_cog(SpookyRating(bot)) - log.info("SpookyRating cog loaded") diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py deleted file mode 100644 index 16f18019..00000000 --- a/bot/seasons/halloween/spookyreact.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging -import re - -import discord -from discord.ext.commands import Bot, Cog - -from bot.constants import Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -SPOOKY_TRIGGERS = { - 'spooky': (r"\bspo{2,}ky\b", "\U0001F47B"), - 'skeleton': (r"\bskeleton\b", "\U0001F480"), - 'doot': (r"\bdo{2,}t\b", "\U0001F480"), - 'pumpkin': (r"\bpumpkin\b", "\U0001F383"), - 'halloween': (r"\bhalloween\b", "\U0001F383"), - 'jack-o-lantern': (r"\bjack-o-lantern\b", "\U0001F383"), - 'danger': (r"\bdanger\b", "\U00002620") -} - - -class SpookyReact(Cog): - """A cog that makes the bot react to message triggers.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @in_month(Month.october) - @Cog.listener() - async def on_message(self, ctx: discord.Message) -> None: - """ - A command to send the seasonalbot github project. - - Lines that begin with the bot's command prefix are ignored - - Seasonalbot's own messages are ignored - """ - for trigger in SPOOKY_TRIGGERS.keys(): - trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower()) - if trigger_test: - # Check message for bot replies and/or command invocations - # Short circuit if they're found, logging is handled in _short_circuit_check - if await self._short_circuit_check(ctx): - return - else: - await ctx.add_reaction(SPOOKY_TRIGGERS[trigger][1]) - logging.info(f"Added '{trigger}' reaction to message ID: {ctx.id}") - - async def _short_circuit_check(self, ctx: discord.Message) -> bool: - """ - Short-circuit helper check. - - Return True if: - * author is the bot - * prefix is not None - """ - # Check for self reaction - if ctx.author == self.bot.user: - logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}") - return True - - # Check for command invocation - # Because on_message doesn't give a full Context object, generate one first - tmp_ctx = await self.bot.get_context(ctx) - if tmp_ctx.prefix: - logging.debug(f"Ignoring reactions on command invocation. Message ID: {ctx.id}") - return True - - return False - - -def setup(bot: Bot) -> None: - """Spooky reaction Cog load.""" - bot.add_cog(SpookyReact(bot)) - log.info("SpookyReact cog loaded") diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py deleted file mode 100644 index e0676d0a..00000000 --- a/bot/seasons/halloween/spookysound.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging -import random -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Hacktoberfest - -log = logging.getLogger(__name__) - - -class SpookySound(commands.Cog): - """A cog that plays a spooky sound in a voice channel on command.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.sound_files = list(Path("bot/resources/halloween/spookysounds").glob("*.mp3")) - self.channel = None - - @commands.cooldown(rate=1, per=1) - @commands.command(brief="Play a spooky sound, restricted to once per 2 mins") - async def spookysound(self, ctx: commands.Context) -> None: - """ - Connect to the Hacktoberbot voice channel, play a random spooky sound, then disconnect. - - Cannot be used more than once in 2 minutes. - """ - if not self.channel: - await self.bot.wait_until_ready() - self.channel = self.bot.get_channel(Hacktoberfest.voice_id) - - await ctx.send("Initiating spooky sound...") - file_path = random.choice(self.sound_files) - src = discord.FFmpegPCMAudio(str(file_path.resolve())) - voice = await self.channel.connect() - voice.play(src, after=lambda e: self.bot.loop.create_task(self.disconnect(voice))) - - @staticmethod - async def disconnect(voice: discord.VoiceClient) -> None: - """Helper method to disconnect a given voice client.""" - await voice.disconnect() - - -def setup(bot: commands.Bot) -> None: - """Spooky sound Cog load.""" - bot.add_cog(SpookySound(bot)) - log.info("SpookySound cog loaded") diff --git a/bot/seasons/halloween/timeleft.py b/bot/seasons/halloween/timeleft.py deleted file mode 100644 index 8cb3f4f6..00000000 --- a/bot/seasons/halloween/timeleft.py +++ /dev/null @@ -1,60 +0,0 @@ -import logging -from datetime import datetime -from typing import Tuple - -from discord.ext import commands - -log = logging.getLogger(__name__) - - -class TimeLeft(commands.Cog): - """A Cog that tells you how long left until Hacktober is over!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @staticmethod - def in_october() -> bool: - """Return True if the current month is October.""" - return datetime.utcnow().month == 10 - - @staticmethod - def load_date() -> Tuple[int, datetime, datetime]: - """Return of a tuple of the current time and the end and start times of the next October.""" - now = datetime.utcnow() - year = now.year - if now.month > 10: - year += 1 - end = datetime(year, 11, 1, 11, 59, 59) - start = datetime(year, 10, 1) - return now, end, start - - @commands.command() - async def timeleft(self, ctx: commands.Context) -> None: - """ - Calculates the time left until the end of Hacktober. - - Whilst in October, displays the days, hours and minutes left. - Only displays the days left until the beginning and end whilst in a different month - """ - now, end, start = self.load_date() - diff = end - now - days, seconds = diff.days, diff.seconds - if self.in_october(): - minutes = seconds // 60 - hours, minutes = divmod(minutes, 60) - await ctx.send(f"There is currently only {days} days, {hours} hours and {minutes}" - "minutes left until the end of Hacktober.") - else: - start_diff = start - now - start_days = start_diff.days - await ctx.send( - f"It is not currently Hacktober. However, the next one will start in {start_days} days " - f"and will finish in {days} days." - ) - - -def setup(bot: commands.Bot) -> None: - """Cog load.""" - bot.add_cog(TimeLeft(bot)) - log.info("TimeLeft cog loaded") diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/seasons/pride/drag_queen_name.py b/bot/seasons/pride/drag_queen_name.py deleted file mode 100644 index 43813fbd..00000000 --- a/bot/seasons/pride/drag_queen_name.py +++ /dev/null @@ -1,33 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -log = logging.getLogger(__name__) - - -class DragNames(commands.Cog): - """Gives a random drag queen name!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.names = self.load_names() - - @staticmethod - def load_names() -> list: - """Loads a list of drag queen names.""" - with open(Path("bot/resources/pride/drag_queen_names.json"), "r", encoding="utf-8") as f: - return json.load(f) - - @commands.command(name="dragname", aliases=["dragqueenname", "queenme"]) - async def dragname(self, ctx: commands.Context) -> None: - """Sends a message with a drag queen name.""" - await ctx.send(random.choice(self.names)) - - -def setup(bot: commands.Bot) -> None: - """Cog loader for drag queen name generator.""" - bot.add_cog(DragNames(bot)) - log.info("Drag queen name generator cog loaded!") diff --git a/bot/seasons/pride/pride_anthem.py b/bot/seasons/pride/pride_anthem.py deleted file mode 100644 index b0c6d34e..00000000 --- a/bot/seasons/pride/pride_anthem.py +++ /dev/null @@ -1,58 +0,0 @@ -import json -import logging -import random -from pathlib import Path - -from discord.ext import commands - -log = logging.getLogger(__name__) - - -class PrideAnthem(commands.Cog): - """Embed a random youtube video for a gay anthem!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.anthems = self.load_vids() - - def get_video(self, genre: str = None) -> dict: - """ - Picks a random anthem from the list. - - If `genre` is supplied, it will pick from videos attributed with that genre. - If none can be found, it will log this as well as provide that information to the user. - """ - if not genre: - return random.choice(self.anthems) - else: - songs = [song for song in self.anthems if genre.casefold() in song["genre"]] - try: - return random.choice(songs) - except IndexError: - log.info("No videos for that genre.") - - @staticmethod - def load_vids() -> list: - """Loads a list of videos from the resources folder as dictionaries.""" - with open(Path("bot/resources/pride/anthems.json"), "r", encoding="utf-8") as f: - anthems = json.load(f) - return anthems - - @commands.command(name="prideanthem", aliases=["anthem", "pridesong"]) - async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None: - """ - Sends a message with a video of a random pride anthem. - - If `genre` is supplied, it will select from that genre only. - """ - anthem = self.get_video(genre) - if anthem: - await ctx.send(anthem["url"]) - else: - await ctx.send("I couldn't find a video, sorry!") - - -def setup(bot: commands.Bot) -> None: - """Cog loader for pride anthem.""" - bot.add_cog(PrideAnthem(bot)) - log.info("Pride anthems cog loaded!") diff --git a/bot/seasons/pride/pride_avatar.py b/bot/seasons/pride/pride_avatar.py deleted file mode 100644 index 85e49d5c..00000000 --- a/bot/seasons/pride/pride_avatar.py +++ /dev/null @@ -1,145 +0,0 @@ -import logging -from io import BytesIO -from pathlib import Path - -import discord -from PIL import Image, ImageDraw -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -OPTIONS = { - "agender": "agender", - "androgyne": "androgyne", - "androgynous": "androgyne", - "aromantic": "aromantic", - "aro": "aromantic", - "ace": "asexual", - "asexual": "asexual", - "bigender": "bigender", - "bisexual": "bisexual", - "bi": "bisexual", - "demiboy": "demiboy", - "demigirl": "demigirl", - "demi": "demisexual", - "demisexual": "demisexual", - "gay": "gay", - "lgbt": "gay", - "queer": "gay", - "homosexual": "gay", - "fluid": "genderfluid", - "genderfluid": "genderfluid", - "genderqueer": "genderqueer", - "intersex": "intersex", - "lesbian": "lesbian", - "non-binary": "nonbinary", - "enby": "nonbinary", - "nb": "nonbinary", - "nonbinary": "nonbinary", - "omnisexual": "omnisexual", - "omni": "omnisexual", - "pansexual": "pansexual", - "pan": "pansexual", - "pangender": "pangender", - "poly": "polysexual", - "polysexual": "polysexual", - "polyamory": "polyamory", - "polyamorous": "polyamory", - "transgender": "transgender", - "trans": "transgender", - "trigender": "trigender" -} - - -class PrideAvatar(commands.Cog): - """Put an LGBT spin on your avatar!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @staticmethod - def crop_avatar(avatar: Image) -> Image: - """This crops the avatar into a circle.""" - mask = Image.new("L", avatar.size, 0) - draw = ImageDraw.Draw(mask) - draw.ellipse((0, 0) + avatar.size, fill=255) - avatar.putalpha(mask) - return avatar - - @staticmethod - def crop_ring(ring: Image, px: int) -> Image: - """This crops the ring into a circle.""" - mask = Image.new("L", ring.size, 0) - draw = ImageDraw.Draw(mask) - draw.ellipse((0, 0) + ring.size, fill=255) - draw.ellipse((px, px, 1024-px, 1024-px), fill=0) - ring.putalpha(mask) - return ring - - @commands.group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) - async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None: - """ - This surrounds an avatar with a border of a specified LGBT flag. - - This defaults to the LGBT rainbow flag if none is given. - The amount of pixels can be given which determines the thickness of the flag border. - This has a maximum of 512px and defaults to a 64px border. - The full image is 1024x1024. - """ - pixels = 0 if pixels < 0 else 512 if pixels > 512 else pixels - - option = option.lower() - - if option not in OPTIONS.keys(): - return await ctx.send("I don't have that flag!") - - flag = OPTIONS[option] - - async with ctx.typing(): - - # Get avatar bytes - image_bytes = await ctx.author.avatar_url.read() - avatar = Image.open(BytesIO(image_bytes)) - avatar = avatar.convert("RGBA").resize((1024, 1024)) - - avatar = self.crop_avatar(avatar) - - ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) - ring = ring.convert("RGBA") - ring = self.crop_ring(ring, pixels) - - avatar.alpha_composite(ring, (0, 0)) - bufferedio = BytesIO() - avatar.save(bufferedio, format="PNG") - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed - embed = discord.Embed( - name="Your Lovely Pride Avatar", - description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" - ) - embed.set_image(url="attachment://pride_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - - @prideavatar.command() - async def flags(self, ctx: commands.Context) -> None: - """This lists the flags that can be used with the prideavatar command.""" - choices = sorted(set(OPTIONS.values())) - options = "• " + "\n• ".join(choices) - embed = discord.Embed( - title="I have the following flags:", - description=options, - colour=Colours.soft_red - ) - - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Cog load.""" - bot.add_cog(PrideAvatar(bot)) - log.info("PrideAvatar cog loaded") diff --git a/bot/seasons/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py deleted file mode 100644 index 2db8f5c2..00000000 --- a/bot/seasons/pride/pride_facts.py +++ /dev/null @@ -1,107 +0,0 @@ -import json -import logging -import random -from datetime import datetime -from pathlib import Path -from typing import Union - -import dateutil.parser -import discord -from discord.ext import commands - -from bot.constants import Channels, Colours, Month -from bot.utils.decorators import seasonal_task - -log = logging.getLogger(__name__) - -Sendable = Union[commands.Context, discord.TextChannel] - - -class PrideFacts(commands.Cog): - """Provides a new fact every day during the Pride season!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.facts = self.load_facts() - - self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily()) - - @staticmethod - def load_facts() -> dict: - """Loads a dictionary of years mapping to lists of facts.""" - with open(Path("bot/resources/pride/facts.json"), "r", encoding="utf-8") as f: - return json.load(f) - - @seasonal_task(Month.june) - async def send_pride_fact_daily(self) -> None: - """Background task to post the daily pride fact every day.""" - await self.bot.wait_until_ready() - - channel = self.bot.get_channel(Channels.seasonalbot_commands) - await self.send_select_fact(channel, datetime.utcnow()) - - async def send_random_fact(self, ctx: commands.Context) -> None: - """Provides a fact from any previous day, or today.""" - now = datetime.utcnow() - previous_years_facts = (self.facts[x] for x in self.facts.keys() if int(x) < now.year) - current_year_facts = self.facts.get(str(now.year), [])[:now.day] - previous_facts = current_year_facts + [x for y in previous_years_facts for x in y] - try: - await ctx.send(embed=self.make_embed(random.choice(previous_facts))) - except IndexError: - await ctx.send("No facts available") - - async def send_select_fact(self, target: Sendable, _date: Union[str, datetime]) -> None: - """Provides the fact for the specified day, if the day is today, or is in the past.""" - now = datetime.utcnow() - if isinstance(_date, str): - try: - date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True) - except (ValueError, OverflowError) as err: - await target.send(f"Error parsing date: {err}") - return - else: - date = _date - if date.year < now.year or (date.year == now.year and date.day <= now.day): - try: - await target.send(embed=self.make_embed(self.facts[str(date.year)][date.day - 1])) - except KeyError: - await target.send(f"The year {date.year} is not yet supported") - return - except IndexError: - await target.send(f"Day {date.day} of {date.year} is not yet support") - return - else: - await target.send("The fact for the selected day is not yet available.") - - @commands.command(name="pridefact", aliases=["pridefacts"]) - async def pridefact(self, ctx: commands.Context) -> None: - """ - Sends a message with a pride fact of the day. - - If "random" is given as an argument, a random previous fact will be provided. - - If a date is given as an argument, and the date is in the past, the fact from that day - will be provided. - """ - message_body = ctx.message.content[len(ctx.invoked_with) + 2:] - if message_body == "": - await self.send_select_fact(ctx, datetime.utcnow()) - elif message_body.lower().startswith("rand"): - await self.send_random_fact(ctx) - else: - await self.send_select_fact(ctx, message_body) - - def make_embed(self, fact: str) -> discord.Embed: - """Makes a nice embed for the fact to be sent.""" - return discord.Embed( - colour=Colours.pink, - title="Pride Fact!", - description=fact - ) - - -def setup(bot: commands.Bot) -> None: - """Cog loader for pride facts.""" - bot.add_cog(PrideFacts(bot)) - log.info("Pride facts cog loaded!") diff --git a/bot/seasons/valentines/__init__.py b/bot/seasons/valentines/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py deleted file mode 100644 index 1e883d21..00000000 --- a/bot/seasons/valentines/be_my_valentine.py +++ /dev/null @@ -1,237 +0,0 @@ -import logging -import random -from json import load -from pathlib import Path -from typing import Optional, Tuple - -import discord -from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType - -from bot.constants import Channels, Client, Colours, Lovefest, Month -from bot.utils.decorators import in_month - -log = logging.getLogger(__name__) - -HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] - - -class BeMyValentine(commands.Cog): - """A cog that sends Valentines to other users!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.valentines = self.load_json() - - @staticmethod - def load_json() -> dict: - """Load Valentines messages from the static resources.""" - p = Path("bot/resources/valentines/bemyvalentine_valentines.json") - with p.open() as json_data: - valentines = load(json_data) - return valentines - - @in_month(Month.february) - @commands.group(name="lovefest") - async def lovefest_role(self, ctx: commands.Context) -> None: - """ - Subscribe or unsubscribe from the lovefest role. - - The lovefest role makes you eligible to receive anonymous valentines from other users. - - 1) use the command \".lovefest sub\" to get the lovefest role. - 2) use the command \".lovefest unsub\" to get rid of the lovefest role. - """ - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @lovefest_role.command(name="sub") - async def add_role(self, ctx: commands.Context) -> None: - """Adds the lovefest role.""" - user = ctx.author - role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) - if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: - await user.add_roles(role) - await ctx.send("The Lovefest role has been added !") - else: - await ctx.send("You already have the role !") - - @lovefest_role.command(name="unsub") - async def remove_role(self, ctx: commands.Context) -> None: - """Removes the lovefest role.""" - user = ctx.author - role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) - if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: - await ctx.send("You dont have the lovefest role.") - else: - await user.remove_roles(role) - await ctx.send("The lovefest role has been successfully removed !") - - @commands.cooldown(1, 1800, BucketType.user) - @commands.group(name='bemyvalentine', invoke_without_command=True) - async def send_valentine( - self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None - ) -> None: - """ - Send a valentine to user, if specified, or to a random user with the lovefest role. - - syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message] - (optional) - - example: .bemyvalentine (sends valentine as a poem or a compliment to a random user) - example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman) - example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman) - NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command. - """ - if ctx.guild is None: - # This command should only be used in the server - msg = "You are supposed to use this command in the server." - return await ctx.send(msg) - - if user: - if Lovefest.role_id not in [role.id for role in user.roles]: - message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" - return await ctx.send(message) - - if user == ctx.author: - # Well a user can't valentine himself/herself. - return await ctx.send("Come on dude, you can't send a valentine to yourself :expressionless:") - - emoji_1, emoji_2 = self.random_emoji() - lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) - channel = self.bot.get_channel(Channels.seasonalbot_commands) - valentine, title = self.valentine_check(valentine_type) - - if user is None: - author = ctx.author - user = self.random_user(author, lovefest_role.members) - if user is None: - return await ctx.send("There are no users avilable to whome your valentine can be sent.") - - embed = discord.Embed( - title=f'{emoji_1} {title} {user.display_name} {emoji_2}', - description=f'{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**', - color=Colours.pink - ) - await channel.send(user.mention, embed=embed) - - @commands.cooldown(1, 1800, BucketType.user) - @send_valentine.command(name='secret') - async def anonymous( - self, ctx: commands.Context, user: Optional[discord.Member] = None, *, valentine_type: str = None - ) -> None: - """ - Send an anonymous Valentine via DM to to a user, if specified, or to a random with the lovefest role. - - **This command should be DMed to the bot.** - - syntax : .bemyvalentine secret [user](optional) [p/poem/c/compliment/or you can type your own valentine message] - (optional) - - example : .bemyvalentine secret (sends valentine as a poem or a compliment to a random user in DM making you - anonymous) - example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous) - example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to - Iceman in DM making you anonymous) - """ - if ctx.guild is not None: - # This command is only DM specific - msg = "You are not supposed to use this command in the server, DM the command to the bot." - return await ctx.send(msg) - - if user: - if Lovefest.role_id not in [role.id for role in user.roles]: - message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" - return await ctx.send(message) - - if user == ctx.author: - # Well a user cant valentine himself/herself. - return await ctx.send('Come on dude, you cant send a valentine to yourself :expressionless:') - - guild = self.bot.get_guild(id=Client.guild) - emoji_1, emoji_2 = self.random_emoji() - lovefest_role = discord.utils.get(guild.roles, id=Lovefest.role_id) - valentine, title = self.valentine_check(valentine_type) - - if user is None: - author = ctx.author - user = self.random_user(author, lovefest_role.members) - if user is None: - return await ctx.send("There are no users avilable to whome your valentine can be sent.") - - embed = discord.Embed( - title=f'{emoji_1}{title} {user.display_name}{emoji_2}', - description=f'{valentine} \n **{emoji_2}From anonymous{emoji_1}**', - color=Colours.pink - ) - try: - await user.send(embed=embed) - except discord.Forbidden: - await ctx.author.send(f"{user} has DMs disabled, so I couldn't send the message. Sorry!") - else: - await ctx.author.send(f"Your message has been sent to {user}") - - def valentine_check(self, valentine_type: str) -> Tuple[str, str]: - """Return the appropriate Valentine type & title based on the invoking user's input.""" - if valentine_type is None: - valentine, title = self.random_valentine() - - elif valentine_type.lower() in ['p', 'poem']: - valentine = self.valentine_poem() - title = 'A poem dedicated to' - - elif valentine_type.lower() in ['c', 'compliment']: - valentine = self.valentine_compliment() - title = 'A compliment for' - - else: - # in this case, the user decides to type his own valentine. - valentine = valentine_type - title = 'A message for' - return valentine, title - - @staticmethod - def random_user(author: discord.Member, members: discord.Member) -> None: - """ - Picks a random member from the list provided in `members`. - - The invoking author is ignored. - """ - if author in members: - members.remove(author) - - return random.choice(members) if members else None - - @staticmethod - def random_emoji() -> Tuple[str, str]: - """Return two random emoji from the module-defined constants.""" - emoji_1 = random.choice(HEART_EMOJIS) - emoji_2 = random.choice(HEART_EMOJIS) - return emoji_1, emoji_2 - - def random_valentine(self) -> Tuple[str, str]: - """Grabs a random poem or a compliment (any message).""" - valentine_poem = random.choice(self.valentines['valentine_poems']) - valentine_compliment = random.choice(self.valentines['valentine_compliments']) - random_valentine = random.choice([valentine_compliment, valentine_poem]) - if random_valentine == valentine_poem: - title = 'A poem dedicated to' - else: - title = 'A compliment for ' - return random_valentine, title - - def valentine_poem(self) -> str: - """Grabs a random poem.""" - valentine_poem = random.choice(self.valentines['valentine_poems']) - return valentine_poem - - def valentine_compliment(self) -> str: - """Grabs a random compliment.""" - valentine_compliment = random.choice(self.valentines['valentine_compliments']) - return valentine_compliment - - -def setup(bot: commands.Bot) -> None: - """Be my Valentine Cog load.""" - bot.add_cog(BeMyValentine(bot)) - log.info("BeMyValentine cog loaded") diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/seasons/valentines/lovecalculator.py deleted file mode 100644 index 03d3d7d5..00000000 --- a/bot/seasons/valentines/lovecalculator.py +++ /dev/null @@ -1,104 +0,0 @@ -import bisect -import hashlib -import json -import logging -import random -from pathlib import Path -from typing import Union - -import discord -from discord import Member -from discord.ext import commands -from discord.ext.commands import BadArgument, Cog, clean_content - -from bot.constants import Roles - -log = logging.getLogger(__name__) - -with Path("bot/resources/valentines/love_matches.json").open() as file: - LOVE_DATA = json.load(file) - LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items()) - - -class LoveCalculator(Cog): - """A cog for calculating the love between two people.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(aliases=('love_calculator', 'love_calc')) - @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) - async def love(self, ctx: commands.Context, who: Union[Member, str], whom: Union[Member, str] = None) -> None: - """ - Tells you how much the two love each other. - - This command accepts users or arbitrary strings as arguments. - Users are converted from: - - User ID - - Mention - - name#discrim - - name - - nickname - - Any two arguments will always yield the same result, though the order of arguments matters: - Running .love joseph erlang will always yield the same result. - Running .love erlang joseph won't yield the same result as .love joseph erlang - - If you want to use multiple words for one argument, you must include quotes. - .love "Zes Vappa" "morning coffee" - - If only one argument is provided, the subject will become one of the helpers at random. - """ - if whom is None: - staff = ctx.guild.get_role(Roles.helpers).members - whom = random.choice(staff) - - def normalize(arg: Union[Member, str]) -> str: - if isinstance(arg, Member): - # If we are given a member, return name#discrim without any extra changes - arg = str(arg) - else: - # Otherwise normalise case and remove any leading/trailing whitespace - arg = arg.strip().title() - # This has to be done manually to be applied to usernames - return clean_content(escape_markdown=True).convert(ctx, arg) - - who, whom = [await normalize(arg) for arg in (who, whom)] - - # Make sure user didn't provide something silly such as 10 spaces - if not (who and whom): - raise BadArgument('Arguments be non-empty strings.') - - # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary) - # - # hashlib is used over the builtin hash() to guarantee same result over multiple runtimes - m = hashlib.sha256(who.encode() + whom.encode()) - # Mod 101 for [0, 100] - love_percent = sum(m.digest()) % 101 - - # We need the -1 due to how bisect returns the point - # see the documentation for further detail - # https://docs.python.org/3/library/bisect.html#bisect.bisect - index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1 - # We already have the nearest "fit" love level - # We only need the dict, so we can ditch the first element - _, data = LOVE_DATA[index] - - status = random.choice(data['titles']) - embed = discord.Embed( - title=status, - description=f'{who} \N{HEAVY BLACK HEART} {whom} scored {love_percent}%!\n\u200b', - color=discord.Color.dark_magenta() - ) - embed.add_field( - name='A letter from Dr. Love:', - value=data['text'] - ) - - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Love calculator Cog load.""" - bot.add_cog(LoveCalculator(bot)) - log.info("LoveCalculator cog loaded") diff --git a/bot/seasons/valentines/movie_generator.py b/bot/seasons/valentines/movie_generator.py deleted file mode 100644 index ce1d7d5b..00000000 --- a/bot/seasons/valentines/movie_generator.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -import random -from os import environ -from urllib import parse - -import discord -from discord.ext import commands - -TMDB_API_KEY = environ.get("TMDB_API_KEY") - -log = logging.getLogger(__name__) - - -class RomanceMovieFinder(commands.Cog): - """A Cog that returns a random romance movie suggestion to a user.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(name="romancemovie") - async def romance_movie(self, ctx: commands.Context) -> None: - """Randomly selects a romance movie and displays information about it.""" - # Selecting a random int to parse it to the page parameter - random_page = random.randint(0, 20) - # TMDB api params - params = { - "api_key": TMDB_API_KEY, - "language": "en-US", - "sort_by": "popularity.desc", - "include_adult": "false", - "include_video": "false", - "page": random_page, - "with_genres": "10749" - } - # The api request url - request_url = "https://api.themoviedb.org/3/discover/movie?" + parse.urlencode(params) - async with self.bot.http_session.get(request_url) as resp: - # Trying to load the json file returned from the api - try: - data = await resp.json() - # Selecting random result from results object in the json file - selected_movie = random.choice(data["results"]) - - embed = discord.Embed( - title=f":sparkling_heart: {selected_movie['title']} :sparkling_heart:", - description=selected_movie["overview"], - ) - embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}") - embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"]) - embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"]) - await ctx.send(embed=embed) - except KeyError: - warning_message = "A KeyError was raised while fetching information on the movie. The API service" \ - " could be unavailable or the API key could be set incorrectly." - embed = discord.Embed(title=warning_message) - log.warning(warning_message) - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Romance movie Cog load.""" - bot.add_cog(RomanceMovieFinder(bot)) - log.info("RomanceMovieFinder cog loaded") diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/seasons/valentines/myvalenstate.py deleted file mode 100644 index 0256c39a..00000000 --- a/bot/seasons/valentines/myvalenstate.py +++ /dev/null @@ -1,87 +0,0 @@ -import collections -import json -import logging -from pathlib import Path -from random import choice - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/valentines/valenstates.json"), "r") as file: - STATES = json.load(file) - - -class MyValenstate(commands.Cog): - """A Cog to find your most likely Valentine's vacation destination.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - def levenshtein(self, source: str, goal: str) -> int: - """Calculates the Levenshtein Distance between source and goal.""" - if len(source) < len(goal): - return self.levenshtein(goal, source) - if len(source) == 0: - return len(goal) - if len(goal) == 0: - return len(source) - - pre_row = list(range(0, len(source) + 1)) - for i, source_c in enumerate(source): - cur_row = [i + 1] - for j, goal_c in enumerate(goal): - if source_c != goal_c: - cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]) + 1) - else: - cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j])) - pre_row = cur_row - return pre_row[-1] - - @commands.command() - async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None: - """Find the vacation spot(s) with the most matching characters to the invoking user.""" - eq_chars = collections.defaultdict(int) - if name is None: - author = ctx.message.author.name.lower().replace(' ', '') - else: - author = name.lower().replace(' ', '') - - for state in STATES.keys(): - lower_state = state.lower().replace(' ', '') - eq_chars[state] = self.levenshtein(author, lower_state) - - matches = [x for x, y in eq_chars.items() if y == min(eq_chars.values())] - valenstate = choice(matches) - matches.remove(valenstate) - - embed_title = "But there are more!" - if len(matches) > 1: - leftovers = f"{', '.join(matches[:len(matches)-2])}, and {matches[len(matches)-1]}" - embed_text = f"You have {len(matches)} more matches, these being {leftovers}." - elif len(matches) == 1: - embed_title = "But there's another one!" - leftovers = str(matches) - embed_text = f"You have another match, this being {leftovers}." - else: - embed_title = "You have a true match!" - embed_text = "This state is your true Valenstate! There are no states that would suit" \ - " you better" - - embed = discord.Embed( - title=f'Your Valenstate is {valenstate} \u2764', - description=f'{STATES[valenstate]["text"]}', - colour=Colours.pink - ) - embed.add_field(name=embed_title, value=embed_text) - embed.set_image(url=STATES[valenstate]["flag"]) - await ctx.channel.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Valenstate Cog load.""" - bot.add_cog(MyValenstate(bot)) - log.info("MyValenstate cog loaded") diff --git a/bot/seasons/valentines/pickuplines.py b/bot/seasons/valentines/pickuplines.py deleted file mode 100644 index 8b2c9822..00000000 --- a/bot/seasons/valentines/pickuplines.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging -import random -from json import load -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/valentines/pickup_lines.json"), "r", encoding="utf8") as f: - pickup_lines = load(f) - - -class PickupLine(commands.Cog): - """A cog that gives random cheesy pickup lines.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command() - async def pickupline(self, ctx: commands.Context) -> None: - """ - Gives you a random pickup line. - - Note that most of them are very cheesy. - """ - random_line = random.choice(pickup_lines['lines']) - embed = discord.Embed( - title=':cheese: Your pickup line :cheese:', - description=random_line['line'], - color=Colours.pink - ) - embed.set_thumbnail( - url=random_line.get('image', pickup_lines['placeholder']) - ) - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Pickup lines Cog load.""" - bot.add_cog(PickupLine(bot)) - log.info('PickupLine cog loaded') diff --git a/bot/seasons/valentines/savethedate.py b/bot/seasons/valentines/savethedate.py deleted file mode 100644 index e0bc3904..00000000 --- a/bot/seasons/valentines/savethedate.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging -import random -from json import load -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] - -with open(Path("bot/resources/valentines/date_ideas.json"), "r", encoding="utf8") as f: - VALENTINES_DATES = load(f) - - -class SaveTheDate(commands.Cog): - """A cog that gives random suggestion for a Valentine's date.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command() - async def savethedate(self, ctx: commands.Context) -> None: - """Gives you ideas for what to do on a date with your valentine.""" - random_date = random.choice(VALENTINES_DATES['ideas']) - emoji_1 = random.choice(HEART_EMOJIS) - emoji_2 = random.choice(HEART_EMOJIS) - embed = discord.Embed( - title=f"{emoji_1}{random_date['name']}{emoji_2}", - description=f"{random_date['description']}", - colour=Colours.pink - ) - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Save the date Cog Load.""" - bot.add_cog(SaveTheDate(bot)) - log.info("SaveTheDate cog loaded") diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/seasons/valentines/valentine_zodiac.py deleted file mode 100644 index c8d77e75..00000000 --- a/bot/seasons/valentines/valentine_zodiac.py +++ /dev/null @@ -1,58 +0,0 @@ -import logging -import random -from json import load -from pathlib import Path - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -LETTER_EMOJI = ':love_letter:' -HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] - - -class ValentineZodiac(commands.Cog): - """A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.zodiacs = self.load_json() - - @staticmethod - def load_json() -> dict: - """Load zodiac compatibility from static JSON resource.""" - p = Path("bot/resources/valentines/zodiac_compatibility.json") - with p.open() as json_data: - zodiacs = load(json_data) - return zodiacs - - @commands.command(name="partnerzodiac") - async def counter_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None: - """Provides a counter compatible zodiac sign to the given user's zodiac sign.""" - try: - compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.lower()]) - except KeyError: - return await ctx.send(zodiac_sign.capitalize() + " zodiac sign does not exist.") - - emoji1 = random.choice(HEART_EMOJIS) - emoji2 = random.choice(HEART_EMOJIS) - embed = discord.Embed( - title="Zodic Compatibility", - description=f'{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac["Zodiac"]}\n' - f'{emoji2}Compatibility meter : {compatible_zodiac["compatibility_score"]}{emoji2}', - color=Colours.pink - ) - embed.add_field( - name=f'A letter from Dr.Zodiac {LETTER_EMOJI}', - value=compatible_zodiac['description'] - ) - await ctx.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Valentine zodiac Cog load.""" - bot.add_cog(ValentineZodiac(bot)) - log.info("ValentineZodiac cog loaded") diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/seasons/valentines/whoisvalentine.py deleted file mode 100644 index b8586dca..00000000 --- a/bot/seasons/valentines/whoisvalentine.py +++ /dev/null @@ -1,53 +0,0 @@ -import json -import logging -from pathlib import Path -from random import choice - -import discord -from discord.ext import commands - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -with open(Path("bot/resources/valentines/valentine_facts.json"), "r") as file: - FACTS = json.load(file) - - -class ValentineFacts(commands.Cog): - """A Cog for displaying facts about Saint Valentine.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.command(aliases=('whoisvalentine', 'saint_valentine')) - async def who_is_valentine(self, ctx: commands.Context) -> None: - """Displays info about Saint Valentine.""" - embed = discord.Embed( - title="Who is Saint Valentine?", - description=FACTS['whois'], - color=Colours.pink - ) - embed.set_thumbnail( - url='https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_' - 'facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg' - ) - - await ctx.channel.send(embed=embed) - - @commands.command() - async def valentine_fact(self, ctx: commands.Context) -> None: - """Shows a random fact about Valentine's Day.""" - embed = discord.Embed( - title=choice(FACTS['titles']), - description=choice(FACTS['text']), - color=Colours.pink - ) - - await ctx.channel.send(embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Who is Valentine Cog load.""" - bot.add_cog(ValentineFacts(bot)) - log.info("ValentineFacts cog loaded") diff --git a/bot/utils/persist.py b/bot/utils/persist.py index e5db4d5d..6f6507fa 100644 --- a/bot/utils/persist.py +++ b/bot/utils/persist.py @@ -2,7 +2,7 @@ import sqlite3 from pathlib import Path from shutil import copyfile -from bot.seasons import get_season_names +from bot.exts import get_season_names DIRECTORY = Path("data") # directory that has a persistent volume mapped to it -- cgit v1.2.3