easterobot 1.0.0__py3-none-any.whl → 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. easterobot/alembic/env.py +91 -0
  2. easterobot/alembic/script.py.mako +28 -0
  3. easterobot/alembic/versions/2f0d4305e320_init_database.py +67 -0
  4. easterobot/alembic/versions/940c3b9c702d_add_lock_on_eggs.py +38 -0
  5. easterobot/bot.py +93 -462
  6. easterobot/cli.py +56 -17
  7. easterobot/commands/__init__.py +8 -0
  8. easterobot/commands/base.py +34 -13
  9. easterobot/commands/basket.py +10 -12
  10. easterobot/commands/edit.py +4 -4
  11. easterobot/commands/enable.py +5 -1
  12. easterobot/commands/game.py +187 -0
  13. easterobot/commands/help.py +1 -1
  14. easterobot/commands/reset.py +1 -1
  15. easterobot/commands/search.py +4 -4
  16. easterobot/commands/top.py +7 -18
  17. easterobot/config.py +67 -3
  18. easterobot/games/__init__.py +14 -0
  19. easterobot/games/connect.py +206 -0
  20. easterobot/games/game.py +262 -0
  21. easterobot/games/rock_paper_scissor.py +206 -0
  22. easterobot/games/tic_tac_toe.py +168 -0
  23. easterobot/hunts/__init__.py +14 -0
  24. easterobot/hunts/hunt.py +428 -0
  25. easterobot/hunts/rank.py +82 -0
  26. easterobot/models.py +2 -1
  27. easterobot/resources/alembic.ini +87 -0
  28. easterobot/resources/config.example.yml +10 -2
  29. easterobot/resources/credits.txt +5 -1
  30. easterobot/resources/emotes/icons/arrow.png +0 -0
  31. easterobot/resources/emotes/icons/end.png +0 -0
  32. easterobot/resources/emotes/icons/versus.png +0 -0
  33. easterobot/resources/emotes/icons/wait.png +0 -0
  34. {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/METADATA +11 -5
  35. easterobot-1.1.1.dist-info/RECORD +66 -0
  36. easterobot-1.0.0.dist-info/RECORD +0 -48
  37. /easterobot/resources/{eggs → emotes/eggs}/egg_01.png +0 -0
  38. /easterobot/resources/{eggs → emotes/eggs}/egg_02.png +0 -0
  39. /easterobot/resources/{eggs → emotes/eggs}/egg_03.png +0 -0
  40. /easterobot/resources/{eggs → emotes/eggs}/egg_04.png +0 -0
  41. /easterobot/resources/{eggs → emotes/eggs}/egg_05.png +0 -0
  42. /easterobot/resources/{eggs → emotes/eggs}/egg_06.png +0 -0
  43. /easterobot/resources/{eggs → emotes/eggs}/egg_07.png +0 -0
  44. /easterobot/resources/{eggs → emotes/eggs}/egg_08.png +0 -0
  45. /easterobot/resources/{eggs → emotes/eggs}/egg_09.png +0 -0
  46. /easterobot/resources/{eggs → emotes/eggs}/egg_10.png +0 -0
  47. /easterobot/resources/{eggs → emotes/eggs}/egg_11.png +0 -0
  48. /easterobot/resources/{eggs → emotes/eggs}/egg_12.png +0 -0
  49. /easterobot/resources/{eggs → emotes/eggs}/egg_13.png +0 -0
  50. /easterobot/resources/{eggs → emotes/eggs}/egg_14.png +0 -0
  51. /easterobot/resources/{eggs → emotes/eggs}/egg_15.png +0 -0
  52. /easterobot/resources/{eggs → emotes/eggs}/egg_16.png +0 -0
  53. /easterobot/resources/{eggs → emotes/eggs}/egg_17.png +0 -0
  54. /easterobot/resources/{eggs → emotes/eggs}/egg_18.png +0 -0
  55. /easterobot/resources/{eggs → emotes/eggs}/egg_19.png +0 -0
  56. /easterobot/resources/{eggs → emotes/eggs}/egg_20.png +0 -0
  57. {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/WHEEL +0 -0
  58. {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/entry_points.txt +0 -0
  59. {easterobot-1.0.0.dist-info → easterobot-1.1.1.dist-info}/licenses/LICENSE +0 -0
easterobot/config.py CHANGED
@@ -1,9 +1,12 @@
1
1
  """Main program."""
2
2
 
3
+ import logging
4
+ import logging.config
3
5
  import os
4
6
  import pathlib
5
7
  import random
6
8
  from abc import ABC, abstractmethod
9
+ from argparse import Namespace
7
10
  from collections.abc import Iterable
8
11
  from typing import (
9
12
  Any,
@@ -17,6 +20,7 @@ from typing import (
17
20
 
18
21
  import discord
19
22
  import msgspec
23
+ from alembic.config import Config
20
24
  from typing_extensions import TypeGuard, get_args, get_origin, override
21
25
 
22
26
  RAND = random.SystemRandom()
@@ -24,7 +28,11 @@ RAND = random.SystemRandom()
24
28
  T = TypeVar("T")
25
29
  V = TypeVar("V")
26
30
  Members = Union[discord.Member, list[discord.Member]]
31
+
32
+ HERE = pathlib.Path(__file__).parent.resolve()
27
33
  RESOURCES = pathlib.Path(__file__).parent.resolve() / "resources"
34
+ DEFAULT_CONFIG_PATH = pathlib.Path("config.yml")
35
+ EXAMPLE_CONFIG_PATH = RESOURCES / "config.example.yml"
28
36
 
29
37
 
30
38
  class Serializable(ABC, Generic[V]):
@@ -167,12 +175,14 @@ class MCooldown(msgspec.Struct):
167
175
  class MWeights(msgspec.Struct):
168
176
  egg: float
169
177
  speed: float
178
+ base: float
170
179
 
171
180
 
172
181
  class MHunt(msgspec.Struct):
173
182
  timeout: float
174
183
  cooldown: MCooldown
175
184
  weights: MWeights
185
+ game: float
176
186
 
177
187
 
178
188
  class MCommand(msgspec.Struct):
@@ -219,7 +229,7 @@ class MText(msgspec.Struct):
219
229
  Conjugation = dict[str, MGender]
220
230
 
221
231
 
222
- class MCommands(msgspec.Struct):
232
+ class MCommands(msgspec.Struct, forbid_unknown_fields=True):
223
233
  search: SearchCommand
224
234
  top: MCommand
225
235
  basket: MCommand
@@ -228,6 +238,9 @@ class MCommands(msgspec.Struct):
228
238
  disable: MCommand
229
239
  help: MCommand
230
240
  edit: MCommand
241
+ connect4: MCommand
242
+ tictactoe: MCommand
243
+ rockpaperscissor: MCommand
231
244
 
232
245
  def __getitem__(self, key: str, /) -> MCommand:
233
246
  """Get a command."""
@@ -256,13 +269,20 @@ class MConfig(msgspec.Struct, dict=True):
256
269
  appear: RandomItem[str]
257
270
  action: RandomItem[MText]
258
271
  commands: MCommands
272
+ token: Optional[Union[str, msgspec.UnsetType]] = msgspec.UNSET
259
273
  _resources: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
260
274
  msgspec.field(name="resources", default=msgspec.UNSET)
261
275
  )
262
276
  _working_directory: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
263
277
  msgspec.field(name="working_directory", default=msgspec.UNSET)
264
278
  )
265
- token: Optional[Union[str, msgspec.UnsetType]] = msgspec.UNSET
279
+
280
+ @property
281
+ def database_uri(self) -> str:
282
+ """Get async string for database."""
283
+ return self.database.replace(
284
+ "%(data)s", "/" + self.working_directory.as_posix()
285
+ )
266
286
 
267
287
  def verified_token(self) -> str:
268
288
  """Get the safe token."""
@@ -320,6 +340,36 @@ class MConfig(msgspec.Struct, dict=True):
320
340
  conj.attach(self.conjugation)
321
341
  return conj(member)
322
342
 
343
+ def alembic_config(self, namespace: Optional[Namespace] = None) -> Config:
344
+ """Get alembic config."""
345
+ config_alembic = str(self.resources / "alembic.ini")
346
+ cfg = Config(
347
+ file_=config_alembic,
348
+ ini_section="alembic" if namespace is None else namespace.name,
349
+ cmd_opts=namespace,
350
+ attributes={
351
+ "easterobot_config": self,
352
+ },
353
+ )
354
+ cfg.set_main_option("sqlalchemy.url", self.database_uri)
355
+ cfg.set_main_option("script_location", str(HERE / "alembic"))
356
+ return cfg
357
+
358
+ def configure_logging(self) -> None:
359
+ """Configure logging."""
360
+ if self.use_logging_file and not hasattr(self, "__logging_flag"):
361
+ logging_file = self.resources / "logging.conf"
362
+ defaults = {"data": self.working_directory.as_posix()}
363
+ if not logging_file.is_file():
364
+ error_message = f"Cannot find message: {str(logging_file)!r}"
365
+ raise FileNotFoundError(error_message)
366
+ logging.config.fileConfig(
367
+ logging_file,
368
+ disable_existing_loggers=False,
369
+ defaults=defaults,
370
+ )
371
+ self.__logging_flag = True
372
+
323
373
 
324
374
  def _dec_hook(typ: type[T], obj: Any) -> T:
325
375
  # Get the base type
@@ -371,7 +421,7 @@ def convert(obj: Any, typ: type[T]) -> T:
371
421
  )
372
422
 
373
423
 
374
- def load_config(
424
+ def load_config_from_buffer(
375
425
  data: Union[bytes, str],
376
426
  token: Optional[str] = None,
377
427
  *,
@@ -388,6 +438,20 @@ def load_config(
388
438
  return config
389
439
 
390
440
 
441
+ def load_config_from_path(
442
+ path: Union[str, pathlib.Path],
443
+ token: Optional[str] = None,
444
+ *,
445
+ env: bool = False,
446
+ ) -> MConfig:
447
+ """Load config."""
448
+ path = pathlib.Path(path)
449
+ data = path.read_bytes()
450
+ config = load_config_from_buffer(data, token=token, env=env)
451
+ config.attach_default_working_directory(path.parent)
452
+ return config
453
+
454
+
391
455
  def agree(
392
456
  singular: str,
393
457
  plural: str,
@@ -0,0 +1,14 @@
1
+ """Init package."""
2
+
3
+ from easterobot.bot import Easterobot
4
+ from easterobot.games.game import GameCog
5
+
6
+ __all__ = [
7
+ "GameCog",
8
+ ]
9
+
10
+
11
+ async def setup(bot: Easterobot) -> None:
12
+ game_cog = GameCog(bot)
13
+ bot.game = game_cog
14
+ await bot.add_cog(game_cog)
@@ -0,0 +1,206 @@
1
+ """Connect4 and Connect3."""
2
+
3
+ import datetime
4
+ from functools import partial
5
+ from typing import Optional
6
+
7
+ import discord
8
+ from discord.utils import format_dt
9
+ from typing_extensions import override
10
+
11
+ from easterobot.games.game import Game, Player
12
+
13
+ EMOJIS_MAPPER = {
14
+ "1️⃣": 0,
15
+ "2️⃣": 1,
16
+ "3️⃣": 2,
17
+ "4️⃣": 3,
18
+ "5️⃣": 4,
19
+ "6️⃣": 5,
20
+ "7️⃣": 6,
21
+ "8️⃣": 7,
22
+ "9️⃣": 8,
23
+ "🔟": 9,
24
+ }
25
+ EMOJIS = tuple(EMOJIS_MAPPER)
26
+
27
+
28
+ class Connect(Game):
29
+ def __init__( # noqa: PLR0913
30
+ self,
31
+ player1: discord.Member,
32
+ player2: discord.Member,
33
+ message: discord.Message,
34
+ rows: int,
35
+ cols: int,
36
+ win_count: int,
37
+ ) -> None:
38
+ """Instantiate Connect4."""
39
+ self.grid: list[list[Optional[Player]]] = [
40
+ [None] * rows for _ in range(cols)
41
+ ]
42
+ self.timeout = False
43
+ self.player1 = Player(player1, 1)
44
+ self.player2 = Player(player2, 2)
45
+ self.rows = rows
46
+ self.cols = cols
47
+ self.win_count = win_count
48
+ self.turn = 0
49
+ super().__init__(message)
50
+
51
+ async def on_start(self) -> None:
52
+ """Run."""
53
+ await self.update()
54
+ for emoji in EMOJIS[: self.cols]:
55
+ await self.message.add_reaction(emoji)
56
+ self.start_timer(60)
57
+
58
+ async def update(self) -> None:
59
+ """Update the text."""
60
+ footer = ""
61
+ label = ""
62
+ if not self.terminate:
63
+ label = self.piece(self.current)
64
+ label += f" Joueur actuel : {self.current.member.mention}\n\n"
65
+ player: Optional[Player] = self.current
66
+ elif self.winner:
67
+ forfait = "par forfait " if self.timeout else ""
68
+ footer = f"\n## Gagnant {forfait}{self.winner.mention} 🎉"
69
+ player = self.current
70
+ else:
71
+ footer = (
72
+ f"\n## Égalité entre {self.player1.member.mention} "
73
+ f"et {self.player2.member.mention} 🤝"
74
+ )
75
+ player = None
76
+ content = label
77
+ content += "│".join(EMOJIS[: self.cols])
78
+ content += "\n"
79
+ content += "\n".join(
80
+ "│".join(self.piece(self.grid[y][x]) for y in range(self.cols))
81
+ for x in reversed(range(self.rows))
82
+ )
83
+ content += footer
84
+ now = datetime.datetime.now() + datetime.timedelta(seconds=62) # noqa: DTZ005
85
+ if not self.terminate:
86
+ content += f"\n\nFin du tour {format_dt(now, style='R')}"
87
+ embed = discord.Embed(description=content, color=self.color(player))
88
+ embed.set_author(
89
+ name="Partie terminée" if self.terminate else "Partie en cours",
90
+ icon_url=(
91
+ player.member.display_avatar.url
92
+ if player
93
+ else self.bot.app_emojis["end"].url
94
+ ),
95
+ )
96
+ self.message = await self.message.edit(
97
+ embed=embed,
98
+ content="",
99
+ view=None,
100
+ )
101
+
102
+ async def on_reaction(
103
+ self,
104
+ member_id: int,
105
+ reaction: discord.PartialEmoji,
106
+ ) -> None:
107
+ """Add a reaction to the message."""
108
+ if (
109
+ reaction.name not in EMOJIS[: self.cols]
110
+ or member_id != self.current.member.id
111
+ ):
112
+ return
113
+ await self.place(EMOJIS_MAPPER[reaction.name], self.current)
114
+
115
+ @property
116
+ def current(self) -> Player:
117
+ """Get the current member playing."""
118
+ return [self.player1, self.player2][self.turn % 2]
119
+
120
+ def piece(self, member: Optional[Player]) -> str:
121
+ """Get the current member playing."""
122
+ if member is None:
123
+ return "⚪"
124
+ if member == self.player1:
125
+ return "🔴"
126
+ if member == self.player2:
127
+ return "🟡"
128
+ error_message = f"Invalid member: {member!r}"
129
+ raise ValueError(error_message)
130
+
131
+ def color(self, player: Optional[Player]) -> Optional[discord.Colour]:
132
+ """Get the current player playing."""
133
+ if player is None:
134
+ return discord.Colour.from_str("#d4d5d6") # Grey
135
+ if player == self.player1:
136
+ return discord.Colour.from_str("#ca2a3e") # Red
137
+ if player == self.player2:
138
+ return discord.Colour.from_str("#e9bb51") # Yellow
139
+ error_message = f"Invalid player: {player!r}"
140
+ raise ValueError(error_message)
141
+
142
+ async def place(
143
+ self,
144
+ col: int,
145
+ player: Player,
146
+ ) -> None:
147
+ """Place a jetton."""
148
+ async with self.lock:
149
+ if self.terminate:
150
+ return
151
+ winner = None
152
+ for row in range(self.rows):
153
+ if self.grid[col][row] is None:
154
+ await self.stop_timer()
155
+ self.grid[col][row] = player
156
+ if self._is_winner(col, row, player):
157
+ winner = player
158
+ break
159
+ else:
160
+ return # Can't be placed
161
+ if winner:
162
+ await self.set_winner(player.member)
163
+ elif all( # Draw case
164
+ self.grid[col][-1] is not None for col in range(self.cols)
165
+ ):
166
+ await self.set_winner(None)
167
+ else:
168
+ self.turn += 1
169
+ self.start_timer(60)
170
+ await self.update()
171
+
172
+ @override
173
+ async def on_timeout(self) -> None:
174
+ self.turn += 1
175
+ self.timeout = True
176
+ await self.set_winner(self.current.member)
177
+ await self.update()
178
+
179
+ def _is_winner(self, col: int, row: int, player: Player) -> bool:
180
+ directions = [(1, 0), (0, 1), (1, 1), (1, -1)]
181
+ for dx, dy in directions:
182
+ if (
183
+ self._count_consecutive(col, row, dx, dy, player)
184
+ + self._count_consecutive(col, row, -dx, -dy, player)
185
+ - 1
186
+ ) >= self.win_count:
187
+ return True
188
+ return False
189
+
190
+ def _count_consecutive(
191
+ self, col: int, row: int, dx: int, dy: int, player: Player
192
+ ) -> int:
193
+ count = 0
194
+ c, r = col, row
195
+ while (
196
+ 0 <= c < self.cols
197
+ and 0 <= r < self.rows
198
+ and self.grid[c][r] == player
199
+ ):
200
+ count += 1
201
+ c += dx
202
+ r += dy
203
+ return count
204
+
205
+
206
+ Connect4 = partial(Connect, rows=6, cols=7, win_count=4)
@@ -0,0 +1,262 @@
1
+ """Base class for game."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import datetime
6
+ import logging
7
+ from collections.abc import Coroutine
8
+ from dataclasses import dataclass
9
+ from typing import Any, Callable, Optional
10
+ from uuid import uuid4
11
+
12
+ import discord
13
+ from discord.ext import commands
14
+ from discord.message import convert_emoji_reaction
15
+ from discord.utils import format_dt
16
+
17
+ from easterobot.bot import Easterobot
18
+ from easterobot.commands.base import Context, Interaction, InteractionChannel
19
+ from easterobot.config import RAND
20
+
21
+ logger = logging.getLogger(__name__)
22
+ AsyncCallback = Callable[[], Coroutine[Any, Any, None]]
23
+ Button = discord.ui.Button[discord.ui.View]
24
+
25
+
26
+ class GameError(Exception):
27
+ pass
28
+
29
+
30
+ class InvalidActionError(GameError):
31
+ pass
32
+
33
+
34
+ class InvalidPlayerError(GameError):
35
+ @staticmethod
36
+ def from_player(member: discord.Member) -> "InvalidPlayerError":
37
+ """Create error from a member."""
38
+ return InvalidPlayerError(
39
+ f"Player {member!r} is not included in the game"
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class Player:
45
+ member: discord.Member
46
+ number: int
47
+
48
+
49
+ class Game:
50
+ bot: Easterobot
51
+
52
+ def __init__(self, message: discord.Message) -> None:
53
+ """Instantiate Game."""
54
+ self.id = uuid4()
55
+ self.message = message
56
+ self.terminate = False
57
+ self.winner: Optional[discord.Member] = None
58
+ self.lock = asyncio.Lock()
59
+ self._cleanup: Optional[AsyncCallback] = None
60
+ self._completion: Optional[AsyncCallback] = None
61
+ self._end_event = asyncio.Event()
62
+ self._reset_countdown_event = asyncio.Event()
63
+ self._timeout_task: Optional[asyncio.Task[None]] = None
64
+
65
+ async def set_completion(self, callback: AsyncCallback) -> None:
66
+ """Get the current state for a player."""
67
+ self._completion = callback
68
+
69
+ async def wait_winner(self) -> Optional[discord.Member]:
70
+ """Wait the end of the game."""
71
+ await self._end_event.wait()
72
+ return self.winner
73
+
74
+ async def on_start(self) -> None:
75
+ """Get the current state for a player."""
76
+
77
+ async def on_reaction(
78
+ self,
79
+ member_id: int,
80
+ reaction: discord.PartialEmoji,
81
+ ) -> None:
82
+ """Add a reaction to the message."""
83
+
84
+ async def on_timeout(self) -> None:
85
+ """Can when game timeout."""
86
+
87
+ async def set_winner(self, winner: Optional[discord.Member]) -> None:
88
+ """Remove the game from the manager."""
89
+ self.terminate = True
90
+ self.winner = winner
91
+ if self._cleanup is not None:
92
+ await self._cleanup()
93
+ if self._completion is not None:
94
+ await self._completion()
95
+ self._end_event.set()
96
+
97
+ def start_timer(self, seconds: float) -> str:
98
+ """Start the timer for turn."""
99
+ now = datetime.datetime.now() + datetime.timedelta(seconds=seconds) # noqa: DTZ005
100
+ dt = format_dt(now, style="R")
101
+ self._timeout_task = asyncio.create_task(self._timeout_worker(seconds))
102
+ return dt
103
+
104
+ async def stop_timer(self) -> None:
105
+ """Stop the timer and wait it end."""
106
+ if self._timeout_task:
107
+ self._reset_countdown_event.set()
108
+ await self._timeout_task
109
+ self._timeout_task = None
110
+ self._reset_countdown_event = asyncio.Event()
111
+
112
+ async def _timeout_worker(self, seconds: float) -> None:
113
+ """Timeout action."""
114
+ event = self._reset_countdown_event
115
+ try:
116
+ await asyncio.wait_for(event.wait(), timeout=seconds)
117
+ except asyncio.TimeoutError:
118
+ if not event.is_set():
119
+ async with self.lock:
120
+ await self.on_timeout()
121
+
122
+ def __repr__(self) -> str:
123
+ """Get game representation."""
124
+ return (
125
+ f"<{self.__class__.__qualname__} "
126
+ f"id={self.id!r} message={self.message!r} "
127
+ f"terminate={self.terminate!r} winner={self.winner!r}"
128
+ ">"
129
+ )
130
+
131
+ def __str__(self) -> str:
132
+ """Get game representation."""
133
+ return Game.__repr__(self) # Enforce usage of Game class for __str__
134
+
135
+
136
+ class GameCog(commands.Cog):
137
+ def __init__(self, bot: Easterobot) -> None:
138
+ """Manage all games."""
139
+ self.bot = bot
140
+ self._games: dict[int, Game] = {}
141
+
142
+ async def dual(
143
+ self,
144
+ channel: InteractionChannel,
145
+ reference: discord.Message,
146
+ user1: discord.Member,
147
+ user2: discord.Member,
148
+ ) -> Optional[discord.Member]:
149
+ """Start a dual between two players."""
150
+ from easterobot.games.connect import Connect4
151
+ from easterobot.games.rock_paper_scissor import RockPaperScissor
152
+ from easterobot.games.tic_tac_toe import TicTacToe
153
+
154
+ cls = RAND.choice([Connect4, TicTacToe, RockPaperScissor])
155
+ now = datetime.datetime.now() + datetime.timedelta(seconds=63) # noqa: DTZ005
156
+ dt = format_dt(now, style="R")
157
+ msg = await channel.send(
158
+ f"{user1.mention} et {user2.mention} vont s'affronter {dt} ...",
159
+ reference=reference,
160
+ )
161
+ await asyncio.sleep(63)
162
+ game: Game = cls(user1, user2, msg) # type: ignore[operator]
163
+ await self.run(game)
164
+ return await game.wait_winner()
165
+
166
+ async def run(self, game: Game) -> None:
167
+ """Attach the game to the manager."""
168
+ message_id = game.message.id
169
+
170
+ async def _cleanup() -> None:
171
+ if message_id in self._games:
172
+ del self._games[message_id]
173
+ else:
174
+ logger.warning("Missing game: %s", game)
175
+ await game.message.clear_reactions()
176
+
177
+ self._games[message_id] = game
178
+ game._cleanup = _cleanup # noqa: SLF001
179
+ game.bot = self.bot
180
+ await game.on_start()
181
+
182
+ async def ask_dual(
183
+ self,
184
+ ctx: Context,
185
+ member: discord.Member,
186
+ bet: int,
187
+ ) -> Optional[discord.Message]:
188
+ """Send basic message for initialization."""
189
+ future: asyncio.Future[bool] = asyncio.Future()
190
+ accept = False
191
+
192
+ view = discord.ui.View()
193
+ yes_btn: Button = discord.ui.Button(
194
+ label="Accepter", style=discord.ButtonStyle.green, emoji="⚔️"
195
+ )
196
+ no_btn: Button = discord.ui.Button(
197
+ label="Refuser", style=discord.ButtonStyle.red, emoji="🛡️"
198
+ )
199
+
200
+ async def yes(interaction: Interaction) -> Any:
201
+ if interaction.user.id == member.id:
202
+ future.set_result(True)
203
+ await interaction.response.defer()
204
+
205
+ async def no(interaction: Interaction) -> Any:
206
+ if interaction.user.id == member.id:
207
+ future.set_result(False)
208
+ await interaction.response.defer()
209
+
210
+ yes_btn.callback = yes # type: ignore[method-assign,assignment]
211
+ no_btn.callback = no # type: ignore[method-assign,assignment]
212
+ view.add_item(yes_btn)
213
+ view.add_item(no_btn)
214
+ now = datetime.datetime.now() + datetime.timedelta(seconds=180) # noqa: DTZ005
215
+ dt = format_dt(now, style="R")
216
+ result = await ctx.response.send_message(
217
+ f"{member.mention}, {ctx.user.mention} "
218
+ f"vous demande en duel pour `{bet}` œufs ⚔️"
219
+ f"\nVous devez repondre {dt} !",
220
+ view=view,
221
+ )
222
+ message = result.resource
223
+ if not isinstance(message, discord.Message):
224
+ error_message = f"Invalid kind of message: {message!r}"
225
+ raise TypeError(error_message)
226
+ with contextlib.suppress(asyncio.TimeoutError):
227
+ accept = await asyncio.wait_for(future, timeout=180)
228
+ if not accept:
229
+ await message.edit(
230
+ content=(
231
+ f"{member.mention}, {ctx.user.mention} a refusé le duel 🛡️"
232
+ ),
233
+ view=None,
234
+ )
235
+ return None
236
+ if not isinstance(result.resource, discord.Message):
237
+ error_message = f"Invalid kind of message: {result.resource!r}"
238
+ raise TypeError(error_message)
239
+ return result.resource
240
+
241
+ @commands.Cog.listener()
242
+ async def on_raw_reaction_add(
243
+ self, payload: discord.RawReactionActionEvent
244
+ ) -> None:
245
+ """Handle reaction."""
246
+ if payload.message_author_id is None:
247
+ return
248
+ if self.bot.user and payload.user_id == self.bot.user.id:
249
+ return
250
+ if payload.message_id in self._games:
251
+ # Use connection for faster remove
252
+ emoji = convert_emoji_reaction(payload.emoji)
253
+ game = self._games[payload.message_id]
254
+ await asyncio.gather(
255
+ self.bot._connection.http.remove_reaction( # noqa: SLF001
256
+ payload.channel_id,
257
+ payload.message_id,
258
+ emoji,
259
+ payload.user_id,
260
+ ),
261
+ game.on_reaction(payload.user_id, payload.emoji),
262
+ )