aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/constants.py2
-rw-r--r--bot/exts/evergreen/bookmark.py5
-rw-r--r--bot/exts/evergreen/duck_game.py356
-rw-r--r--bot/exts/evergreen/realpython.py76
-rw-r--r--bot/exts/evergreen/reddit.py3
-rw-r--r--bot/exts/evergreen/snakes/_utils.py3
-rw-r--r--bot/exts/internal_eval/_internal_eval.py5
-rw-r--r--bot/resources/evergreen/LuckiestGuy-Regular.ttfbin0 -> 58292 bytes
-rw-r--r--bot/resources/evergreen/all_cards.pngbin0 -> 155466 bytes
-rw-r--r--bot/resources/evergreen/ducks_help_ex.pngbin0 -> 343921 bytes
10 files changed, 445 insertions, 5 deletions
diff --git a/bot/constants.py b/bot/constants.py
index b5097601..2730106b 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -134,7 +134,7 @@ class Client(NamedTuple):
prefix = environ.get("PREFIX", ".")
token = environ.get("BOT_TOKEN")
sentry_dsn = environ.get("BOT_SENTRY_DSN")
- debug = environ.get("BOT_DEBUG", "").lower() == "true"
+ debug = environ.get("BOT_DEBUG", "true").lower() == "true"
github_bot_repo = "https://github.com/python-discord/sir-lancebot"
# Override seasonal locks: 1 (January) to 12 (December)
month_override = int(environ["MONTH_OVERRIDE"]) if "MONTH_OVERRIDE" in environ else None
diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py
index 85c9b46f..f93371a6 100644
--- a/bot/exts/evergreen/bookmark.py
+++ b/bot/exts/evergreen/bookmark.py
@@ -7,14 +7,16 @@ import discord
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Colours, ERROR_REPLIES, Icons
+from bot.constants import Categories, Colours, ERROR_REPLIES, Icons, WHITELISTED_CHANNELS
from bot.utils.converters import WrappedMessageConverter
+from bot.utils.decorators import whitelist_override
log = logging.getLogger(__name__)
# Number of seconds to wait for other users to bookmark the same message
TIMEOUT = 120
BOOKMARK_EMOJI = "📌"
+WHITELISTED_CATEGORIES = (Categories.help_in_use,)
class Bookmark(commands.Cog):
@@ -85,6 +87,7 @@ class Bookmark(commands.Cog):
await message.add_reaction(BOOKMARK_EMOJI)
return message
+ @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
@commands.command(name="bookmark", aliases=("bm", "pin"))
async def bookmark(
self,
diff --git a/bot/exts/evergreen/duck_game.py b/bot/exts/evergreen/duck_game.py
new file mode 100644
index 00000000..51e7a98a
--- /dev/null
+++ b/bot/exts/evergreen/duck_game.py
@@ -0,0 +1,356 @@
+import asyncio
+import random
+import re
+from collections import defaultdict
+from io import BytesIO
+from itertools import product
+from pathlib import Path
+from urllib.parse import urlparse
+
+import discord
+from PIL import Image, ImageDraw, ImageFont
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, MODERATION_ROLES
+from bot.utils.decorators import with_role
+
+
+DECK = list(product(*[(0, 1, 2)]*4))
+
+GAME_DURATION = 180
+
+# Scoring
+CORRECT_SOLN = 1
+INCORRECT_SOLN = -1
+CORRECT_GOOSE = 2
+INCORRECT_GOOSE = -1
+
+# Distribution of minimum acceptable solutions at board generation.
+# This is for gameplay reasons, to shift the number of solutions per board up,
+# while still making the end of the game unpredictable.
+# Note: this is *not* the same as the distribution of number of solutions.
+
+SOLN_DISTR = 0, 0.05, 0.05, 0.1, 0.15, 0.25, 0.2, 0.15, .05
+
+IMAGE_PATH = Path("bot", "resources", "evergreen", "all_cards.png")
+FONT_PATH = Path("bot", "resources", "evergreen", "LuckiestGuy-Regular.ttf")
+HELP_IMAGE_PATH = Path("bot", "resources", "evergreen", "ducks_help_ex.png")
+
+ALL_CARDS = Image.open(IMAGE_PATH)
+LABEL_FONT = ImageFont.truetype(str(FONT_PATH), size=16)
+CARD_WIDTH = 155
+CARD_HEIGHT = 97
+
+EMOJI_WRONG = "\u274C"
+
+ANSWER_REGEX = re.compile(r'^\D*(\d+)\D+(\d+)\D+(\d+)\D*$')
+
+HELP_TEXT = """
+**Each card has 4 features**
+Color, Number, Hat, and Accessory
+
+**A valid flight**
+3 cards where each feature is either all the same or all different
+
+**Call "GOOSE"**
+if you think there are no more flights
+
+**+1** for each valid flight
+**+2** for a correct "GOOSE" call
+**-1** for any wrong answer
+
+The first flight below is invalid: the first card has swords while the other two have no accessory.\
+ It would be valid if the first card was empty-handed, or one of the other two had paintbrushes.
+
+The second flight is valid because there are no 2:1 splits; each feature is either all the same or all different.
+"""
+
+
+def assemble_board_image(board: list[tuple[int]], rows: int, columns: int) -> Image:
+ """Cut and paste images representing the given cards into an image representing the board."""
+ new_im = Image.new("RGBA", (CARD_WIDTH*columns, CARD_HEIGHT*rows))
+ draw = ImageDraw.Draw(new_im)
+ for idx, card in enumerate(board):
+ card_image = get_card_image(card)
+ row, col = divmod(idx, columns)
+ top, left = row * CARD_HEIGHT, col * CARD_WIDTH
+ new_im.paste(card_image, (left, top))
+ draw.text(
+ xy=(left+5, top+5), # magic numbers are buffers for the card labels
+ text=str(idx),
+ fill=(0, 0, 0),
+ font=LABEL_FONT,
+ )
+ return new_im
+
+
+def get_card_image(card: tuple[int]) -> Image:
+ """Slice the image containing all the cards to get just this card."""
+ # The master card image file should have 9x9 cards,
+ # arranged such that their features can be interpreted as ordered trinary.
+ row, col = divmod(as_trinary(card), 9)
+ x1 = col * CARD_WIDTH
+ x2 = x1 + CARD_WIDTH
+ y1 = row * CARD_HEIGHT
+ y2 = y1 + CARD_HEIGHT
+ return ALL_CARDS.crop((x1, y1, x2, y2))
+
+
+def as_trinary(card: tuple[int]) -> int:
+ """Find the card's unique index by interpreting its features as trinary."""
+ return int(''.join(str(x) for x in card), base=3)
+
+
+class DuckGame:
+ """A class for a single game."""
+
+ def __init__(
+ self,
+ rows: int = 4,
+ columns: int = 3,
+ minimum_solutions: int = 1,
+ ) -> None:
+ """
+ Take samples from the deck to generate a board.
+
+ Args:
+ rows (int, optional): Rows in the game board. Defaults to 4.
+ columns (int, optional): Columns in the game board. Defaults to 3.
+ minimum_solutions (int, optional): Minimum acceptable number of solutions in the board. Defaults to 1.
+ """
+ self.rows = rows
+ self.columns = columns
+ size = rows * columns
+
+ self._solutions = None
+ self.claimed_answers = {}
+ self.scores = defaultdict(int)
+ self.editing_embed = asyncio.Lock()
+
+ self.board = random.sample(DECK, size)
+ while len(self.solutions) < minimum_solutions:
+ self.board = random.sample(DECK, size)
+
+ @property
+ def board(self) -> list[tuple[int]]:
+ """Accesses board property."""
+ return self._board
+
+ @board.setter
+ def board(self, val: list[tuple[int]]) -> None:
+ """Erases calculated solutions if the board changes."""
+ self._solutions = None
+ self._board = val
+
+ @property
+ def solutions(self) -> None:
+ """Calculate valid solutions and cache to avoid redoing work."""
+ if self._solutions is None:
+ self._solutions = set()
+ for idx_a, card_a in enumerate(self.board):
+ for idx_b, card_b in enumerate(self.board[idx_a+1:], start=idx_a+1):
+ # Two points determine a line, and there are exactly 3 points per line in {0,1,2}^4.
+ # The completion of a line will only be a duplicate point if the other two points are the same,
+ # which is prevented by the triangle iteration.
+ completion = tuple(
+ feat_a if feat_a == feat_b else 3-feat_a-feat_b
+ for feat_a, feat_b in zip(card_a, card_b)
+ )
+ try:
+ idx_c = self.board.index(completion)
+ except ValueError:
+ continue
+
+ # Indices within the solution are sorted to detect duplicate solutions modulo order.
+ solution = tuple(sorted((idx_a, idx_b, idx_c)))
+ self._solutions.add(solution)
+
+ return self._solutions
+
+
+class DuckGamesDirector(commands.Cog):
+ """A cog for running Duck Duck Duck Goose games."""
+
+ def __init__(self, bot: Bot) -> None:
+ self.bot = bot
+ self.current_games = {}
+
+ @commands.group(
+ name='duckduckduckgoose',
+ aliases=['dddg', 'ddg', 'duckduckgoose', 'duckgoose'],
+ invoke_without_command=True
+ )
+ @commands.cooldown(rate=1, per=2, type=commands.BucketType.channel)
+ async def start_game(self, ctx: commands.Context) -> None:
+ """Generate a board, send the game embed, and end the game after a time limit."""
+ if ctx.channel.id in self.current_games:
+ await ctx.send("There's already a game running!")
+ return
+
+ minimum_solutions, = random.choices(range(len(SOLN_DISTR)), weights=SOLN_DISTR)
+ game = DuckGame(minimum_solutions=minimum_solutions)
+ game.running = True
+ self.current_games[ctx.channel.id] = game
+
+ game.embed_msg = await self.send_board_embed(ctx, game)
+ await asyncio.sleep(GAME_DURATION)
+
+ # Checking for the channel ID in the currently running games is not sufficient.
+ # The game could have been ended by a player, and a new game already started in the same channel.
+ if game.running:
+ try:
+ del self.current_games[ctx.channel.id]
+ await self.end_game(ctx.channel, game, end_message="Time's up!")
+ except KeyError:
+ pass
+
+ @commands.Cog.listener()
+ async def on_message(self, msg: discord.Message) -> None:
+ """Listen for messages and process them as answers if appropriate."""
+ if msg.author.bot:
+ return
+
+ channel = msg.channel
+ if channel.id not in self.current_games:
+ return
+
+ game = self.current_games[channel.id]
+ if msg.content.strip().lower() == 'goose':
+ # If all of the solutions have been claimed, i.e. the "goose" call is correct.
+ if len(game.solutions) == len(game.claimed_answers):
+ try:
+ del self.current_games[channel.id]
+ game.scores[msg.author] += CORRECT_GOOSE
+ await self.end_game(channel, game, end_message=f"{msg.author.display_name} GOOSED!")
+ except KeyError:
+ pass
+ else:
+ await msg.add_reaction(EMOJI_WRONG)
+ game.scores[msg.author] += INCORRECT_GOOSE
+ return
+
+ # Valid answers contain 3 numbers.
+ if not (match := re.match(ANSWER_REGEX, msg.content)):
+ return
+ answer = tuple(sorted(int(m) for m in match.groups()))
+
+ # Be forgiving for answers that use indices not on the board.
+ if not all(0 <= n < len(game.board) for n in answer):
+ return
+
+ # Also be forgiving for answers that have already been claimed (and avoid penalizing for racing conditions).
+ if answer in game.claimed_answers:
+ return
+
+ if answer in game.solutions:
+ game.claimed_answers[answer] = msg.author
+ game.scores[msg.author] += CORRECT_SOLN
+ await self.display_claimed_answer(game, msg.author, answer)
+ else:
+ await msg.add_reaction(EMOJI_WRONG)
+ game.scores[msg.author] += INCORRECT_SOLN
+
+ async def send_board_embed(self, ctx: commands.Context, game: DuckGame) -> discord.Message:
+ """Create and send the initial game embed. This will be edited as the game goes on."""
+ image = assemble_board_image(game.board, game.rows, game.columns)
+ with BytesIO() as image_stream:
+ image.save(image_stream, format="png")
+ image_stream.seek(0)
+ file = discord.File(fp=image_stream, filename="board.png")
+ embed = discord.Embed(
+ title="Duck Duck Duck Goose!",
+ color=Colours.bright_green,
+ footer=""
+ )
+ embed.set_image(url="attachment://board.png")
+ return await ctx.send(embed=embed, file=file)
+
+ async def display_claimed_answer(self, game: DuckGame, author: discord.Member, answer: tuple[int]) -> None:
+ """Add a claimed answer to the game embed."""
+ async with game.editing_embed:
+ game_embed, = game.embed_msg.embeds
+ old_footer = game_embed.footer.text
+ if old_footer == discord.Embed.Empty:
+ old_footer = ""
+ game_embed.set_footer(text=f"{old_footer}\n{str(answer):12s} - {author.display_name}")
+ await self.edit_embed_with_image(game.embed_msg, game_embed)
+
+ async def end_game(self, channel: discord.TextChannel, game: DuckGame, end_message: str) -> None:
+ """Edit the game embed to reflect the end of the game and mark the game as not running."""
+ game.running = False
+
+ scoreboard_embed = discord.Embed(
+ title=end_message,
+ color=discord.Color.dark_purple(),
+ )
+ scores = sorted(
+ game.scores.items(),
+ key=lambda item: item[1],
+ reverse=True,
+ )
+ scoreboard = "Final scores:\n\n"
+ scoreboard += "\n".join(f"{member.display_name}: {score}" for member, score in scores)
+ scoreboard_embed.description = scoreboard
+ await channel.send(embed=scoreboard_embed)
+
+ missed = [ans for ans in game.solutions if ans not in game.claimed_answers]
+ if missed:
+ missed_text = "Flights everyone missed:\n" + "\n".join(f"{ans}" for ans in missed)
+ else:
+ missed_text = "All the flights were found!"
+
+ game_embed, = game.embed_msg.embeds
+ old_footer = game_embed.footer.text
+ if old_footer == discord.Embed.Empty:
+ old_footer = ""
+ embed_as_dict = game_embed.to_dict() # Cannot set embed color after initialization
+ embed_as_dict["color"] = discord.Color.red().value
+ game_embed = discord.Embed.from_dict(embed_as_dict)
+ game_embed.set_footer(
+ text=f"{old_footer.rstrip()}\n\n{missed_text}"
+ )
+ await self.edit_embed_with_image(game.embed_msg, game_embed)
+
+ @start_game.command(name="help")
+ async def show_rules(self, ctx: commands.Context) -> None:
+ """Explain the rules of the game."""
+ await self.send_help_embed(ctx)
+
+ @start_game.command(name="stop")
+ @with_role(*MODERATION_ROLES)
+ async def stop_game(self, ctx: commands.Context) -> None:
+ """Stop a currently running game. Only available to mods."""
+ try:
+ game = self.current_games.pop(ctx.channel.id)
+ except KeyError:
+ await ctx.send("No game currently running in this channel")
+ return
+ await self.end_game(ctx.channel, game, end_message="Game canceled.")
+
+ @staticmethod
+ async def send_help_embed(ctx: commands.Context) -> discord.Message:
+ """Send rules embed."""
+ embed = discord.Embed(
+ title="Compete against other players to find valid flights!",
+ color=discord.Color.dark_purple(),
+ )
+ embed.description = HELP_TEXT
+ file = discord.File(HELP_IMAGE_PATH, filename="help.png")
+ embed.set_image(url="attachment://help.png")
+ embed.set_footer(
+ text="Tip: using Discord's compact message display mode can help keep the board on the screen"
+ )
+ return await ctx.send(file=file, embed=embed)
+
+ @staticmethod
+ async def edit_embed_with_image(msg: discord.Message, embed: discord.Embed) -> None:
+ """Edit an embed without the attached image going wonky."""
+ attach_name = urlparse(embed.image.url).path.split("/")[-1]
+ embed.set_image(url=f"attachment://{attach_name}")
+ await msg.edit(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the DuckGamesDirector cog."""
+ bot.add_cog(DuckGamesDirector(bot))
diff --git a/bot/exts/evergreen/realpython.py b/bot/exts/evergreen/realpython.py
new file mode 100644
index 00000000..e722dd4b
--- /dev/null
+++ b/bot/exts/evergreen/realpython.py
@@ -0,0 +1,76 @@
+import logging
+from html import unescape
+from urllib.parse import quote_plus
+
+from discord import Embed
+from discord.ext import commands
+
+from bot import bot
+from bot.constants import Colours
+
+logger = logging.getLogger(__name__)
+
+
+API_ROOT = "https://realpython.com/search/api/v1/"
+ARTICLE_URL = "https://realpython.com{article_url}"
+SEARCH_URL = "https://realpython.com/search?q={user_search}"
+
+
+ERROR_EMBED = Embed(
+ title="Error while searching Real Python",
+ description="There was an error while trying to reach Real Python. Please try again shortly.",
+ color=Colours.soft_red,
+)
+
+
+class RealPython(commands.Cog):
+ """User initiated command to search for a Real Python article."""
+
+ def __init__(self, bot: bot.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=["rp"])
+ @commands.cooldown(1, 10, commands.cooldowns.BucketType.user)
+ async def realpython(self, ctx: commands.Context, *, user_search: str) -> None:
+ """Send 5 articles that match the user's search terms."""
+ params = {"q": user_search, "limit": 5}
+ async with self.bot.http_session.get(url=API_ROOT, params=params) as response:
+ if response.status != 200:
+ logger.error(
+ f"Unexpected status code {response.status} from Real Python"
+ )
+ await ctx.send(embed=ERROR_EMBED)
+ return
+
+ data = await response.json()
+
+ articles = data["results"]
+
+ if len(articles) == 0:
+ no_articles = Embed(
+ title=f"No articles found for '{user_search}'", color=Colours.soft_red
+ )
+ await ctx.send(embed=no_articles)
+ return
+
+ article_embed = Embed(
+ title="Search results - Real Python",
+ url=SEARCH_URL.format(user_search=quote_plus(user_search)),
+ description="Here are the top 5 results:",
+ color=Colours.orange,
+ )
+
+ for article in articles:
+ article_embed.add_field(
+ name=unescape(article["title"]),
+ value=ARTICLE_URL.format(article_url=article["url"]),
+ inline=False,
+ )
+ article_embed.set_footer(text="Click the links to go to the articles.")
+
+ await ctx.send(embed=article_embed)
+
+
+def setup(bot: bot.Bot) -> None:
+ """Load the Real Python Cog."""
+ bot.add_cog(RealPython(bot))
diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py
index e57fa2c0..4df170c6 100644
--- a/bot/exts/evergreen/reddit.py
+++ b/bot/exts/evergreen/reddit.py
@@ -79,7 +79,8 @@ class Reddit(Cog):
text = data["selftext"]
if text:
- first_page += textwrap.shorten(text, width=100, placeholder="...").replace("*", "") + "\n"
+ text = escape_markdown(text).replace("[", "⦋").replace("]", "⦌")
+ first_page += textwrap.shorten(text, width=100, placeholder="...") + "\n"
ups = data["ups"]
comments = data["num_comments"]
diff --git a/bot/exts/evergreen/snakes/_utils.py b/bot/exts/evergreen/snakes/_utils.py
index 0a5894b7..f996d7f8 100644
--- a/bot/exts/evergreen/snakes/_utils.py
+++ b/bot/exts/evergreen/snakes/_utils.py
@@ -55,7 +55,8 @@ snakes = {
"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"
+ "Baby Cobra": "https://i.imgur.com/jk14ryt.png",
+ "Baby Anaconda": "https://i.imgur.com/EpdrnNr.png",
}
BOARD_TILE_SIZE = 56 # the size of each board tile
diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py
index 56bf5add..b7749144 100644
--- a/bot/exts/internal_eval/_internal_eval.py
+++ b/bot/exts/internal_eval/_internal_eval.py
@@ -7,7 +7,7 @@ import discord
from discord.ext import commands
from bot.bot import Bot
-from bot.constants import Roles
+from bot.constants import Client, Roles
from bot.utils.decorators import with_role
from bot.utils.extensions import invoke_help_command
from ._helpers import EvalContext
@@ -41,6 +41,9 @@ class InternalEval(commands.Cog):
self.bot = bot
self.locals = {}
+ if Client.debug:
+ self.internal_group.add_check(commands.is_owner().predicate)
+
@staticmethod
def shorten_output(
output: str,
diff --git a/bot/resources/evergreen/LuckiestGuy-Regular.ttf b/bot/resources/evergreen/LuckiestGuy-Regular.ttf
new file mode 100644
index 00000000..8c79c875
--- /dev/null
+++ b/bot/resources/evergreen/LuckiestGuy-Regular.ttf
Binary files differ
diff --git a/bot/resources/evergreen/all_cards.png b/bot/resources/evergreen/all_cards.png
new file mode 100644
index 00000000..10ed2eb8
--- /dev/null
+++ b/bot/resources/evergreen/all_cards.png
Binary files differ
diff --git a/bot/resources/evergreen/ducks_help_ex.png b/bot/resources/evergreen/ducks_help_ex.png
new file mode 100644
index 00000000..01d9c243
--- /dev/null
+++ b/bot/resources/evergreen/ducks_help_ex.png
Binary files differ