easterobot 1.1.1__py3-none-any.whl → 1.3.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.
easterobot/bot.py CHANGED
@@ -1,7 +1,6 @@
1
1
  """Main program."""
2
2
 
3
3
  import logging
4
- import logging.config
5
4
  import pathlib
6
5
  import shutil
7
6
  from getpass import getpass
@@ -37,7 +36,8 @@ from .config import (
37
36
  T = TypeVar("T")
38
37
 
39
38
  logger = logging.getLogger(__name__)
40
- INTENTS = discord.Intents.all()
39
+ INTENTS = discord.Intents.default()
40
+ INTENTS.message_content = True
41
41
 
42
42
 
43
43
  class Easterobot(discord.ext.commands.Bot):
@@ -47,15 +47,24 @@ class Easterobot(discord.ext.commands.Bot):
47
47
 
48
48
  def __init__(self, config: MConfig) -> None:
49
49
  """Initialise Easterbot."""
50
+ # Initialize intents
51
+ intents = discord.Intents.default()
52
+ if config.message_content:
53
+ intents.message_content = True
54
+
55
+ # Remove warning about VoiceClient
56
+ discord.VoiceClient.warn_nacl = False
57
+
58
+ # Initialize Bot
50
59
  super().__init__(
51
60
  command_prefix=".",
52
61
  description="Bot discord pour faire la chasse aux œufs",
53
62
  activity=discord.Game(name="rechercher des œufs"),
54
63
  intents=INTENTS,
55
64
  )
56
- self.config = config
57
65
 
58
66
  # Attributes
67
+ self.config = config
59
68
  self.app_commands: list[discord.app_commands.AppCommand] = []
60
69
  self.app_emojis: dict[str, discord.Emoji] = {}
61
70
 
@@ -89,9 +98,9 @@ class Easterobot(discord.ext.commands.Bot):
89
98
  cls,
90
99
  destination: Union[Path, str],
91
100
  *,
92
- token: Optional[str],
93
- env: bool,
94
- interactive: bool,
101
+ token: Optional[str] = None,
102
+ env: bool = False,
103
+ interactive: bool = False,
95
104
  ) -> "Easterobot":
96
105
  """Generate all data."""
97
106
  destination = Path(destination).resolve()
@@ -12,6 +12,7 @@ from easterobot.commands.game import (
12
12
  tictactoe_command,
13
13
  )
14
14
  from easterobot.commands.help import help_command
15
+ from easterobot.commands.info import info_command
15
16
  from easterobot.commands.reset import reset_command
16
17
  from easterobot.commands.search import search_command
17
18
  from easterobot.commands.top import top_command
