From ccfad468dd3c2fa97b37dad1f0ad250bb3fa4aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=B1=CE=B5=D1=8F=D1=8F=CE=B5?= <47398145+AiroPi@users.noreply.github.com> Date: Tue, 5 Dec 2023 13:53:19 +0100 Subject: [PATCH 1/4] fix typing issues (#109) - unfix requirements.txt for now - add temporary type: ignore with TODO tag - fix general typing issues - refactor MessageDisplay to drop unused types - add lingua stubs since the sources is rust code --- requirements.txt | 22 +++++++++++----------- src/cogs/poll/edit.py | 2 +- src/cogs/translate/_types.py | 2 +- src/core/_config.py | 2 +- src/core/response.py | 25 +++---------------------- typings/lingua.pyi | 31 +++++++++++++++++++++++++++++++ 6 files changed, 48 insertions(+), 36 deletions(-) create mode 100644 typings/lingua.pyi diff --git a/requirements.txt b/requirements.txt index 389181e..1cb4f94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ -wheel==0.41.1 -asyncpg==0.28.0 -sqlalchemy[asyncio]==2.0.19 -typing_extensions==4.7.1 -discord.py==2.3.2 -click==8.1.6 -psutil==5.9.5 -alembic==1.11.2 +wheel +asyncpg +sqlalchemy[asyncio] +typing_extensions +discord.py +click +psutil +alembic topggpy==2.0.0a0 -lingua-language-detector==1.3.2 -aiohttp==3.8.5 -python-dateutil==2.8.2 +lingua-language-detector +aiohttp +python-dateutil diff --git a/src/cogs/poll/edit.py b/src/cogs/poll/edit.py index 6024d1f..033fe61 100644 --- a/src/cogs/poll/edit.py +++ b/src/cogs/poll/edit.py @@ -358,7 +358,7 @@ class AddChoice(Menu["MyBot"], ui.Modal): def __init__(self, parent: EditChoices) -> None: ui.Modal.__init__(self, title=_("Add a new choice")) - Menu.__init__(self, parent=parent) # pyright: ignore [reportUnknownMemberType] + Menu.__init__(self, parent=parent) # type: ignore # TODO async def build(self) -> Self: self.choice = ui.TextInput[Self]( diff --git a/src/cogs/translate/_types.py b/src/cogs/translate/_types.py index 1b1f69f..e88db29 100644 --- a/src/cogs/translate/_types.py +++ b/src/cogs/translate/_types.py @@ -7,7 +7,7 @@ class SendStrategy(Protocol): - async def __call__(self, *, content: str = Any, embeds: Sequence[Embed] = Any, view: ui.View = MISSING) -> Any: + async def __call__(self, *, content: str = ..., embeds: Sequence[Embed] = ..., view: ui.View = MISSING) -> Any: ... diff --git a/src/core/_config.py b/src/core/_config.py index 7746b66..d0b6d30 100644 --- a/src/core/_config.py +++ b/src/core/_config.py @@ -29,7 +29,7 @@ class Config: TRANSLATOR_SERVICES: str = "libretranslate" LOG_WEBHOOK_URL: str | None = None - _instance: ClassVar[Config] | None = None + _instance: ClassVar[Self] | None = None _defined: ClassVar[bool] = False def __new__(cls, *args: Any, **kwargs: Any) -> Self: diff --git a/src/core/response.py b/src/core/response.py index e1ea7db..a3f3aed 100644 --- a/src/core/response.py +++ b/src/core/response.py @@ -1,7 +1,7 @@ import logging from dataclasses import dataclass from enum import Enum, auto -from typing import Any, Iterator, Literal, Mapping, overload +from typing import Any, Iterator, Mapping import discord from discord import Color, Embed @@ -9,13 +9,13 @@ logger = logging.getLogger(__name__) -@dataclass() +@dataclass class MessageDisplay(Mapping[str, Embed | str | None]): """ Used to represent the "display" of a message. It contains the content, the embeds, etc... """ - embed: Embed | None = None + embed: Embed content: str | None = None def __getitem__(self, key: str) -> Any: @@ -43,11 +43,6 @@ def __len__(self) -> int: return 0 -class _ResponseEmbed(MessageDisplay): - embed: Embed - content: str | None = None - - class ResponseType(Enum): success = auto() info = auto() @@ -70,20 +65,6 @@ class ResponseType(Enum): } -@overload -def response_constructor( - response_type: ResponseType, message: str, embedded: Literal[True] = ..., author_url: str | None = ... -) -> _ResponseEmbed: - ... - - -@overload -def response_constructor( - response_type: ResponseType, message: str, embedded: Literal[False] = ..., author_url: str | None = ... -) -> MessageDisplay: - ... - - def response_constructor( response_type: ResponseType, message: str, embedded: bool = True, author_url: str | None = None ) -> MessageDisplay: diff --git a/typings/lingua.pyi b/typings/lingua.pyi new file mode 100644 index 0000000..b01abb4 --- /dev/null +++ b/typings/lingua.pyi @@ -0,0 +1,31 @@ +from enum import Enum, auto +from typing import Self + +class Language(Enum): + ENGLISH = auto() + ARABIC = auto() + CHINESE = auto() + FRENCH = auto() + GERMAN = auto() + HINDI = auto() + INDONESIAN = auto() + IRISH = auto() + ITALIAN = auto() + JAPANESE = auto() + KOREAN = auto() + POLISH = auto() + PORTUGUESE = auto() + RUSSIAN = auto() + SPANISH = auto() + TURKISH = auto() + VIETNAMESE = auto() + +class LanguageDetectorBuilder: + @classmethod + def from_languages(cls, *args: Language) -> Self: ... + def with_low_accuracy_mode(self) -> Self: ... + def with_minimum_relative_distance(self, value: float) -> Self: ... + def build(self) -> LanguageDetector: ... + +class LanguageDetector: + def detect_language_of(self, text: str) -> Language | None: ... From b5c16e9028ef7268e4ec47cfbfa4efb1c3f34d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=B1=CE=B5=D1=8F=D1=8F=CE=B5?= <47398145+AiroPi@users.noreply.github.com> Date: Tue, 5 Dec 2023 14:23:14 +0100 Subject: [PATCH 2/4] Add 2048 (#110) Merge this branch to clean a little the github repo. This game can be considered as ready, but in any case, it is marked as "beta" AND the bot is still in beta. I can also disable the feature when running the bot. Merging the branch will reduce merge conflits for later. --- pyproject.toml | 5 +- requirements.txt | 1 + src/cogs/api.py | 5 +- src/cogs/game/__init__.py | 40 ++++- src/cogs/game/game_2084.py | 103 +++++++++++ src/cogs/game/minesweeper/__init__.py | 137 +++++++++++++++ src/cogs/game/minesweeper/minesweeper_game.py | 163 ++++++++++++++++++ src/core/constants.py | 26 ++- 8 files changed, 465 insertions(+), 15 deletions(-) create mode 100644 src/cogs/game/game_2084.py create mode 100644 src/cogs/game/minesweeper/__init__.py create mode 100644 src/cogs/game/minesweeper/minesweeper_game.py diff --git a/pyproject.toml b/pyproject.toml index 090049a..fc4e90e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ -[tool.bandit.assert_used] -skips = ["*/test_*.py", "*/test_*.py"] +[tool.bandit] +skips = ["B101"] + [tool.black] line-length = 120 diff --git a/requirements.txt b/requirements.txt index 1cb4f94..a9a0c45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ discord.py click psutil alembic +two048 # a personal library topggpy==2.0.0a0 lingua-language-detector aiohttp diff --git a/src/cogs/api.py b/src/cogs/api.py index f062413..1f09d04 100644 --- a/src/cogs/api.py +++ b/src/cogs/api.py @@ -3,7 +3,7 @@ import logging from functools import partial from os import getpid -from typing import TYPE_CHECKING, Awaitable, Callable, Concatenate, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Awaitable, Callable, Concatenate, ParamSpec, TypeVar, cast from aiohttp import hdrs, web from psutil import Process @@ -58,7 +58,8 @@ async def cog_unload(self) -> None: @route(hdrs.METH_GET, "/memory") async def test(self, request: web.Request): - return web.Response(text=f"{round(Process(getpid()).memory_info().rss/1024/1024, 2)} MB") + rss = cast(int, Process(getpid()).memory_info().rss) # pyright: ignore[reportUnknownMemberType] + return web.Response(text=f"{round(rss/1024/1024, 2)} MB") async def setup(bot: MyBot): diff --git a/src/cogs/game/__init__.py b/src/cogs/game/__init__.py index 89a1067..2e3e2b2 100644 --- a/src/cogs/game/__init__.py +++ b/src/cogs/game/__init__.py @@ -8,15 +8,17 @@ from core import SpecialGroupCog, cog_property +from .connect4 import GameConnect4 +from .game_2084 import Two048Cog +from .minesweeper import MinesweeperCog +from .rpc import GameRPC +from .tictactoe import GameTictactoe + if TYPE_CHECKING: from discord import Interaction from mybot import MyBot - from .connect4 import GameConnect4 - from .rpc import GameRPC - from .tictactoe import GameTictactoe - logger = logging.getLogger(__name__) @@ -44,6 +46,14 @@ def rpc_cog(self) -> GameRPC: def tictactoe_cog(self) -> GameTictactoe: ... + @cog_property("minesweeper") + def minesweeper_cog(self) -> MinesweeperCog: + ... + + @cog_property("game_2048") + def two048_cog(self) -> Two048Cog: + ... + @app_commands.command( name=__("connect4"), description=__("Play connect 4"), @@ -68,14 +78,28 @@ async def rpc(self, inter: Interaction) -> None: async def tictactoe(self, inter: Interaction) -> None: await self.tictactoe_cog.tictactoe(inter) + @app_commands.command( + name=__("minesweeper"), + description=__("Play minesweeper"), + extras={"soon": True}, + ) + async def minesweeper(self, inter: Interaction) -> None: + await self.minesweeper_cog.minesweeper(inter) + + @app_commands.command( + name=__("2048"), + description=__("Play 2048"), + extras={"beta": True}, + ) + async def _2048(self, inter: Interaction) -> None: + await self.two048_cog.two048(inter) -async def setup(bot: MyBot) -> None: - from .connect4 import GameConnect4 - from .rpc import GameRPC - from .tictactoe import GameTictactoe +async def setup(bot: MyBot) -> None: await bot.add_cog(GameTictactoe(bot)) await bot.add_cog(GameRPC(bot)) await bot.add_cog(GameConnect4(bot)) + await bot.add_cog(MinesweeperCog(bot)) + await bot.add_cog(Two048Cog(bot)) await bot.add_cog(Game(bot)) diff --git a/src/cogs/game/game_2084.py b/src/cogs/game/game_2084.py new file mode 100644 index 0000000..6057f95 --- /dev/null +++ b/src/cogs/game/game_2084.py @@ -0,0 +1,103 @@ +# TODO : show score +# TODO : create game save +# TODO : handle game over + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Self, TypedDict + +import discord +from discord import ui +from two048 import Direction, Tile, Two048 + +from core import ResponseType, SpecialCog, response_constructor +from core.constants import Emojis + +if TYPE_CHECKING: + from discord import Interaction + + from mybot import MyBot + + +logger = logging.getLogger(__name__) + + +class ResponseT(TypedDict): + view: Two048View + content: str + + +tile_value_to_emoji = { + 0: Emojis.two048_empty, + 2: Emojis.two048_2, + 4: Emojis.two048_4, + 8: Emojis.two048_8, + 16: Emojis.two048_16, + 32: Emojis.two048_32, + 64: Emojis.two048_64, + 128: Emojis.two048_128, + 256: Emojis.two048_256, + 512: Emojis.two048_512, + 1024: Emojis.two048_1024, + 2048: Emojis.two048_2048, + 4096: Emojis.two048_4096, + 8192: Emojis.two048_8192, +} + + +class Two048Cog(SpecialCog["MyBot"], name="game_2048"): + async def two048(self, inter: Interaction) -> None: + await inter.response.send_message(**Two048View.init_game(inter.user)) + + +class Two048View(ui.View): + def __init__(self, game: Two048, user: discord.User | discord.Member): + super().__init__() + self.game = game + self.user = user + + @classmethod + def init_game(cls, user: discord.User | discord.Member) -> ResponseT: + view = cls(Two048(), user) + + content = cls.str_board(view.game.board) + + return {"view": view, "content": content} + + @staticmethod + def str_board(board: list[list[Tile]]) -> str: + return "\n".join("".join(tile_value_to_emoji[t.value] for t in row) for row in board) + + async def interaction_check(self, interaction: Interaction, /) -> bool: + if not interaction.user == self.user: + await interaction.response.send_message( + **response_constructor(ResponseType.error, "You are not the owner of this game.") + ) + return False + return True + + async def play(self, inter: Interaction, direction: Direction) -> None: + self.game.play(direction) + + await inter.response.edit_message(view=self, content=self.str_board(self.game.board)) + + @ui.button(emoji=Emojis.left_arrow, style=discord.ButtonStyle.blurple) + async def left(self, inter: Interaction, button: ui.Button[Self]) -> None: + del button # unused + await self.play(inter, Direction.LEFT) + + @ui.button(emoji=Emojis.down_arrow, style=discord.ButtonStyle.blurple) + async def down(self, inter: Interaction, button: ui.Button[Self]) -> None: + del button # unused + await self.play(inter, Direction.DOWN) + + @ui.button(emoji=Emojis.up_arrow, style=discord.ButtonStyle.blurple) + async def up(self, inter: Interaction, button: ui.Button[Self]) -> None: + del button # unused + await self.play(inter, Direction.UP) + + @ui.button(emoji=Emojis.right_arrow, style=discord.ButtonStyle.blurple) + async def right(self, inter: Interaction, button: ui.Button[Self]) -> None: + del button # unused + await self.play(inter, Direction.RIGHT) diff --git a/src/cogs/game/minesweeper/__init__.py b/src/cogs/game/minesweeper/__init__.py new file mode 100644 index 0000000..bc2ad01 --- /dev/null +++ b/src/cogs/game/minesweeper/__init__.py @@ -0,0 +1,137 @@ +# TODO ? : add a way to play by reply to the message. + +from __future__ import annotations + +import logging +from string import ascii_uppercase +from typing import TYPE_CHECKING, Self + +import discord +from discord import ui + +from core import ResponseType, SpecialCog, response_constructor + +from .minesweeper_game import Minesweeper, MinesweeperConfig, PlayType + +if TYPE_CHECKING: + from discord import Interaction + + from mybot import MyBot + + +logger = logging.getLogger(__name__) + +corner = " " +row_denominators = ["⒈", "⒉", "⒊", "⒋", "⒌", "⒍", "⒎", "⒏", "⒐", "⒑", "⒒", "⒓", "⒔", "⒕", "⒖", "⒗", "⒘", "⒙", "⒚", "⒛"] +column_denominators = ascii_uppercase + +board_size = (20, 13) + + +def build_board_display(game: Minesweeper) -> str: + description = "```" + corner + " " * 3 + " ".join(column_denominators[i] for i in range(0, game.size[1])) + "\n" + + display_chars = { + 0: " ", + -1: "X", + } + display_chars.update({i: str(i) for i in range(1, 10)}) + flag_char = "?" + unrevealed_char = "■" + + def get_char(row: int, column: int) -> str: + if (row, column) in game.flags: + return flag_char + if (row, column) in game.revealed: + return display_chars[game.board[row][column]] + + return unrevealed_char + + for row in range(0, game.size[0]): + description += row_denominators[row] + " |" + for column in range(0, game.size[1]): + description += " " + get_char(row, column) + description += "\n" + description += "```" + + return description + + +class MinesweeperCog(SpecialCog["MyBot"], name="minesweeper"): + async def minesweeper(self, inter: Interaction) -> None: + embed = response_constructor(ResponseType.info, "Minesweeper").embed + game = Minesweeper(board_size, 0) # only used for the view, will be regenerated within the first play + embed.description = build_board_display(game) + + embed.add_field(name="Time", value=f"") + + view = MinesweeperView(embed) + await inter.response.send_message(embed=embed, view=view) + + +class MinesweeperView(ui.View): + def __init__(self, game_embed: discord.Embed): + super().__init__(timeout=180) + self.game: Minesweeper | None = None + self.game_embed = game_embed + + self.row.options = [ + discord.SelectOption(label=row_denominators[i], value=str(i)) for i in range(0, board_size[0]) + ] + self.column.options = [ + discord.SelectOption(label=column_denominators[i], value=str(i)) for i in range(0, board_size[1]) + ] + + def check_playable(self) -> None: + if self.row.values != [] and self.column.values != []: + self.play.disabled = False + + @ui.select( + cls=ui.Select, + placeholder="Select the row", + options=[], + ) + async def row(self, inter: Interaction, value: ui.Select[Self]) -> None: + for i, option in enumerate(self.row.options): + option.default = i == int(value.values[0]) + self.check_playable() + await inter.response.edit_message(view=self) + + @ui.select( + cls=ui.Select, + placeholder="Select the column", + options=[], + ) + async def column(self, inter: Interaction, value: ui.Select[Self]) -> None: + for i, option in enumerate(self.column.options): + option.default = i == int(value.values[0]) + self.check_playable() + await inter.response.edit_message(view=self) + + @ui.button(label="Play", style=discord.ButtonStyle.green, disabled=True) + async def play(self, inter: Interaction, button: ui.Button[Self]) -> None: + x, y = int(self.row.values[0]), int(self.column.values[0]) + if self.game is None: + conf = MinesweeperConfig(height=board_size[0], width=board_size[1], number_of_mines=50, initial_play=(x, y)) + self.game = Minesweeper.from_config(conf) + self.flag.disabled = False + else: + play = self.game.play(x, y) + print(play) + if play.type == PlayType.BOMB_EXPLODED: + print("bomb exploded") + self.clear_items() + + self.game_embed.description = build_board_display(self.game) + await inter.response.edit_message(embed=self.game_embed, view=self) + + @ui.button(label="Flag", style=discord.ButtonStyle.blurple, disabled=True) + async def flag(self, inter: Interaction, button: ui.Button[Self]) -> None: + if TYPE_CHECKING: + assert self.game is not None + + x, y = int(self.row.values[0]), int(self.column.values[0]) + self.game.add_flag(x, y) + + self.game_embed.description = build_board_display(self.game) + await inter.response.edit_message(embed=self.game_embed) diff --git a/src/cogs/game/minesweeper/minesweeper_game.py b/src/cogs/game/minesweeper/minesweeper_game.py new file mode 100644 index 0000000..1ea8a45 --- /dev/null +++ b/src/cogs/game/minesweeper/minesweeper_game.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import random +from collections.abc import Iterable +from dataclasses import dataclass +from enum import Enum, auto +from itertools import chain, permutations +from typing import TypeAlias, cast + +height: TypeAlias = int +width: TypeAlias = int +row: TypeAlias = int +column: TypeAlias = int + +boardT = list[list[int]] + + +@dataclass +class MinesweeperConfig: + height: int + width: int + number_of_mines: int + initial_play: tuple[row, column] | None = None + + +class GameOver(Exception): + pass + + +class PlayType(Enum): + BOMB_EXPLODED = auto() + EMPTY_SPOT = auto() + NUMBERED_SPOT = auto() + ALREADY_REVEALED = auto() + + +@dataclass +class Play: + type: PlayType + positions: tuple[tuple[row, column], ...] + + +class Minesweeper: + def __init__( + self, + size: tuple[height, width], + number_of_mines: int, + initial_play: tuple[row, column] | None = None, + ): + self.size: tuple[height, width] = size + self.number_of_mines: int = number_of_mines + + self.revealed: list[tuple[height, width]] = [] + self.flags: list[tuple[height, width]] = [] + self.game_over: bool = False + self._board: boardT = self.create_board(initial_play) + if initial_play is not None: + self.play(*initial_play) + + @classmethod + def from_config(cls, config: MinesweeperConfig): + return cls((config.height, config.width), config.number_of_mines, config.initial_play) + + @property + def board(self) -> boardT: + return self._board + + @property + def positions(self) -> set[tuple[height, width]]: + return {(x, y) for x in range(self.size[0]) for y in range(self.size[1])} + + @property + def mines_positions(self) -> set[tuple[height, width]]: + return {(x, y) for x in range(self.size[0]) for y in range(self.size[1]) if self._board[x][y] == -1} + + def is_inside(self, x: int, y: int) -> bool: + return 0 <= x < self.size[0] and 0 <= y < self.size[1] + + def add_flag(self, x: int, y: int) -> None: + if (x, y) in self.revealed: + return + self.flags.append((x, y)) + + def play(self, x: int, y: int) -> Play: + """Play the game at the given position. Return False if a bomb is found.""" + if self.game_over: + raise GameOver("The game is over.") + + if not self.is_inside(x, y): + raise ValueError("The given position is out of the board.") + + if (x, y) in self.mines_positions: + self.game_over = True + self.revealed.append((x, y)) + return Play(PlayType.BOMB_EXPLODED, ((x, y),)) + + if (x, y) in self.revealed: + return Play(PlayType.ALREADY_REVEALED, ((x, y),)) + + if self._board[x][y] == 0: + positions = self.diffuse_empty_places(x, y) + return Play(PlayType.EMPTY_SPOT, positions) + else: + self.revealed.append((x, y)) + return Play(PlayType.NUMBERED_SPOT, ((x, y),)) + + def diffuse_empty_places(self, x: int, y: int, is_corner: bool = False) -> tuple[tuple[row, column], ...]: + """Diffuse the empty place at the given position. This function is recursive.""" + if (x, y) in self.revealed or not self.is_inside(x, y): + return tuple() + + self.revealed.append((x, y)) + + if self._board[x][y] == 0: + gen: Iterable[tuple[int, int]] = cast( + Iterable[tuple[int, int]], chain(permutations(range(-1, 2, 1), 2), ((1, 1), (-1, -1))) + ) + return ((x, y),) + tuple( + cpl for dx, dy in gen for cpl in self.diffuse_empty_places(x + dx, y + dy, dx != 0 and dy != 0) + ) + + else: + return ((x, y),) + + def create_board(self, initial_play: tuple[row, column] | None) -> boardT: + board: boardT = [[0 for _ in range(self.size[1])] for _ in range(self.size[0])] + + def increment_around(x: int, y: int): + """Increment the value of the cells around the given position.""" + + # I think this can be done in a more elegant way + def incr(x: int, y: int): + if 0 <= x < self.size[0] and 0 <= y < self.size[1]: + if board[x][y] != -1: + board[x][y] += 1 + + relative_positions: Iterable[tuple[int, int]] = cast( + Iterable[tuple[int, int]], chain(permutations(range(-1, 2, 1), 2), ((1, 1), (-1, -1))) + ) + for dx, dy in relative_positions: + incr(x + dx, y + dy) + + positions = self.positions + if initial_play is not None: + positions -= {initial_play} + + mines_pos = random.sample(list(positions), self.number_of_mines) + for x, y in mines_pos: + board[x][y] = -1 + increment_around(x, y) + + return board + + def display(self) -> None: + for x, row in enumerate(self._board): + special_repr = { + -1: "X", + 0: " ", + } + print( + *(special_repr.get(case, case) if (x, y) in self.revealed else "■" for y, case in enumerate(row)), + sep=" ", + ) diff --git a/src/core/constants.py b/src/core/constants.py index 2b09b3d..aa937f7 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -8,7 +8,7 @@ class Emoji(str): def __new__(cls, id: Snowflake) -> Self: - return super().__new__(cls, f"<:e:{id}>") + return super().__new__(cls, f"<:_:{id}>") def __init__(self, id: Snowflake) -> None: self._id = id @@ -20,8 +20,28 @@ def id(self): class Emojis: - beta_1 = Emoji(1083897907981320272) - beta_2 = Emoji(1083897910728609872) + beta_1 = Emoji(1012449366449078384) + beta_2 = Emoji(1012449349424390265) + + two048_2 = Emoji(1068989299808292956) + two048_4 = Emoji(1068989301888663604) + two048_8 = Emoji(1068989303243415672) + two048_16 = Emoji(1068989304912756807) + two048_32 = Emoji(1068989307349635133) + two048_64 = Emoji(1068989309119643770) + two048_128 = Emoji(1068989310268887061) + two048_256 = Emoji(1068989312529596546) + two048_512 = Emoji(1068989314127626321) + two048_1024 = Emoji(1068989316195426395) + two048_2048 = Emoji(1068989317592121446) + two048_4096 = Emoji(1068989378413744231) + two048_8192 = Emoji(1068989381379096646) + two048_empty = Emoji(1068990458929365072) + + up_arrow = Emoji(1069018469036736544) + down_arrow = Emoji(1069018464301363240) + left_arrow = Emoji(1069018466624995439) + right_arrow = Emoji(1069018468185288834) soon_1 = Emoji(1083897922522988575) soon_2 = Emoji(1083897924146180198) From a8b3653e220b7f6a16c022dcf1ca9fa7651bd65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=B1=CE=B5=D1=8F=D1=8F=CE=B5?= <47398145+AiroPi@users.noreply.github.com> Date: Tue, 5 Dec 2023 14:40:11 +0100 Subject: [PATCH 3/4] Calculator command (#111) Implement the calculator command since it is ready for a while now and I have too much useless branches. --- src/cogs/calculator.py | 35 ----- src/cogs/calculator/__init__.py | 231 ++++++++++++++++++++++++++++++++ src/cogs/calculator/calcul.py | 130 ++++++++++++++++++ 3 files changed, 361 insertions(+), 35 deletions(-) delete mode 100644 src/cogs/calculator.py create mode 100644 src/cogs/calculator/__init__.py create mode 100644 src/cogs/calculator/calcul.py diff --git a/src/cogs/calculator.py b/src/cogs/calculator.py deleted file mode 100644 index cbe2cc2..0000000 --- a/src/cogs/calculator.py +++ /dev/null @@ -1,35 +0,0 @@ -# TODO(airo.pi_): use an autocompleter for the initial expression - - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from discord import app_commands -from discord.app_commands import locale_str as __ -from discord.ext.commands import Cog # pyright: ignore[reportMissingTypeStubs] - -if TYPE_CHECKING: - from discord import Interaction - - from mybot import MyBot - -logger = logging.getLogger(__name__) - - -class Calculator(Cog): - def __init__(self, bot: MyBot): - self.bot: MyBot = bot - - @app_commands.command( - name=__("calculator"), - description=__("Show a calculator you can use."), - extras={"soon": True}, - ) - async def calculator(self, inter: Interaction, initial_expression: str) -> None: - raise NotImplementedError("Calculator is not implemented.") - - -async def setup(bot: MyBot): - await bot.add_cog(Calculator(bot)) diff --git a/src/cogs/calculator/__init__.py b/src/cogs/calculator/__init__.py new file mode 100644 index 0000000..01fe7c1 --- /dev/null +++ b/src/cogs/calculator/__init__.py @@ -0,0 +1,231 @@ +# TODO : use an autocompleter for the initial expression + + +from __future__ import annotations + +import decimal +import logging +from functools import partial +from typing import TYPE_CHECKING, Self + +import discord +from discord import ButtonStyle, app_commands, ui +from discord.app_commands import locale_str as __ + +from core import SpecialCog +from core.i18n import _ + +from .calcul import Calcul, UnclosedParentheses + +if TYPE_CHECKING: + from discord import Interaction + + from mybot import MyBot + + +logger = logging.getLogger(__name__) + + +def display_calcul(calcul: Calcul) -> str: + if calcul.just_calculated: + display = f"> ```py\n" f"> {calcul.expr} =\n" f"> {calcul.answer: <41}\n" f"> ```" + calcul.expr = calcul.answer + calcul.new = True + else: + display = f"> ```py\n" f"> Ans = {calcul.answer}\n" f"> {calcul.expr: <41}\n" f"> ```" + return display + + +class Calculator(SpecialCog["MyBot"]): + @app_commands.command( + name=__("calculator"), + description=__("Show a calculator you can use."), + extras={"beta": True}, + ) + async def calculator(self, inter: Interaction, initial_expression: str | None) -> None: + calcul = Calcul() + + if initial_expression: + try: + calcul.expr = initial_expression + calcul.process() + except Exception: + calcul.reset_expr() + + view = CalculatorView(self, inter, calcul) + await inter.response.send_message(content=display_calcul(calcul), view=view) + + @calculator.autocomplete("initial_expression") + async def calculator_direct_autocomplete(self, inter: Interaction, current: str) -> list[app_commands.Choice[str]]: + """ + An autocompleter to show the result of the current expression while typing it. + """ + try: + result = Calcul.string_process(current) + except Exception: + return [app_commands.Choice(name=__("Invalid expression"), value=current)] + else: + return [ + app_commands.Choice(name=result, value=current), + ] + + +class CalculatorView(ui.View): + def __init__(self, parent: Calculator, inter: Interaction, calcul: Calcul): + super().__init__(timeout=600) + self.parent: Calculator = parent + self.inter: Interaction = inter + self.calcul: Calcul = calcul + + buttons = [ + (ButtonStyle.primary, "xᶤ", " ^ "), + (ButtonStyle.primary, "x²", "pow2"), + (ButtonStyle.primary, "AC", "clear"), + (ButtonStyle.primary, "⌫", "delete"), + (ButtonStyle.danger, "/", " / "), + (ButtonStyle.primary, "(", "("), + (ButtonStyle.secondary, "7", "7"), + (ButtonStyle.secondary, "8", "8"), + (ButtonStyle.secondary, "9", "9"), + (ButtonStyle.danger, "x", " * "), + (ButtonStyle.primary, ")", ")"), + (ButtonStyle.secondary, "4", "4"), + (ButtonStyle.secondary, "5", "5"), + (ButtonStyle.secondary, "6", "6"), + (ButtonStyle.danger, "-", " - "), + (ButtonStyle.primary, "π", "π"), + (ButtonStyle.secondary, "1", "1"), + (ButtonStyle.secondary, "2", "2"), + (ButtonStyle.secondary, "3", "3"), + (ButtonStyle.danger, "+", " + "), + (ButtonStyle.primary, "Ans", "Ans"), + (ButtonStyle.secondary, "⁺∕₋", "opposite"), + (ButtonStyle.secondary, "0", "0"), + (ButtonStyle.secondary, ",", "."), + (ButtonStyle.success, "=", "result"), + ] + for style, label, custom_id in buttons: + button: ui.Button[Self] = ui.Button(style=style, label=label, custom_id=custom_id) + button.callback = partial(self.compute, button) + self.add_item(button) + + async def on_timeout(self) -> None: + item: ui.Button[Self] + for item in self.children: # type: ignore + item.disabled = True + message = await self.inter.original_response() + await message.edit(view=self) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + passed: bool = interaction.user.id == self.inter.user.id + + if not passed: # TODO : error style + await interaction.response.send_message( + content="Vous ne pouvez pas interagir sur une calculatrice de quelqu'un d'autre, faites vous-même la commande !", + ephemeral=True, + ) + + return passed + + async def compute(self, button: ui.Button[Self], interaction: discord.Interaction): + print(self, button, interaction) + calcul = self.calcul + selection = button.custom_id + + numbers = ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9") + operators = (" / ", " + ", " * ", " - ", " ^ ") + + avertissements: list[str] = [] + + if selection in numbers + ("π", "Ans"): + if calcul.new: + calcul.expr = "" + calcul.new = False + calcul.expr += selection + + elif selection == ".": + if calcul.new: + calcul.expr = "" + calcul.new = False + if any(calcul.expr.endswith(nb) for nb in numbers + (".",)): + if "." not in calcul.expr.split(" ")[-1]: + calcul.expr += "." + else: + calcul.expr += "0." + + elif selection in operators: + calcul.new = False + if any(calcul.expr.endswith(sign) for sign in operators): + calcul.expr = calcul.expr[:-3] + selection + else: + if selection == " - " and calcul.expr.endswith("("): + calcul.expr += "0 - " + else: + calcul.expr += selection + + elif selection == "(": + if calcul.new: + calcul.expr = "" + calcul.new = False + calcul.expr += selection + + elif selection == ")": + if calcul.expr.count("(") > calcul.expr.count(")"): + if calcul.expr.endswith("("): + calcul.expr += "0)" + else: + calcul.expr += ")" + + elif selection == "pow2": + calcul.new = False + if any(calcul.expr.endswith(sign) for sign in operators): + calcul.expr = calcul.expr[:-3] + " ^ 2" + else: + calcul.expr += " ^ 2" + + elif selection == "opposite": + if calcul.expr in ("π", "Ans"): + calcul.expr = "-" + calcul.expr + elif calcul.expr in ("-π", "-Ans"): + calcul.expr = calcul.expr[1:] + else: + try: + result = decimal.Decimal(calcul.expr) + except (ValueError, decimal.FloatOperation, decimal.InvalidOperation): + avertissements.append("L'expression doit être un simple nombre.") + else: + calcul.expr = str(-result) + + elif selection == "result": + try: + calcul.process() + except decimal.InvalidOperation as error: + print(error) + avertissements.append("Quelque chose cloche avec votre calcul, vérifiez la syntax.") + except ZeroDivisionError: + avertissements.append("Vous ne pouvez pas diviser par 0.") + except UnclosedParentheses: + avertissements.append("Vous n'avez pas fermé certaines parenthèses.") + except decimal.Overflow: + avertissements.append("On dirait que le résultat est vraiment gros. Trop gros...") + + elif selection == "clear": + calcul.reset_expr() + + elif selection == "delete": + if any(calcul.expr.endswith(sign) for sign in operators): + calcul.expr = calcul.expr[:-3] + else: + calcul.expr = calcul.expr[:-1] + + if not calcul.expr: + calcul.reset_expr() + + if avertissements: + await interaction.response.send_message(ephemeral=True, content="\n".join(avertissements)) + else: + await interaction.response.edit_message(content=display_calcul(calcul)) + + +async def setup(bot: MyBot): + await bot.add_cog(Calculator(bot)) diff --git a/src/cogs/calculator/calcul.py b/src/cogs/calculator/calcul.py new file mode 100644 index 0000000..09e8878 --- /dev/null +++ b/src/cogs/calculator/calcul.py @@ -0,0 +1,130 @@ +""" +This is a complete program to simulate a calculator in python. +It is fully based on regex. So no security issues. +But could be improved. +""" + +import decimal +import operator as op +import re +from collections.abc import Sequence +from math import pi +from typing import Callable + +Decimal = decimal.Decimal + + +def regex_builder(sign: str) -> re.Pattern[str]: + pattern = ( + r"((?:[-+]?\d*\.?\d+)(?:[eE](?:[-+]?\d+))?) *" + "\\" + sign + r" *((?:[-+]?\d*\.?\d+)(?:[eE](?:[-+]?\d+))?)" + ) + return re.compile(pattern) + + +re_parentheses = re.compile(r"\((.*?)\)") + +addition = (regex_builder("+"), op.add) +subtraction = (regex_builder("-"), op.sub) +division = (regex_builder("/"), op.truediv) +multiplication = (regex_builder("*"), op.mul) +power = (regex_builder("^"), op.pow) + + +class UnclosedParentheses(Exception): + ... + + +class Calcul: + """ + Hmm... Comments seems important here. + """ + + def __init__(self) -> None: + self._expression: str = "0" + self._answer: str = "0" + self.just_calculated: bool = False + self.new: bool = True + + @property + def expr(self) -> str: + return self._expression + + @expr.setter + def expr(self, value: str) -> None: + self.just_calculated = False + self._expression = value + + @property + def answer(self) -> str: + return self._answer + + @answer.setter + def answer(self, value: str) -> None: + self.just_calculated = True + self._answer = value + + def reset_expr(self) -> None: + self.new = True + self._expression = "0" + + @staticmethod + def operator_process(calcul: str, pattern: re.Pattern[str], operator_method: Callable[[Decimal, Decimal], Decimal]): + while result := pattern.search(calcul): + operation_result = operator_method(Decimal(result.group(1)), Decimal(result.group(2))).normalize() + calcul = pattern.sub(str(operation_result), calcul, 1) + return calcul + + @staticmethod + def multiple_operators_process( + calcul: str, + patterns: Sequence[re.Pattern[str]], + operator_methods: Sequence[Callable[[Decimal, Decimal], Decimal]], + ): + def sort_key(y: re.Match[str]) -> int: + return y.start(2) + + results: list[re.Match[str]] + iterable = (x for pattern in patterns if (x := pattern.search(calcul))) + while results := sorted(iterable, key=sort_key): + result = results[0] + + operator_method = operator_methods[patterns.index(result.re)] + operation_result = operator_method(Decimal(result.group(1)), Decimal(result.group(2))).normalize() + + calcul = result.re.sub(str(operation_result), calcul, 1) + return calcul + + @staticmethod + def string_process(calcul: str) -> str: + decimal.getcontext().prec = 10 + + if calcul.count("(") != calcul.count(")"): + raise UnclosedParentheses() + while match := re.search(r"([)\d])(\()", calcul): + calcul = match.re.sub(r"\1 * \2", calcul) + while match := re.search(r"(\))(\d)", calcul): + calcul = match.re.sub(r"\1 * \2", calcul) + + while result := re_parentheses.search(calcul): + operation_result = Calcul.string_process(result.group(1)) + calcul = re_parentheses.sub(str(operation_result), calcul, 1) + + calcul = Calcul.operator_process(calcul, *power) + + calcul = Calcul.multiple_operators_process(calcul, *zip(multiplication, division)) # type: ignore (zip typing issue) + calcul = Calcul.multiple_operators_process(calcul, *zip(addition, subtraction)) # type: ignore (zip typing issue) + + return calcul + + def process(self) -> None: + expression = self.expr + expression = expression.replace("π", "(" + str(pi) + ")") + expression = expression.replace("Ans", "(" + str(self.answer) + ")") + result = self.string_process(expression) + result = decimal.Decimal(result).normalize() # Check if the result is correct, if not, raise errors. + self.answer = str(result) + + +if __name__ == "__main__": + calcul = Calcul() + print(Calcul.string_process("1+2+3+4+5+6+7+8+9+10")) From 9b76e85e391fd3fab9d372d7d7badf535b5142e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C4=B1=CE=B5=D1=8F=D1=8F=CE=B5?= <47398145+AiroPi@users.noreply.github.com> Date: Tue, 5 Dec 2023 14:43:47 +0100 Subject: [PATCH 4/4] Delete .github/dependabot.yml (#95) --- .github/dependabot.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 91abb11..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "pip" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly"