aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts')
-rw-r--r--bot/exts/evergreen/8bitify.py2
-rw-r--r--bot/exts/evergreen/battleship.py2
-rw-r--r--bot/exts/evergreen/game.py83
-rw-r--r--bot/exts/evergreen/xkcd.py89
-rw-r--r--bot/exts/halloween/hacktoberstats.py45
-rw-r--r--bot/exts/halloween/spookynamerate.py401
6 files changed, 591 insertions, 31 deletions
diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
index c048d9bf..54e68f80 100644
--- a/bot/exts/evergreen/8bitify.py
+++ b/bot/exts/evergreen/8bitify.py
@@ -19,7 +19,7 @@ class EightBitify(commands.Cog):
@staticmethod
def quantize(image: Image) -> Image:
"""Reduces colour palette to 256 colours."""
- return image.quantize(colors=32)
+ return image.quantize()
@commands.command(name="8bitify")
async def eightbit_command(self, ctx: commands.Context) -> None:
diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py
index 9bc374e6..fa3fb35c 100644
--- a/bot/exts/evergreen/battleship.py
+++ b/bot/exts/evergreen/battleship.py
@@ -140,7 +140,7 @@ class Game:
@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")
+ index = ord(square[0].upper()) - ord("A")
number = int(square[1:])
return grid[number-1][index] # -1 since lists are indexed from 0
diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py
index d0fd7a40..d37be0e2 100644
--- a/bot/exts/evergreen/game.py
+++ b/bot/exts/evergreen/game.py
@@ -2,7 +2,8 @@ import difflib
import logging
import random
import re
-from datetime import datetime as dt
+from asyncio import sleep
+from datetime import datetime as dt, timedelta
from enum import IntEnum
from typing import Any, Dict, List, Optional, Tuple
@@ -17,10 +18,25 @@ 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"
+BASE_URL = "https://api.igdb.com/v4"
-HEADERS = {
- "user-key": Tokens.igdb,
+CLIENT_ID = Tokens.igdb_client_id
+CLIENT_SECRET = Tokens.igdb_client_secret
+
+# The number of seconds before expiry that we attempt to re-fetch a new access token
+ACCESS_TOKEN_RENEWAL_WINDOW = 60*60*24*2
+
+# URL to request API access token
+OAUTH_URL = "https://id.twitch.tv/oauth2/token"
+
+OAUTH_PARAMS = {
+ "client_id": CLIENT_ID,
+ "client_secret": CLIENT_SECRET,
+ "grant_type": "client_credentials"
+}
+
+BASE_HEADERS = {
+ "Client-ID": CLIENT_ID,
"Accept": "application/json"
}
@@ -135,8 +151,47 @@ class Games(Cog):
self.http_session: ClientSession = bot.http_session
self.genres: Dict[str, int] = {}
-
- self.refresh_genres_task.start()
+ self.headers = BASE_HEADERS
+
+ self.bot.loop.create_task(self.renew_access_token())
+
+ async def renew_access_token(self) -> None:
+ """Refeshes V4 access token a number of seconds before expiry. See `ACCESS_TOKEN_RENEWAL_WINDOW`."""
+ while True:
+ async with self.http_session.post(OAUTH_URL, params=OAUTH_PARAMS) as resp:
+ result = await resp.json()
+ if resp.status != 200:
+ # If there is a valid access token continue to use that,
+ # otherwise unload cog.
+ if "Authorization" in self.headers:
+ time_delta = timedelta(seconds=ACCESS_TOKEN_RENEWAL_WINDOW)
+ logger.error(
+ "Failed to renew IGDB access token. "
+ f"Current token will last for {time_delta} "
+ f"OAuth response message: {result['message']}"
+ )
+ else:
+ logger.warning(
+ "Invalid OAuth credentials. Unloading Games cog. "
+ f"OAuth response message: {result['message']}"
+ )
+ self.bot.remove_cog('Games')
+
+ return
+
+ self.headers["Authorization"] = f"Bearer {result['access_token']}"
+
+ # Attempt to renew before the token expires
+ next_renewal = result["expires_in"] - ACCESS_TOKEN_RENEWAL_WINDOW
+
+ time_delta = timedelta(seconds=next_renewal)
+ logger.info(f"Successfully renewed access token. Refreshing again in {time_delta}")
+
+ # This will be true the first time this loop runs.
+ # Since we now have an access token, its safe to start this task.
+ if self.genres == {}:
+ self.refresh_genres_task.start()
+ await sleep(next_renewal)
@tasks.loop(hours=24.0)
async def refresh_genres_task(self) -> None:
@@ -156,9 +211,8 @@ class Games(Cog):
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:
+ async with self.http_session.post(f"{BASE_URL}/genres", data=body, headers=self.headers) as resp:
result = await resp.json()
-
genres = {genre["name"].capitalize(): genre["id"] for genre in result}
# Replace complex names with names from ALIASES
@@ -306,7 +360,7 @@ class Games(Cog):
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:
+ async with self.http_session.post(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp:
return await resp.json()
async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]:
@@ -348,7 +402,7 @@ class Games(Cog):
# 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:
+ async with self.http_session.post(url=f"{BASE_URL}/games", data=body, headers=self.headers) as resp:
data = await resp.json()
# Loop over games, format them to good format, make line and append this to total lines
@@ -377,7 +431,7 @@ class Games(Cog):
"offset": offset
})
- async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=HEADERS) as resp:
+ async with self.http_session.post(url=f"{BASE_URL}/companies", data=body, headers=self.headers) as resp:
return await resp.json()
async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]:
@@ -418,7 +472,10 @@ class Games(Cog):
def setup(bot: Bot) -> 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.")
+ if not Tokens.igdb_client_id:
+ logger.warning("No IGDB client ID. Not loading Games cog.")
+ return
+ if not Tokens.igdb_client_secret:
+ logger.warning("No IGDB client secret. Not loading Games cog.")
return
bot.add_cog(Games(bot))
diff --git a/bot/exts/evergreen/xkcd.py b/bot/exts/evergreen/xkcd.py
new file mode 100644
index 00000000..d3224bfe
--- /dev/null
+++ b/bot/exts/evergreen/xkcd.py
@@ -0,0 +1,89 @@
+import logging
+import re
+from random import randint
+from typing import Dict, Optional, Union
+
+from discord import Embed
+from discord.ext import tasks
+from discord.ext.commands import Cog, Context, command
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+COMIC_FORMAT = re.compile(r"latest|[0-9]+")
+BASE_URL = "https://xkcd.com"
+
+
+class XKCD(Cog):
+ """Retrieving XKCD comics."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+ self.latest_comic_info: Dict[str, Union[str, int]] = {}
+ self.get_latest_comic_info.start()
+
+ def cog_unload(self) -> None:
+ """Cancels refreshing of the task for refreshing the most recent comic info."""
+ self.get_latest_comic_info.cancel()
+
+ @tasks.loop(minutes=30)
+ async def get_latest_comic_info(self) -> None:
+ """Refreshes latest comic's information ever 30 minutes. Also used for finding a random comic."""
+ async with self.bot.http_session.get(f"{BASE_URL}/info.0.json") as resp:
+ if resp.status == 200:
+ self.latest_comic_info = await resp.json()
+ else:
+ log.debug(f"Failed to get latest XKCD comic information. Status code {resp.status}")
+
+ @command(name="xkcd")
+ async def fetch_xkcd_comics(self, ctx: Context, comic: Optional[str]) -> None:
+ """
+ Getting an xkcd comic's information along with the image.
+
+ To get a random comic, don't type any number as an argument. To get the latest, type 'latest'.
+ """
+ embed = Embed(title=f"XKCD comic '{comic}'")
+
+ embed.colour = Colours.soft_red
+
+ if comic and (comic := re.match(COMIC_FORMAT, comic)) is None:
+ embed.description = "Comic parameter should either be an integer or 'latest'."
+ await ctx.send(embed=embed)
+ return
+
+ comic = randint(1, self.latest_comic_info['num']) if comic is None else comic.group(0)
+
+ if comic == "latest":
+ info = self.latest_comic_info
+ else:
+ async with self.bot.http_session.get(f"{BASE_URL}/{comic}/info.0.json") as resp:
+ if resp.status == 200:
+ info = await resp.json()
+ else:
+ embed.title = f"XKCD comic #{comic}"
+ embed.description = f"{resp.status}: Could not retrieve xkcd comic #{comic}."
+ log.debug(f"Retrieving xkcd comic #{comic} failed with status code {resp.status}.")
+ await ctx.send(embed=embed)
+ return
+
+ embed.title = f"XKCD comic #{info['num']}"
+
+ if info["img"][-3:] in ("jpg", "png", "gif"):
+ embed.set_image(url=info["img"])
+ date = f"{info['year']}/{info['month']}/{info['day']}"
+ embed.set_footer(text=f"{date} - #{info['num']}, \'{info['safe_title']}\'")
+ embed.colour = Colours.soft_green
+ else:
+ embed.description = (
+ "The selected comic is interactive, and cannot be displayed within an embed.\n"
+ f"Comic can be viewed [here](https://xkcd.com/{info['num']})."
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Loading the XKCD cog."""
+ bot.add_cog(XKCD(bot))
diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py
index 84b75022..a1c55922 100644
--- a/bot/exts/halloween/hacktoberstats.py
+++ b/bot/exts/halloween/hacktoberstats.py
@@ -1,15 +1,16 @@
import logging
+import random
import re
from collections import Counter
from datetime import datetime, timedelta
-from typing import List, Tuple, Union
+from typing import List, Optional, Tuple, Union
import aiohttp
import discord
from async_rediscache import RedisCache
from discord.ext import commands
-from bot.constants import Channels, Month, Tokens, WHITELISTED_CHANNELS
+from bot.constants import Channels, Month, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS
from bot.utils.decorators import in_month, override_in_channel
log = logging.getLogger(__name__)
@@ -125,18 +126,28 @@ class HacktoberStats(commands.Cog):
async with ctx.typing():
prs = await self.get_october_prs(github_username)
+ if prs is None: # Will be None if the user was not found
+ await ctx.send(
+ embed=discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=f"GitHub user `{github_username}` was not found.",
+ colour=discord.Colour.red()
+ )
+ )
+ return
+
if prs:
stats_embed = await self.build_embed(github_username, prs)
await ctx.send('Here are some stats!', embed=stats_embed)
else:
- await ctx.send(f"No valid October GitHub contributions found for '{github_username}'")
+ await ctx.send(f"No valid Hacktoberfest PRs found for '{github_username}'")
async 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}'")
in_review, accepted = await self._categorize_prs(prs)
- n = len(accepted) + len(in_review) # total number of PRs
+ n = len(accepted) + len(in_review) # Total number of PRs
if n >= PRS_FOR_SHIRT:
shirtstr = f"**{github_username} is eligible for a T-shirt or a tree!**"
elif n == PRS_FOR_SHIRT - 1:
@@ -162,7 +173,7 @@ class HacktoberStats(commands.Cog):
icon_url="https://avatars1.githubusercontent.com/u/35706162?s=200&v=4"
)
- # this will handle when no PRs in_review or accepted
+ # This will handle when no PRs in_review or accepted
review_str = self._build_prs_string(in_review, github_username) or "None"
accepted_str = self._build_prs_string(accepted, github_username) or "None"
stats_embed.add_field(
@@ -178,7 +189,7 @@ class HacktoberStats(commands.Cog):
return stats_embed
@staticmethod
- async def get_october_prs(github_username: str) -> Union[List[dict], None]:
+ async def get_october_prs(github_username: str) -> Optional[List[dict]]:
"""
Query GitHub's API for PRs created during the month of October by github_username.
@@ -198,7 +209,8 @@ class HacktoberStats(commands.Cog):
"number": int
}
- Otherwise, return None
+ Otherwise, return empty list.
+ None will be returned when the GitHub user was not found.
"""
logging.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'")
base_url = "https://api.github.com/search/issues?q="
@@ -226,14 +238,15 @@ class HacktoberStats(commands.Cog):
# Ignore logging non-existent users or users we do not have permission to see
if api_message == GITHUB_NONEXISTENT_USER_MESSAGE:
logging.debug(f"No GitHub user found named '{github_username}'")
+ return
else:
logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")
- return
+ return [] # No October PRs were found due to error
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
+ logging.info(f"No October PRs found for GitHub user: '{github_username}'")
+ return []
logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'")
outlist = [] # list of pr information dicts that will get returned
@@ -250,7 +263,7 @@ class HacktoberStats(commands.Cog):
"number": item["number"]
}
- # if the PR has 'invalid' or 'spam' labels, the PR must be
+ # If the PR has 'invalid' or 'spam' labels, the PR must be
# either merged or approved for it to be included
if HacktoberStats._has_label(item, ["invalid", "spam"]):
if not await HacktoberStats._is_accepted(itemdict):
@@ -263,28 +276,28 @@ class HacktoberStats(commands.Cog):
outlist.append(itemdict)
continue
- # checking PR's labels for "hacktoberfest-accepted"
+ # Checking PR's labels for "hacktoberfest-accepted"
if HacktoberStats._has_label(item, "hacktoberfest-accepted"):
outlist.append(itemdict)
continue
- # no need to query github if repo topics are fetched before already
+ # No need to query GitHub if repo topics are fetched before already
if shortname in hackto_topics.keys():
if hackto_topics[shortname]:
outlist.append(itemdict)
continue
- # fetch topics for the pr repo
+ # Fetch topics for the PR's repo
topics_query_url = f"https://api.github.com/repos/{shortname}/topics"
logging.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}")
jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER)
if jsonresp2.get("names") is None:
logging.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}")
- return
+ continue # Assume the repo doesn't have the `hacktoberfest` topic if API request errored
# PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label
# must be in repo with 'hacktoberfest' topic
if "hacktoberfest" in jsonresp2["names"]:
- hackto_topics[shortname] = True # cache result in the dict for later use if needed
+ hackto_topics[shortname] = True # Cache result in the dict for later use if needed
outlist.append(itemdict)
return outlist
diff --git a/bot/exts/halloween/spookynamerate.py b/bot/exts/halloween/spookynamerate.py
new file mode 100644
index 00000000..e2950343
--- /dev/null
+++ b/bot/exts/halloween/spookynamerate.py
@@ -0,0 +1,401 @@
+import asyncio
+import json
+import random
+from collections import defaultdict
+from datetime import datetime, timedelta
+from logging import getLogger
+from os import getenv
+from pathlib import Path
+from typing import Dict, Union
+
+from async_rediscache import RedisCache
+from discord import Embed, Reaction, TextChannel, User
+from discord.colour import Colour
+from discord.ext import tasks
+from discord.ext.commands import Bot, Cog, Context, group
+
+from bot.constants import Channels, Client, Colours, Month
+from bot.utils.decorators import InMonthCheckFailure
+
+logger = getLogger(__name__)
+
+EMOJIS_VAL = {
+ "\N{Jack-O-Lantern}": 1,
+ "\N{Ghost}": 2,
+ "\N{Skull and Crossbones}": 3,
+ "\N{Zombie}": 4,
+ "\N{Face Screaming In Fear}": 5,
+}
+ADDED_MESSAGES = [
+ "Let's see if you win?",
+ ":jack_o_lantern: SPOOKY :jack_o_lantern:",
+ "If you got it, haunt it.",
+ "TIME TO GET YOUR SPOOKY ON! :skull:",
+]
+PING = "<@{id}>"
+
+EMOJI_MESSAGE = "\n".join([f"- {emoji} {val}" for emoji, val in EMOJIS_VAL.items()])
+HELP_MESSAGE_DICT = {
+ "title": "Spooky Name Rate",
+ "description": f"Help for the `{Client.prefix}spookynamerate` command",
+ "color": Colours.soft_orange,
+ "fields": [
+ {
+ "name": "How to play",
+ "value": (
+ "Everyday, the bot will post a random name, which you will need to spookify using your creativity.\n"
+ "You can rate each message according to how scary it is.\n"
+ "At the end of the day, the author of the message with most reactions will be the winner of the day.\n"
+ f"On a scale of 1 to {len(EMOJIS_VAL)}, the reactions order:\n"
+ f"{EMOJI_MESSAGE}"
+ ),
+ "inline": False,
+ },
+ {
+ "name": "How do I add my spookified name?",
+ "value": f"Simply type `{Client.prefix}spookynamerate add my name`",
+ "inline": False,
+ },
+ {
+ "name": "How do I *delete* my spookified name?",
+ "value": f"Simply type `{Client.prefix}spookynamerate delete`",
+ "inline": False,
+ },
+ ],
+}
+
+
+class SpookyNameRate(Cog):
+ """
+ A game that asks the user to spookify or halloweenify a name that is given everyday.
+
+ It sends a random name everyday. The user needs to try and spookify it to his best ability and
+ send that name back using the `spookynamerate add entry` command
+ """
+
+ # This cache stores the message id of each added word along with a dictionary which contains the name the author
+ # added, the author's id, and the author's score (which is 0 by default)
+ messages = RedisCache()
+
+ # The data cache stores small information such as the current name that is going on and whether it is the first time
+ # the bot is running
+ data = RedisCache()
+ debug = getenv('SPOOKYNAMERATE_DEBUG', False) # Enable if you do not want to limit the commands to October or if
+ # you do not want to wait till 12 UTC. Note: if debug is enabled and you run `.cogs reload spookynamerate`, it
+ # will automatically start the scoring and announcing the result (without waiting for 12, so do not expect it to.).
+ # Also, it won't wait for the two hours (when the poll closes).
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+
+ names_data = self.load_json(
+ Path("bot", "resources", "halloween", "spookynamerate_names.json")
+ )
+ self.first_names = names_data["first_names"]
+ self.last_names = names_data["last_names"]
+ # the names are from https://www.mockaroo.com/
+
+ self.name = None
+
+ self.bot.loop.create_task(self.load_vars())
+
+ self.first_time = None
+ self.poll = False
+ self.announce_name.start()
+ self.checking_messages = asyncio.Lock()
+ # Define an asyncio.Lock() to make sure the dictionary isn't changed
+ # when checking the messages for duplicate emojis'
+
+ async def load_vars(self) -> None:
+ """Loads the variables that couldn't be loaded in __init__."""
+ self.first_time = await self.data.get("first_time", True)
+ self.name = await self.data.get("name")
+
+ @group(name="spookynamerate", invoke_without_command=True)
+ async def spooky_name_rate(self, ctx: Context) -> None:
+ """Get help on the Spooky Name Rate game."""
+ await ctx.send(embed=Embed.from_dict(HELP_MESSAGE_DICT))
+
+ @spooky_name_rate.command(name="list", aliases=["all", "entries"])
+ async def list_entries(self, ctx: Context) -> None:
+ """Send all the entries up till now in a single embed."""
+ await ctx.send(embed=await self.get_responses_list(final=False))
+
+ @spooky_name_rate.command(name="name")
+ async def tell_name(self, ctx: Context) -> None:
+ """Tell the current random name."""
+ if not self.poll:
+ await ctx.send(f"The name is **{self.name}**")
+ return
+
+ await ctx.send(
+ f"The name ~~is~~ was **{self.name}**. The poll has already started, so you cannot "
+ "add an entry."
+ )
+
+ @spooky_name_rate.command(name="add", aliases=["register"])
+ async def add_name(self, ctx: Context, *, name: str) -> None:
+ """Use this command to add/register your spookified name."""
+ if self.poll:
+ logger.info(f"{ctx.message.author} tried to add a name, but the poll had already started.")
+ await ctx.send("Sorry, the poll has started! You can try and participate in the next round though!")
+ return
+
+ message = ctx.message
+
+ for data in (json.loads(user_data) for _, user_data in await self.messages.items()):
+ if data["author"] == message.author.id:
+ await ctx.send(
+ "But you have already added an entry! Type "
+ f"`{self.bot.command_prefix}spookynamerate "
+ "delete` to delete it, and then you can add it again"
+ )
+ return
+
+ elif data["name"] == name:
+ await ctx.send("TOO LATE. Someone has already added this name.")
+ return
+
+ msg = await (await self.get_channel()).send(f"{message.author.mention} added the name {name!r}!")
+
+ await self.messages.set(
+ msg.id,
+ json.dumps(
+ {
+ "name": name,
+ "author": message.author.id,
+ "score": 0,
+ }
+ ),
+ )
+
+ for emoji in EMOJIS_VAL:
+ await msg.add_reaction(emoji)
+
+ logger.info(f"{message.author} added the name {name!r}")
+
+ @spooky_name_rate.command(name="delete")
+ async def delete_name(self, ctx: Context) -> None:
+ """Delete the user's name."""
+ if self.poll:
+ await ctx.send("You can't delete your name since the poll has already started!")
+ return
+ for message_id, data in await self.messages.items():
+ data = json.loads(data)
+
+ if ctx.author.id == data["author"]:
+ await self.messages.delete(message_id)
+ await ctx.send(f'Name deleted successfully ({data["name"]!r})!')
+ return
+
+ await ctx.send(
+ f"But you don't have an entry... :eyes: Type `{self.bot.command_prefix}spookynamerate add your entry`"
+ )
+
+ @Cog.listener()
+ async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
+ """Ensures that each user adds maximum one reaction."""
+ if user.bot or not await self.messages.contains(reaction.message.id):
+ return
+
+ async with self.checking_messages: # Acquire the lock so that the dictionary isn't reset while iterating.
+ if reaction.emoji in EMOJIS_VAL:
+ # create a custom counter
+ reaction_counter = defaultdict(int)
+ for msg_reaction in reaction.message.reactions:
+ async for reaction_user in msg_reaction.users():
+ if reaction_user == self.bot.user:
+ continue
+ reaction_counter[reaction_user] += 1
+
+ if reaction_counter[user] > 1:
+ await user.send(
+ "Sorry, you have already added a reaction, "
+ "please remove your reaction and try again."
+ )
+ await reaction.remove(user)
+ return
+
+ @tasks.loop(hours=24.0)
+ async def announce_name(self) -> None:
+ """Announces the name needed to spookify every 24 hours and the winner of the previous game."""
+ if not self.in_allowed_month():
+ return
+
+ channel = await self.get_channel()
+
+ if self.first_time:
+ await channel.send(
+ "Okkey... Welcome to the **Spooky Name Rate Game**! It's a relatively simple game.\n"
+ f"Everyday, a random name will be sent in <#{Channels.community_bot_commands}> "
+ "and you need to try and spookify it!\nRegister your name using "
+ f"`{self.bot.command_prefix}spookynamerate add spookified name`"
+ )
+
+ await self.data.set("first_time", False)
+ self.first_time = False
+
+ else:
+ if await self.messages.items():
+ await channel.send(embed=await self.get_responses_list(final=True))
+ self.poll = True
+ if not SpookyNameRate.debug:
+ await asyncio.sleep(2 * 60 * 60) # sleep for two hours
+
+ logger.info("Calculating score")
+ for message_id, data in await self.messages.items():
+ data = json.loads(data)
+
+ msg = await channel.fetch_message(message_id)
+ score = 0
+ for reaction in msg.reactions:
+ reaction_value = EMOJIS_VAL.get(reaction.emoji, 0) # get the value of the emoji else 0
+ score += reaction_value * (reaction.count - 1) # multiply by the num of reactions
+ # subtract one, since one reaction was done by the bot
+
+ logger.debug(f"{self.bot.get_user(data['author'])} got a score of {score}")
+ data["score"] = score
+ await self.messages.set(message_id, json.dumps(data))
+
+ # Sort the winner messages
+ winner_messages = sorted(
+ ((msg_id, json.loads(usr_data)) for msg_id, usr_data in await self.messages.items()),
+ key=lambda x: x[1]["score"],
+ reverse=True,
+ )
+
+ winners = []
+ for i, winner in enumerate(winner_messages):
+ winners.append(winner)
+ if len(winner_messages) > i + 1:
+ if winner_messages[i + 1][1]["score"] != winner[1]["score"]:
+ break
+ elif len(winner_messages) == (i + 1) + 1: # The next element is the last element
+ if winner_messages[i + 1][1]["score"] != winner[1]["score"]:
+ break
+
+ # one iteration is complete
+ await channel.send("Today's Spooky Name Rate Game ends now, and the winner(s) is(are)...")
+
+ async with channel.typing():
+ await asyncio.sleep(1) # give the drum roll feel
+
+ if not winners: # There are no winners (no participants)
+ await channel.send("Hmm... Looks like no one participated! :cry:")
+ return
+
+ score = winners[0][1]["score"]
+ congratulations = "to all" if len(winners) > 1 else PING.format(id=winners[0][1]["author"])
+ names = ", ".join(f'{win[1]["name"]} ({PING.format(id=win[1]["author"])})' for win in winners)
+
+ # display winners, their names and scores
+ await channel.send(
+ f"Congratulations {congratulations}!\n"
+ f"You have a score of {score}!\n"
+ f"Your name{ 's were' if len(winners) > 1 else 'was'}:\n{names}"
+ )
+
+ # Send random party emojis
+ party = (random.choice([":partying_face:", ":tada:"]) for _ in range(random.randint(1, 10)))
+ await channel.send(" ".join(party))
+
+ async with self.checking_messages: # Acquire the lock to delete the messages
+ await self.messages.clear() # reset the messages
+
+ # send the next name
+ self.name = f"{random.choice(self.first_names)} {random.choice(self.last_names)}"
+ await self.data.set("name", self.name)
+
+ await channel.send(
+ "Let's move on to the next name!\nAnd the next name is...\n"
+ f"**{self.name}**!\nTry to spookify that... :smirk:"
+ )
+
+ self.poll = False # accepting responses
+
+ @announce_name.before_loop
+ async def wait_till_scheduled_time(self) -> None:
+ """Waits till the next day's 12PM if crossed it, otherwise waits till the same day's 12PM."""
+ if SpookyNameRate.debug:
+ return
+
+ now = datetime.utcnow()
+ if now.hour < 12:
+ twelve_pm = now.replace(hour=12, minute=0, second=0, microsecond=0)
+ time_left = twelve_pm - now
+ await asyncio.sleep(time_left.seconds)
+ return
+
+ tomorrow_12pm = now + timedelta(days=1)
+ tomorrow_12pm = tomorrow_12pm.replace(hour=12, minute=0, second=0, microsecond=0)
+ await asyncio.sleep((tomorrow_12pm - now).seconds)
+
+ async def get_responses_list(self, final: bool = False) -> Embed:
+ """Returns an embed containing the responses of the people."""
+ channel = await self.get_channel()
+
+ embed = Embed(color=Colour.red())
+
+ if await self.messages.items():
+ if final:
+ embed.title = "Spooky Name Rate is about to end!"
+ embed.description = (
+ "This Spooky Name Rate round is about to end in 2 hours! You can review "
+ "the entries below! Have you rated other's names?"
+ )
+ else:
+ embed.title = "All the spookified names!"
+ embed.description = "See a list of all the entries entered by everyone!"
+ else:
+ embed.title = "No one has added an entry yet..."
+
+ for message_id, data in await self.messages.items():
+ data = json.loads(data)
+
+ embed.add_field(
+ name=(self.bot.get_user(data["author"]) or await self.bot.fetch_user(data["author"])).name,
+ value=f"[{(data)['name']}](https://discord.com/channels/{Client.guild}/{channel.id}/{message_id})",
+ )
+
+ return embed
+
+ async def get_channel(self) -> Union[TextChannel, None]:
+ """Gets the sir-lancebot-channel after waiting until ready."""
+ await self.bot.wait_until_ready()
+ channel = self.bot.get_channel(
+ Channels.community_bot_commands
+ ) or await self.bot.fetch_channel(Channels.community_bot_commands)
+ if not channel:
+ logger.warning("Bot is unable to get the #seasonalbot-commands channel. Please check the channel ID.")
+ return channel
+
+ @staticmethod
+ def load_json(file: Path) -> Dict[str, str]:
+ """Loads a JSON file and returns its contents."""
+ with file.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+ @staticmethod
+ def in_allowed_month() -> bool:
+ """Returns whether running in the limited month."""
+ if SpookyNameRate.debug:
+ return True
+
+ if not Client.month_override:
+ return datetime.utcnow().month == Month.OCTOBER
+ return Client.month_override == Month.OCTOBER
+
+ def cog_check(self, ctx: Context) -> bool:
+ """A command to check whether the command is being called in October."""
+ if not self.in_allowed_month():
+ raise InMonthCheckFailure("You can only use these commands in October!")
+ return True
+
+ def cog_unload(self) -> None:
+ """Stops the announce_name task."""
+ self.announce_name.cancel()
+
+
+def setup(bot: Bot) -> None:
+ """Loads the SpookyNameRate Cog."""
+ bot.add_cog(SpookyNameRate(bot))