easterobot 1.0.0__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 (48) hide show
  1. easterobot/__init__.py +19 -0
  2. easterobot/__main__.py +6 -0
  3. easterobot/bot.py +584 -0
  4. easterobot/cli.py +127 -0
  5. easterobot/commands/__init__.py +28 -0
  6. easterobot/commands/base.py +171 -0
  7. easterobot/commands/basket.py +99 -0
  8. easterobot/commands/disable.py +29 -0
  9. easterobot/commands/edit.py +68 -0
  10. easterobot/commands/enable.py +35 -0
  11. easterobot/commands/help.py +33 -0
  12. easterobot/commands/reset.py +121 -0
  13. easterobot/commands/search.py +127 -0
  14. easterobot/commands/top.py +105 -0
  15. easterobot/config.py +401 -0
  16. easterobot/info.py +18 -0
  17. easterobot/logger.py +16 -0
  18. easterobot/models.py +58 -0
  19. easterobot/py.typed +1 -0
  20. easterobot/resources/config.example.yml +226 -0
  21. easterobot/resources/credits.txt +1 -0
  22. easterobot/resources/eggs/egg_01.png +0 -0
  23. easterobot/resources/eggs/egg_02.png +0 -0
  24. easterobot/resources/eggs/egg_03.png +0 -0
  25. easterobot/resources/eggs/egg_04.png +0 -0
  26. easterobot/resources/eggs/egg_05.png +0 -0
  27. easterobot/resources/eggs/egg_06.png +0 -0
  28. easterobot/resources/eggs/egg_07.png +0 -0
  29. easterobot/resources/eggs/egg_08.png +0 -0
  30. easterobot/resources/eggs/egg_09.png +0 -0
  31. easterobot/resources/eggs/egg_10.png +0 -0
  32. easterobot/resources/eggs/egg_11.png +0 -0
  33. easterobot/resources/eggs/egg_12.png +0 -0
  34. easterobot/resources/eggs/egg_13.png +0 -0
  35. easterobot/resources/eggs/egg_14.png +0 -0
  36. easterobot/resources/eggs/egg_15.png +0 -0
  37. easterobot/resources/eggs/egg_16.png +0 -0
  38. easterobot/resources/eggs/egg_17.png +0 -0
  39. easterobot/resources/eggs/egg_18.png +0 -0
  40. easterobot/resources/eggs/egg_19.png +0 -0
  41. easterobot/resources/eggs/egg_20.png +0 -0
  42. easterobot/resources/logging.conf +47 -0
  43. easterobot/resources/logo.png +0 -0
  44. easterobot-1.0.0.dist-info/METADATA +242 -0
  45. easterobot-1.0.0.dist-info/RECORD +48 -0
  46. easterobot-1.0.0.dist-info/WHEEL +4 -0
  47. easterobot-1.0.0.dist-info/entry_points.txt +2 -0
  48. easterobot-1.0.0.dist-info/licenses/LICENSE +21 -0