@@ -24,6 +25,7 @@ __all__ = [
24
25
  "egg_command_group",
25
26
  "enable_command",
26
27
  "help_command",
28
+ "info_command",
27
29
  "reset_command",
28
30
  "rockpaperscissor_command",
29
31
  "search_command",
@@ -33,4 +35,5 @@ __all__ = [
33
35
 
34
36
 
35
37
  async def setup(bot: Easterobot) -> None:
38
+ egg_command_group.name = bot.config.group
36
39
  bot.tree.add_command(egg_command_group)
@@ -170,7 +170,10 @@ def controlled_command( # noqa: C901, PLR0915
170
170
  try:
171
171
  await f(cast(Context, interaction), *args, **kwargs)
172
172
  except InterruptedCommandError:
173
- logger.exception("InterruptedCommandError occur")
173
+ logger.warning(
174
+ "InterruptedCommandError occur for %s",
175
+ event_repr,
176
+ )
174
177
  async with (
175
178
  AsyncSession(interaction.client.engine) as session,
176
179
  cooldown_lock,
@@ -9,7 +9,8 @@ from .base import Context, controlled_command, egg_command_group
9
9
 
10
10
 
11
11
  @egg_command_group.command(
12
- name="disable", description="Désactiver la chasse aux œufs dans le salon"
12
+ name="disable",
13
+ description="Désactiver la chasse aux œufs dans le salon",
13
14
  )
14
15
  @controlled_command(cooldown=True, manage_channels=True)
15
16
  async def disable_command(ctx: Context) -> None:
@@ -13,7 +13,8 @@ from .base import Context, controlled_command, egg_command_group
13
13
 
14
14
 
15
15
  @egg_command_group.command(
16
- name="edit", description="Editer le nombre d'œufs d'un membre"
16
+ name="edit",
17
+ description="Editer le nombre d'œufs d'un membre",
17
18
  )
18
19
  @controlled_command(cooldown=True, administrator=True)
19
20
  async def edit_command(
@@ -9,7 +9,8 @@ from .base import Context, controlled_command, egg_command_group
9
9
 
10
10
 
11
11
  @egg_command_group.command(
12
- name="enable", description="Activer la chasse dans le salon"
12
+ name="enable",
13
+ description="Activer la chasse dans le salon",
13
14
  )
14
15
  @controlled_command(
15
16
  cooldown=True,
@@ -130,25 +130,28 @@ async def game_dual( # noqa: C901, D103, PLR0912
130
130
  await session.commit()
131
131
 
132
132
  # Play the game
133
- game = cls(ctx.user, member, msg)
134
- await ctx.client.game.run(game)
135
- winner = await game.wait_winner()
136
-
137
- # Give eggs to the winner or remove previous one
138
- async with lock:
139
- for e in e1:
140
- e.lock = False
141
- if winner:
142
- e.user_id = winner.id
143
- for e in e2:
144
- e.lock = False
145
- if winner:
146
- e.user_id = winner.id
147
- await session.commit()
133
+ winner = None
134
+ try:
135
+ game = cls(ctx.user, member, msg)
136
+ await ctx.client.game.run(game)
137
+ winner = await game.wait_winner()
138
+ finally:
139
+ # Give eggs to the winner or remove previous one
140
+ async with lock:
141
+ for e in e1:
142
+ e.lock = False
143
+ if winner:
144
+ e.user_id = winner.id
145
+ for e in e2:
146
+ e.lock = False
147
+ if winner:
148
+ e.user_id = winner.id
149
+ await session.commit()
148
150
 
149
151
 
150
152
  @egg_command_group.command(
151
- name="connect4", description="Lancer une partie de puissance 4."
153
+ name="connect4",
154
+ description="Lancer une partie de puissance 4",
152
155
  )
153
156
  @controlled_command(cooldown=True, channel_permissions={"send_messages": True})
154
157
  async def connect4_command(
@@ -161,12 +164,13 @@ async def connect4_command(
161
164
 
162
165
 
163
166
  @egg_command_group.command(
164
- name="tictactoe", description="Lancer une partie de morpion."
167
+ name="tictactoe",
168
+ description="Lancer une partie de morpion",
165
169
  )
166
- @controlled_command(cooldown=True)
170
+ @controlled_command(cooldown=True, channel_permissions={"send_messages": True})
167
171
  async def tictactoe_command(
168
172
  ctx: Context,
169
- member: discord.Member,
173
+ member: Optional[discord.Member] = None,
170
174
  bet: app_commands.Range[int, 0] = 0,
171
175
  ) -> None:
172
176
  """Run a tictactoe."""
@@ -175,12 +179,12 @@ async def tictactoe_command(
175
179
 
176
180
  @egg_command_group.command(
177
181
  name="rockpaperscissor",
178
- description="Lancer une partie de pierre papier ciseaux.",
182
+ description="Lancer une partie de pierre papier ciseaux",
179
183
  )
180
- @controlled_command(cooldown=True)
184
+ @controlled_command(cooldown=True, channel_permissions={"send_messages": True})
181
185
  async def rockpaperscissor_command(
182
186
  ctx: Context,
183
- member: discord.Member,
187
+ member: Optional[discord.Member] = None,
184
188
  bet: app_commands.Range[int, 0] = 0,
185
189
  ) -> None:
186
190
  """Run a rockpaperscissor."""
@@ -7,8 +7,11 @@ from easterobot.hunts.hunt import embed
7
7
  from .base import Context, controlled_command, egg_command_group
8
8
 
9
9
 
10
- @egg_command_group.command(name="help", description="Obtenir de l'aide")
11
- @controlled_command(cooldown=True)
10
+ @egg_command_group.command(
11
+ name="help",
12
+ description="Obtenir l'aide des commandes",
13
+ )
14
+ @controlled_command(cooldown=True, channel_permissions={"send_messages": True})
12
15
  async def help_command(ctx: Context) -> None:
13
16
  """Help command."""
14
17
  emb = embed(
@@ -30,4 +33,4 @@ async def help_command(ctx: Context) -> None:
30
33
  value=f"{option.description}",
31
34
  inline=False,
32
35
  )
33
- await ctx.response.send_message(embed=emb, ephemeral=True)
36
+ await ctx.response.send_message(embed=emb)
@@ -0,0 +1,61 @@
1
+ """Command basket."""
2
+
3
+ import asyncio
4
+ from typing import Optional
5
+
6
+ import discord
7
+ from discord import app_commands
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from easterobot.commands.base import (
11
+ Context,
12
+ controlled_command,
13
+ egg_command_group,
14
+ )
15
+ from easterobot.hunts.hunt import embed
16
+ from easterobot.hunts.rank import Ranking
17
+
18
+
19
+ @egg_command_group.command(
20
+ name="info",
21
+ description="Avoir des informations sur la chance d'un joueur",
22
+ )
23
+ @app_commands.describe(
24
+ user="Joueur a inspecter",
25
+ )
26
+ @controlled_command(cooldown=True)
27
+ async def info_command(
28
+ ctx: Context, user: Optional[discord.Member] = None
29
+ ) -> None:
30
+ """Show current user basket."""
31
+ # Delay the response
32
+ await ctx.response.defer(ephemeral=True)
33
+
34
+ # Set the user of the basket
35
+ hunter = user or ctx.user
36
+
37
+ async with AsyncSession(ctx.client.engine) as session:
38
+ ranking, member_luck = await asyncio.gather(
39
+ Ranking.from_guild(session, ctx.guild_id),
40
+ ctx.client.hunt.get_luck(
41
+ session=session,
42
+ guild_id=hunter.guild.id,
43
+ user_id=hunter.id,
44
+ sleep_hours=False,
45
+ ),
46
+ )
47
+ hunter_rank = ranking.get(hunter.id)
48
+
49
+ await ctx.followup.send(
50
+ embed=embed(
51
+ title=f"Informations sur {hunter.display_name}",
52
+ description=(
53
+ f"Classement : {hunter_rank.badge}\n"
54
+ f"Nombre d'oeufs : `{hunter_rank.eggs}`\n"
55
+ f"Chance brute : `{member_luck.luck:.0%}`\n"
56
+ f"Chance de trouver un oeuf : `{member_luck.discovered:.0%}`\n"
57
+ f"Chance de se faire voler : `{member_luck.spotted:.0%}`"
58
+ ),
59
+ ),
60
+ ephemeral=True,
61
+ )
@@ -14,7 +14,8 @@ from .base import Context, Interaction, controlled_command, egg_command_group
14
14
 
15
15
 
16
16
  @egg_command_group.command(
17
- name="reset", description="Réinitialiser la chasse aux œufs"
17
+ name="reset",
18
+ description="Réinitialiser la chasse aux œufs",
18
19
  )
19
20
  @controlled_command(cooldown=True, administrator=True)
20
21
  async def reset_command(ctx: Context) -> None:
@@ -4,10 +4,10 @@ import logging
4
4
  from typing import Any
5
5
 
6
6
  import discord
7
- from sqlalchemy import and_, func, select
7
+ from sqlalchemy import select
8
8
  from sqlalchemy.ext.asyncio import AsyncSession
9
9
 
10
- from easterobot.config import RAND, agree
10
+ from easterobot.config import agree
11
11
  from easterobot.hunts.hunt import embed
12
12
  from easterobot.models import Egg, Hunt
13
13
 
@@ -21,7 +21,10 @@ from .base import (
21
21
  logger = logging.getLogger("easterobot")
22
22
 
23
23
 
24
- @egg_command_group.command(name="search", description="Rechercher un œuf")
24
+ @egg_command_group.command(
25
+ name="search",
26
+ description="Rechercher un œuf",
27
+ )
25
28
  @controlled_command(cooldown=True, channel_permissions={"send_messages": True})
26
29
  async def search_command(ctx: Context) -> None:
27
30
  """Search command."""
@@ -34,7 +37,7 @@ async def search_command(ctx: Context) -> None:
34
37
  "La chasse aux œufs n'est pas activée dans ce salon",
35
38
  ephemeral=True,
36
39
  )
37
- return
40
+ raise InterruptedCommandError
38
41
  try:
39
42
  await ctx.response.defer(ephemeral=False)
40
43
  except discord.errors.NotFound as err:
@@ -42,39 +45,15 @@ async def search_command(ctx: Context) -> None:
42
45
  name = ctx.user.display_name
43
46
 
44
47
  async with AsyncSession(ctx.client.engine) as session:
45
- egg_max = await session.scalar(
46
- select(
47
- func.count().label("max"),
48
- )
49
- .where(Egg.guild_id == ctx.guild_id)
50
- .group_by(Egg.user_id)
51
- .order_by(func.count().label("max").desc())
52
- .limit(1)
53
- )
54
- egg_max = egg_max or 0
55
- egg_count = await session.scalar(
56
- select(func.count().label("count")).where(
57
- and_(
58
- Egg.guild_id == ctx.guild.id,
59
- Egg.user_id == ctx.user.id,
60
- )
61
- )
48
+ luck = await ctx.client.hunt.get_luck(
49
+ guild_id=ctx.guild_id,
50
+ user_id=ctx.user.id,
51
+ session=session,
52
+ sleep_hours=ctx.client.config.in_sleep_hours(),
62
53
  )
63
- if egg_count is None:
64
- egg_count = 0
65
- ratio = egg_count / egg_max if egg_max != 0 else 1.0
66
-
67
- discovered = ctx.client.config.commands.search.discovered
68
- prob_d = (discovered.max - discovered.min) * (1 - ratio) + discovered.min
69
54
 
70
- sample_d = RAND.random()
71
- if prob_d > sample_d or egg_count < discovered.shield:
72
- sample_s = RAND.random()
73
- spotted = ctx.client.config.commands.search.spotted
74
- prob_s = (spotted.max - spotted.min) * ratio + spotted.min
75
- logger.info("discovered: %.2f > %.2f", prob_d, sample_d)
76
- if prob_s > sample_s and egg_count > spotted.shield:
77
- logger.info("spotted: %.2f > %.2f", prob_s, sample_s)
55
+ if luck.sample_discovered():
56
+ if luck.sample_spotted():
78
57
 
79
58
  async def send_method(
80
59
  *args: Any, **kwargs: Any
@@ -88,7 +67,6 @@ async def search_command(ctx: Context) -> None:
88
67
  send_method=send_method,
89
68
  )
90
69
  else:
91
- logger.info("found: %.2f > %.2f", prob_s, sample_s)
92
70
  emoji = ctx.client.egg_emotes.rand()
93
71
  async with AsyncSession(ctx.client.engine) as session:
94
72
  session.add(
@@ -105,7 +83,7 @@ async def search_command(ctx: Context) -> None:
105
83
  "%s (%s) got an egg for a total %s in %s",
106
84
  ctx.user,
107
85
  ctx.user.id,
108
- agree("{0} egg", "{0} eggs", egg_count),
86
+ agree("{0} egg", "{0} eggs", luck.egg_count),
109
87
  ctx.channel.jump_url,
110
88
  )
111
89
  await ctx.followup.send(
@@ -113,15 +91,14 @@ async def search_command(ctx: Context) -> None:
113
91
  title=f"{name} récupère un œuf",
114
92
  description=ctx.client.config.hidden(ctx.user),
115
93
  thumbnail=emoji.url,
116
- egg_count=egg_count + 1,
94
+ egg_count=luck.egg_count + 1,
117
95
  )
118
96
  )
119
97
  else:
120
- logger.info("failed: %.2f > %.2f", prob_d, sample_d)
121
98
  await ctx.followup.send(
122
99
  embed=embed(
123
100
  title=f"{name} repart bredouille",
124
101
  description=ctx.client.config.failed(ctx.user),
125
- egg_count=egg_count,
102
+ egg_count=luck.egg_count,
126
103
  )
127
104
  )
easterobot/config.py CHANGED
@@ -5,9 +5,11 @@ import logging.config
5
5
  import os
6
6
  import pathlib
7
7
  import random
8
+ import re
8
9
  from abc import ABC, abstractmethod
9
10
  from argparse import Namespace
10
11
  from collections.abc import Iterable
12
+ from datetime import datetime, time, timezone
11
13
  from typing import (
12
14
  Any,
13
15
  Generic,
@@ -30,7 +32,7 @@ V = TypeVar("V")
30
32
  Members = Union[discord.Member, list[discord.Member]]
31
33
 
32
34
  HERE = pathlib.Path(__file__).parent.resolve()
33
- RESOURCES = pathlib.Path(__file__).parent.resolve() / "resources"
35
+ RESOURCES = HERE / "resources"
34
36
  DEFAULT_CONFIG_PATH = pathlib.Path("config.yml")
35
37
  EXAMPLE_CONFIG_PATH = RESOURCES / "config.example.yml"
36
38
 
@@ -80,7 +82,7 @@ class ConjugableText(Serializable[str]):
80
82
  def gender(member: discord.Member) -> Literal["man", "woman"]:
81
83
  """Get the gender of a people."""
82
84
  if any(
83
- marker in role.name.casefold()
85
+ marker in tokenize(role.name)
84
86
  for role in member.roles
85
87
  for marker in (
86
88
  "woman",
@@ -88,6 +90,7 @@ class ConjugableText(Serializable[str]):
88
90
  "femme",
89
91
  "fille",
90
92
  "elle",
93
+ "elles",
91
94
  "her",
92
95
  "she",
93
96
  )
@@ -163,6 +166,14 @@ class RandomConjugableText(RandomItem[ConjugableText]):
163
166
  return cls(convert(obj, typ=list[ConjugableText]))
164
167
 
165
168
 
169
+ class MSleep(msgspec.Struct):
170
+ start: time
171
+ end: time
172
+ divide_hunt: float
173
+ divide_discovered: float
174
+ divide_spotted: float
175
+
176
+
166
177
  class MCooldown(msgspec.Struct):
167
178
  min: float
168
179
  max: float
@@ -194,12 +205,20 @@ class MDiscovered(msgspec.Struct):
194
205
  min: float
195
206
  max: float
196
207
 
208
+ def probability(self, luck: float) -> float:
209
+ """Get discover probability."""
210
+ return (self.max - self.min) * luck + self.min
211
+
197
212
 
198
213
  class MSpotted(msgspec.Struct):
199
214
  shield: int
200
215
  min: float
201
216
  max: float
202
217
 
218
+ def probability(self, luck: float) -> float:
219
+ """Get discover probability."""
220
+ return (self.max - self.min) * (1 - luck) + self.min
221
+
203
222
 
204
223
  class SearchCommand(MCommand):
205
224
  discovered: MDiscovered
@@ -239,6 +258,7 @@ class MCommands(msgspec.Struct, forbid_unknown_fields=True):
239
258
  help: MCommand
240
259
  edit: MCommand
241
260
  connect4: MCommand
261
+ info: MCommand
242
262
  tictactoe: MCommand
243
263
  rockpaperscissor: MCommand
244
264
 
@@ -269,6 +289,16 @@ class MConfig(msgspec.Struct, dict=True):
269
289
  appear: RandomItem[str]
270
290
  action: RandomItem[MText]
271
291
  commands: MCommands
292
+ sleep: MSleep = msgspec.field(
293
+ default_factory=lambda: MSleep(
294
+ start=time(hour=23),
295
+ end=time(hour=9),
296
+ divide_hunt=2.0,
297
+ divide_discovered=2.0,
298
+ divide_spotted=1.5,
299
+ )
300
+ )
301
+ message_content: bool = True
272
302
  token: Optional[Union[str, msgspec.UnsetType]] = msgspec.UNSET
273
303
  _resources: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
274
304
  msgspec.field(name="resources", default=msgspec.UNSET)
@@ -284,6 +314,17 @@ class MConfig(msgspec.Struct, dict=True):
284
314
  "%(data)s", "/" + self.working_directory.as_posix()
285
315
  )
286
316
 
317
+ def in_sleep_hours(self) -> bool:
318
+ """Get if bot is currently in sleep mode."""
319
+ hour = datetime.now(tz=timezone.utc).time()
320
+ if self.sleep.start < self.sleep.end:
321
+ if self.sleep.start < hour < self.sleep.end:
322
+ return True
323
+ elif self.sleep.start > self.sleep.end: # noqa: SIM102
324
+ if not self.sleep.start < hour < self.sleep.end:
325
+ return True
326
+ return False
327
+
287
328
  def verified_token(self) -> str:
288
329
  """Get the safe token."""
289
330
  if self.token is None or self.token is msgspec.UNSET:
@@ -370,6 +411,14 @@ class MConfig(msgspec.Struct, dict=True):
370
411
  )
371
412
  self.__logging_flag = True
372
413
 
414
+ def __str__(self) -> str:
415
+ """Represent the Configuration."""
416
+ return f"<Config {str(self.working_directory)!r}>"
417
+
418
+ def __repr__(self) -> str:
419
+ """Represent the Configuration."""
420
+ return f"<Config {str(self.working_directory)!r}>"
421
+
373
422
 
374
423
  def _dec_hook(typ: type[T], obj: Any) -> T:
375
424
  # Get the base type
@@ -463,3 +512,24 @@ def agree(
463
512
  if amount is None or amount in (-1, 0, 1):
464
513
  return singular.format(amount, *args)
465
514
  return plural.format(amount, *args)
515
+
516
+
517
+ RE_VALID = re.compile(r"[^a-zA-Z0-9éàèê]")
518
+
519
+
520
+ def tokenize(text: str) -> list[str]:
521
+ """Get token from text.
522
+
523
+ Examples:
524
+ >>> tokenize("Activités manuelles")
525
+ ['activités', 'manuelles']
526
+ >>> tokenize("Elle")
527
+ ['elle']
528
+ >>> tokenize("Iel/Iels")
529
+ ['iel', 'iels']
530
+ >>> tokenize("🦉 Elle")
531
+ ['elle']
532
+ """
533
+ text = text.casefold()
534
+ text = RE_VALID.sub(" ", text)
535
+ return text.strip().split()
@@ -1,14 +1,13 @@
1
1
  """Connect4 and Connect3."""
2
2
 
3
- import datetime
4
3
  from functools import partial
5
4
  from typing import Optional
6
5
 
7
6
  import discord
8
- from discord.utils import format_dt
9
7
  from typing_extensions import override
10
8
 
11
9
  from easterobot.games.game import Game, Player
10
+ from easterobot.utils import in_seconds
12
11
 
13
12
  EMOJIS_MAPPER = {
14
13
  "1️⃣": 0,
@@ -51,9 +50,9 @@ class Connect(Game):
51
50
  async def on_start(self) -> None:
52
51
  """Run."""
53
52
  await self.update()
53
+ await self.start_timer(61)
54
54
  for emoji in EMOJIS[: self.cols]:
55
55
  await self.message.add_reaction(emoji)
56
- self.start_timer(60)
57
56
 
58
57
  async def update(self) -> None:
59
58
  """Update the text."""
@@ -81,9 +80,8 @@ class Connect(Game):
81
80
  for x in reversed(range(self.rows))
82
81
  )
83
82
  content += footer
84
- now = datetime.datetime.now() + datetime.timedelta(seconds=62) # noqa: DTZ005
85
83
  if not self.terminate:
86
- content += f"\n\nFin du tour {format_dt(now, style='R')}"
84
+ content += f"\n\nFin du tour {in_seconds(61)}"
87
85
  embed = discord.Embed(description=content, color=self.color(player))
88
86
  embed.set_author(
89
87
  name="Partie terminée" if self.terminate else "Partie en cours",
@@ -95,7 +93,10 @@ class Connect(Game):
95
93
  )
96
94
  self.message = await self.message.edit(
97
95
  embed=embed,
98
- content="",
96
+ content=(
97
+ f"-# {self.player1.member.mention} "
98
+ f"{self.player2.member.mention}"
99
+ ),
99
100
  view=None,
100
101
  )
101
102
 
@@ -166,7 +167,7 @@ class Connect(Game):
166
167
  await self.set_winner(None)
167
168
  else:
168
169
  self.turn += 1
169
- self.start_timer(60)
170
+ await self.start_timer(61)
170
171
  await self.update()
171
172
 
172
173
  @override