easterobot/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Main module."""
2
+
3
+ from .bot import Easterobot
4
+ from .cli import entrypoint
5
+ from .info import (
6
+ __author__,
7
+ __email__,
8
+ __summary__,
9
+ __version__,
10
+ )
11
+
12
+ __all__ = [
13
+ "Easterobot",
14
+ "__author__",
15
+ "__email__",
16
+ "__summary__",
17
+ "__version__",
18
+ "entrypoint",
19
+ ]
easterobot/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entrypoint with `python -m easterobot`."""
2
+
3
+ from .cli import entrypoint
4
+
5
+ if __name__ == "__main__":
6
+ entrypoint() # pragma: no cover
easterobot/bot.py ADDED
@@ -0,0 +1,584 @@
1
+ """Main program."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import logging.config
6
+ import pathlib
7
+ import shutil
8
+ import time
9
+ from collections.abc import Awaitable
10
+ from datetime import datetime, timezone
11
+ from getpass import getpass
12
+ from pathlib import Path
13
+ from typing import (
14
+ Any,
15
+ Callable,
16
+ Optional,
17
+ TypeVar,
18
+ Union,
19
+ )
20
+
21
+ import discord
22
+ import discord.app_commands
23
+ import discord.ext.commands
24
+ from sqlalchemy import and_, func, select
25
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
26
+ from sqlalchemy.sql.expression import Select
27
+
28
+ from .config import (
29
+ RAND,
30
+ RESOURCES,
31
+ MConfig,
32
+ RandomItem,
33
+ agree,
34
+ dump_yaml,
35
+ load_config,
36
+ )
37
+ from .models import Base, Egg, Hunt
38
+
39
+ T = TypeVar("T")
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ HERE = Path(__file__).parent
44
+
45
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
46
+
47
+ DEFAULT_CONFIG_PATH = pathlib.Path("config.yml")
48
+ EXAMPLE_CONFIG_PATH = RESOURCES / "config.example.yml"
49
+ RANK_MEDAL = {1: "🥇", 2: "🥈", 3: "🥉"}
50
+ INTENTS = discord.Intents.all()
51
+
52
+
53
+ class Easterobot(discord.ext.commands.Bot):
54
+ owner: discord.User
55
+
56
+ def __init__(self, config: MConfig) -> None:
57
+ """Initialise Easterbot."""
58
+ super().__init__(
59
+ command_prefix=".",
60
+ description="Bot discord pour faire la chasse aux œufs",
61
+ activity=discord.Game(name="rechercher des œufs"),
62
+ intents=INTENTS,
63
+ )
64
+ self.config = config
65
+ defaults = {"data": self.config.working_directory.as_posix()}
66
+ if self.config.use_logging_file:
67
+ logging_file = self.config.resources / "logging.conf"
68
+ if not logging_file.is_file():
69
+ error_message = f"Cannot find message: {str(logging_file)!r}"
70
+ raise FileNotFoundError(error_message)
71
+ logging.config.fileConfig(
72
+ logging_file,
73
+ disable_existing_loggers=False,
74
+ defaults=defaults,
75
+ )
76
+ self.app_commands: list[discord.app_commands.AppCommand] = []
77
+ self.app_emojis: RandomItem[discord.Emoji] = RandomItem([])
78
+ database_uri = self.config.database.replace(
79
+ "%(data)s", "/" + self.config.working_directory.as_posix()
80
+ )
81
+ logger.info("Open database %s", database_uri)
82
+ self.engine = create_async_engine(database_uri, echo=False)
83
+
84
+ @classmethod
85
+ def from_config(
86
+ cls,
87
+ config_path: Union[str, Path] = DEFAULT_CONFIG_PATH,
88
+ *,
89
+ token: Optional[str] = None,
90
+ env: bool = False,
91
+ ) -> "Easterobot":
92
+ """Instantiate Easterobot from config."""
93
+ path = pathlib.Path(config_path)
94
+ data = pathlib.Path(path).read_bytes()
95
+ config = load_config(data, token=token, env=env)
96
+ config.attach_default_working_directory(path.parent)
97
+ return Easterobot(config)
98
+
99
+ @classmethod
100
+ def generate(
101
+ cls,
102
+ destination: Union[Path, str],
103
+ *,
104
+ token: Optional[str],
105
+ env: bool,
106
+ interactive: bool,
107
+ ) -> "Easterobot":
108
+ """Generate all data."""
109
+ destination = Path(destination).resolve()
110
+ destination.mkdir(parents=True, exist_ok=True)
111
+ config_data = EXAMPLE_CONFIG_PATH.read_bytes()
112
+ config = load_config(config_data, token=token, env=env)
113
+ config.attach_default_working_directory(destination)
114
+ if interactive:
115
+ while True:
116
+ try:
117
+ config.verified_token()
118
+ break
119
+ except (ValueError, TypeError):
120
+ config.token = getpass("Token: ")
121
+ config._resources = pathlib.Path("resources") # noqa: SLF001
122
+ shutil.copytree(
123
+ RESOURCES, destination / "resources", dirs_exist_ok=True
124
+ )
125
+ config_path = destination / "config.yml"
126
+ config_path.write_bytes(dump_yaml(config))
127
+ (destination / ".gitignore").write_bytes(b"*\n")
128
+ return Easterobot(config)
129
+
130
+ # Method that loads cogs
131
+ async def setup_hook(self) -> None:
132
+ """Setup hooks."""
133
+ await self.load_extension(
134
+ "easterobot.commands", package="easterobot.commands.__init__"
135
+ )
136
+
137
+ def auto_run(self) -> None:
138
+ """Run the bot with the given token."""
139
+ self.run(token=self.config.verified_token())
140
+
141
+ async def on_ready(self) -> None:
142
+ """Handle ready event, can be trigger many time if disconnected."""
143
+ # Sync bot commands
144
+ logger.info("Syncing command")
145
+ await self.tree.sync()
146
+ self.app_commands = await self.tree.fetch_commands()
147
+
148
+ # Sync bot owner
149
+ app_info = await self.application_info()
150
+ self.owner = app_info.owner
151
+ logger.info("Owner is %s (%s)", self.owner.display_name, self.owner.id)
152
+
153
+ # Sync emojis
154
+ emojis = {
155
+ emoji.name: emoji
156
+ for emoji in await self.fetch_application_emojis()
157
+ }
158
+ eggs_path = (self.config.resources / "eggs").resolve()
159
+ self.app_emojis = RandomItem([])
160
+ for egg in eggs_path.iterdir():
161
+ if egg.stem not in emojis:
162
+ logger.info(
163
+ "Missing emoji %s, create emoji on application",
164
+ egg.stem,
165
+ )
166
+ image_data = egg.read_bytes()
167
+ emoji = await self.create_application_emoji(
168
+ name=egg.stem,
169
+ image=image_data,
170
+ )
171
+ self.app_emojis.choices.append(emoji)
172
+ else:
173
+ logger.info("Load emoji %s", egg.stem)
174
+ self.app_emojis.choices.append(emojis[egg.stem])
175
+
176
+ # Create the tables
177
+ async with self.engine.begin() as session:
178
+ await session.run_sync(Base.metadata.create_all, checkfirst=True)
179
+
180
+ # Log all available guilds
181
+ async for guild in self.fetch_guilds():
182
+ logger.info("Guild %s (%s)", guild, guild.id)
183
+ logger.info(
184
+ "Logged on as %s (%s) !",
185
+ self.user,
186
+ getattr(self.user, "id", "unknown"),
187
+ )
188
+ pending_hunts: set[asyncio.Task[Any]] = set()
189
+ while True:
190
+ if pending_hunts:
191
+ try:
192
+ _, pending_hunts = await asyncio.wait(
193
+ pending_hunts, timeout=1
194
+ )
195
+ except Exception as err: # noqa: BLE001
196
+ logger.critical("Unattended exception", exc_info=err)
197
+ await asyncio.sleep(5)
198
+ pending_hunts.add(asyncio.create_task(self.loop_hunt()))
199
+
200
+ async def start_hunt( # noqa: C901, PLR0912, PLR0915
201
+ self,
202
+ hunt_id: int,
203
+ description: str,
204
+ *,
205
+ member_id: Optional[int] = None,
206
+ send_method: Optional[
207
+ Callable[..., Awaitable[discord.Message]]
208
+ ] = None,
209
+ ) -> None:
210
+ """Start an hunt in a channel."""
211
+ # Get the hunt channel of resolve it
212
+ channel = self.get_channel(hunt_id)
213
+ if channel is None:
214
+ channel = await self.fetch_channel(hunt_id)
215
+ if not isinstance(channel, discord.TextChannel):
216
+ logger.warning("Invalid channel type %s", channel)
217
+ return
218
+
219
+ # Get from config
220
+ action = self.config.action.rand()
221
+ guild = channel.guild
222
+ emoji = self.app_emojis.rand()
223
+
224
+ # Label and hunters
225
+ hunters: list[discord.Member] = []
226
+ label = action.text
227
+ if member_id is not None:
228
+ member = channel.guild.get_member(member_id)
229
+ if member:
230
+ hunters.append(member)
231
+ label += " (1)"
232
+
233
+ # Start hunt
234
+ logger.info("Start hunt in %s", channel.jump_url)
235
+ timeout = self.config.hunt.timeout + 1
236
+ view = discord.ui.View(timeout=timeout)
237
+ button: discord.ui.Button[Any] = discord.ui.Button(
238
+ label=label,
239
+ style=discord.ButtonStyle.primary,
240
+ emoji=emoji,
241
+ )
242
+ view.add_item(button)
243
+ waiting = False
244
+ active = False
245
+
246
+ async def button_callback(
247
+ interaction: discord.Interaction[Any],
248
+ ) -> None:
249
+ nonlocal waiting, active
250
+ # Respond later
251
+ await interaction.response.defer()
252
+ message = interaction.message
253
+ user = interaction.user
254
+ if (
255
+ message is None # Message must be loaded
256
+ or not isinstance(user, discord.Member) # Must be a member
257
+ ):
258
+ logger.warning("Invalid callback for %s", guild)
259
+ return
260
+ # Check if user doesn't already claim the egg
261
+ for hunter in hunters:
262
+ if hunter.id == user.id:
263
+ logger.info(
264
+ "Already hunt by %s (%s) on %s",
265
+ user,
266
+ user.id,
267
+ message.jump_url,
268
+ )
269
+ return
270
+
271
+ # Add the user to the current users
272
+ hunters.append(user)
273
+
274
+ # Show information about the hunter
275
+ logger.info(
276
+ "Hunt (%d) by %s (%s) on %s",
277
+ len(hunters),
278
+ user,
279
+ user.id,
280
+ message.jump_url,
281
+ )
282
+
283
+ # TODO(dashstrom): must refactor this lock ?
284
+ if active and not waiting:
285
+ waiting = True
286
+ while active: # noqa: ASYNC110
287
+ await asyncio.sleep(0.01)
288
+ waiting = False
289
+
290
+ active = True
291
+ button.label = action.text + f" ({len(hunters)})"
292
+ await message.edit(view=view)
293
+ active = False
294
+
295
+ # Set the button callback
296
+ button.callback = button_callback # type: ignore[method-assign]
297
+
298
+ # Set next hunt
299
+ next_hunt = time.time() + timeout
300
+
301
+ # Create and embed
302
+ emb = embed(
303
+ title="Un œuf a été découvert !",
304
+ description=description
305
+ + f"\n\nTirage du vinqueur : <t:{next_hunt:.0f}:R>",
306
+ thumbnail=emoji.url,
307
+ )
308
+
309
+ # Send the embed in the hunt channel
310
+ if send_method is None:
311
+ message = await channel.send(embed=emb, view=view)
312
+ else:
313
+ message = await send_method(embed=emb, view=view)
314
+
315
+ # TODO(dashstrom): channel is wrong due to the send message !
316
+ # Wait the end of the hunt
317
+ message_url = f"{channel.jump_url}/{message.id}"
318
+ async with channel.typing():
319
+ try:
320
+ await asyncio.wait_for(
321
+ view.wait(), timeout=self.config.hunt.timeout
322
+ )
323
+ except asyncio.TimeoutError:
324
+ logger.info("End hunt for %s", message_url)
325
+
326
+ # Disable button and view after hunt
327
+ button.disabled = True
328
+ view.stop()
329
+ await message.edit(view=view) # Send the stop info
330
+
331
+ # Get if hunt is valid
332
+ async with AsyncSession(self.engine) as session:
333
+ has_hunt = await session.scalar(
334
+ select(Hunt).where(
335
+ and_(
336
+ Hunt.guild_id == guild.id,
337
+ Hunt.channel_id == channel.id,
338
+ )
339
+ )
340
+ )
341
+
342
+ # The egg was not collected
343
+ if not hunters or not has_hunt:
344
+ button.label = "L'œuf n'a pas été ramassé"
345
+ button.style = discord.ButtonStyle.danger
346
+ logger.info("No Hunter for %s", message_url)
347
+
348
+ # Process the winner
349
+ else:
350
+ async with AsyncSession(self.engine) as session:
351
+ # Get the count of egg by user
352
+ res = await session.execute(
353
+ select(Egg.user_id, func.count().label("count"))
354
+ .where(
355
+ and_(
356
+ Egg.guild_id == guild.id,
357
+ Egg.user_id.in_(hunter.id for hunter in hunters),
358
+ )
359
+ )
360
+ .group_by(Egg.user_id)
361
+ )
362
+ eggs: dict[int, int] = dict(res.all()) # type: ignore[arg-type]
363
+ logger.info("Winner draw for %s", message_url)
364
+
365
+ # If only one hunter, give the egg to him
366
+ if len(hunters) == 1:
367
+ winner = hunters[0]
368
+ loser = None
369
+ logger.info("100%% - %s (%s)", winner, winner.id)
370
+ else:
371
+ lh = len(hunters)
372
+ min_eggs = min(eggs.values(), default=0)
373
+ max_eggs = max(eggs.values(), default=0)
374
+ diff_eggs = max_eggs - min_eggs
375
+ w_egg = self.config.hunt.weights.egg
376
+ w_speed = self.config.hunt.weights.speed
377
+ weights = []
378
+
379
+ # Compute chances of each hunters
380
+ for i, h in enumerate(hunters, start=1):
381
+ if diff_eggs != 0:
382
+ egg = eggs.get(h.id, 0) - min_eggs
383
+ p_egg = (1 - egg / diff_eggs) * w_egg + 1 - w_egg
384
+ else:
385
+ p_egg = 1.0
386
+ if lh != 0:
387
+ p_speed = (1 - i / lh) * w_speed + 1 - w_speed
388
+ else:
389
+ p_speed = 1.0
390
+ weights.append(p_egg * p_speed)
391
+ r = sum(weights)
392
+ chances = [(h, p / r) for h, p in zip(hunters, weights)]
393
+ for h, c in chances:
394
+ logger.info("%.2f%% - %s (%s)", c * 100, h, h.id)
395
+
396
+ # Get the winner
397
+ n = RAND.random()
398
+ for h, p in chances:
399
+ if n < p:
400
+ winner = h
401
+ break
402
+ n -= p
403
+ else:
404
+ winner = hunters[-1]
405
+
406
+ # Get a random loser
407
+ hunters.remove(winner)
408
+ loser = RAND.choice(hunters)
409
+
410
+ # Add the egg to the member
411
+ session.add(
412
+ Egg(
413
+ channel_id=channel.id,
414
+ guild_id=channel.guild.id,
415
+ user_id=winner.id,
416
+ emoji_id=emoji.id,
417
+ )
418
+ )
419
+ await session.commit()
420
+
421
+ # Show the embed to loser
422
+ if loser:
423
+ loser_name = loser.display_name
424
+ if len(hunters) == 1:
425
+ text = f"{loser_name} rate un œuf"
426
+ else:
427
+ text = agree(
428
+ "{1} et {0} autre chasseur ratent un œuf",
429
+ "{1} et {0} autres chasseurs ratent un œuf",
430
+ len(hunters) - 1,
431
+ loser_name,
432
+ )
433
+ emb = embed(
434
+ title=text,
435
+ description=action.fail.text(loser),
436
+ image=action.fail.gif,
437
+ )
438
+ await channel.send(embed=emb, reference=message)
439
+
440
+ # Send embed for the winner
441
+ winner_eggs = eggs.get(winner.id, 0) + 1
442
+ emb = embed(
443
+ title=f"{winner.display_name} récupère un œuf",
444
+ description=action.success.text(winner),
445
+ image=action.success.gif,
446
+ thumbnail=emoji.url,
447
+ egg_count=winner_eggs,
448
+ )
449
+ await channel.send(embed=emb, reference=message)
450
+
451
+ # Update button
452
+ button.label = f"L'œuf a été ramassé par {winner.display_name}"
453
+ button.style = discord.ButtonStyle.success
454
+ logger.info(
455
+ "Winner is %s (%s) with %s",
456
+ winner,
457
+ winner.id,
458
+ agree("{0} egg", "{0} eggs", winner_eggs),
459
+ )
460
+
461
+ # Remove emoji and edit view
462
+ button.emoji = None
463
+ await message.edit(view=view)
464
+
465
+ async def loop_hunt(self) -> None:
466
+ """Manage the schedule of run."""
467
+ # Create a async session
468
+ async with AsyncSession(
469
+ self.engine, expire_on_commit=False
470
+ ) as session:
471
+ # Find hunt with next egg available
472
+ now = time.time()
473
+ hunts = (
474
+ await session.scalars(select(Hunt).where(Hunt.next_egg <= now))
475
+ ).all()
476
+
477
+ # For each hunt, set the next run and store the channel ids
478
+ if hunts:
479
+ for hunt in hunts:
480
+ next_egg = now + self.config.hunt.cooldown.rand()
481
+ dt_next = datetime.fromtimestamp(next_egg, tz=timezone.utc)
482
+ logger.info(
483
+ "Next hunt at %s on %s",
484
+ hunt.jump_url,
485
+ dt_next.strftime(DATE_FORMAT),
486
+ )
487
+ hunt.next_egg = next_egg
488
+ await session.commit()
489
+ hunt_ids = [hunt.channel_id for hunt in hunts]
490
+
491
+ # Call start_hunt for each hunt
492
+ if hunt_ids:
493
+ try:
494
+ await asyncio.gather(
495
+ *[
496
+ self.start_hunt(hunt_id, self.config.appear.rand())
497
+ for hunt_id in hunt_ids
498
+ ]
499
+ )
500
+ except Exception as err:
501
+ logger.exception(
502
+ "An error occurred during start hunt", exc_info=err
503
+ )
504
+
505
+ async def get_rank(
506
+ self,
507
+ session: AsyncSession,
508
+ guild_id: int,
509
+ user_id: int,
510
+ ) -> Optional[tuple[int, str, int]]:
511
+ """Get the rank of single user."""
512
+ query = _prepare_rank(guild_id)
513
+ subq = query.subquery()
514
+ select(subq).where(subq.c.user_id == user_id)
515
+ ranks = await _compute_rank(session, query)
516
+ return ranks[0] if ranks else None
517
+
518
+ async def get_ranks(
519
+ self,
520
+ session: AsyncSession,
521
+ guild_id: int,
522
+ limit: Optional[int] = None,
523
+ page: Optional[int] = None,
524
+ ) -> list[tuple[int, str, int]]:
525
+ """Get ranks by page."""
526
+ query = _prepare_rank(guild_id)
527
+ if limit is not None:
528
+ query = query.limit(limit)
529
+ if page is not None:
530
+ query = query.offset(page * limit)
531
+ return await _compute_rank(session, query)
532
+
533
+
534
+ def _prepare_rank(guild_id: int) -> Select[Any]:
535
+ """Create a select query with order user by egg count."""
536
+ return (
537
+ select(
538
+ Egg.user_id,
539
+ func.rank().over(order_by=func.count().desc()).label("row"),
540
+ func.count().label("count"),
541
+ )
542
+ .where(Egg.guild_id == guild_id)
543
+ .group_by(Egg.user_id)
544
+ .order_by(func.count().desc())
545
+ )
546
+
547
+
548
+ async def _compute_rank(
549
+ session: AsyncSession, query: Select[Any]
550
+ ) -> list[tuple[int, str, int]]:
551
+ res = await session.execute(query)
552
+ return [
553
+ (member_id, RANK_MEDAL.get(rank, f"`#{rank}`"), egg_count)
554
+ for member_id, rank, egg_count in res.all()
555
+ ]
556
+
557
+
558
+ def embed( # noqa: PLR0913
559
+ *,
560
+ title: str,
561
+ description: Optional[str] = None,
562
+ image: Optional[str] = None,
563
+ thumbnail: Optional[str] = None,
564
+ egg_count: Optional[int] = None,
565
+ footer: Optional[str] = None,
566
+ ) -> discord.Embed:
567
+ """Create an embed with default format."""
568
+ new_embed = discord.Embed(
569
+ title=title,
570
+ description=description,
571
+ colour=RAND.randint(0, 1 << 24),
572
+ type="gifv" if image else "rich",
573
+ )
574
+ if image is not None:
575
+ new_embed.set_image(url=image)
576
+ if thumbnail is not None:
577
+ new_embed.set_thumbnail(url=thumbnail)
578
+ if egg_count is not None:
579
+ footer = (footer + " - ") if footer else ""
580
+ footer += "Cela lui fait un total de "
581
+ footer += agree("{0} œuf", "{0} œufs", egg_count)
582
+ if footer:
583
+ new_embed.set_footer(text=footer)
584
+ return new_embed