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.
- easterobot/__init__.py +19 -0
- easterobot/__main__.py +6 -0
- easterobot/bot.py +584 -0
- easterobot/cli.py +127 -0
- easterobot/commands/__init__.py +28 -0
- easterobot/commands/base.py +171 -0
- easterobot/commands/basket.py +99 -0
- easterobot/commands/disable.py +29 -0
- easterobot/commands/edit.py +68 -0
- easterobot/commands/enable.py +35 -0
- easterobot/commands/help.py +33 -0
- easterobot/commands/reset.py +121 -0
- easterobot/commands/search.py +127 -0
- easterobot/commands/top.py +105 -0
- easterobot/config.py +401 -0
- easterobot/info.py +18 -0
- easterobot/logger.py +16 -0
- easterobot/models.py +58 -0
- easterobot/py.typed +1 -0
- easterobot/resources/config.example.yml +226 -0
- easterobot/resources/credits.txt +1 -0
- easterobot/resources/eggs/egg_01.png +0 -0
- easterobot/resources/eggs/egg_02.png +0 -0
- easterobot/resources/eggs/egg_03.png +0 -0
- easterobot/resources/eggs/egg_04.png +0 -0
- easterobot/resources/eggs/egg_05.png +0 -0
- easterobot/resources/eggs/egg_06.png +0 -0
- easterobot/resources/eggs/egg_07.png +0 -0
- easterobot/resources/eggs/egg_08.png +0 -0
- easterobot/resources/eggs/egg_09.png +0 -0
- easterobot/resources/eggs/egg_10.png +0 -0
- easterobot/resources/eggs/egg_11.png +0 -0
- easterobot/resources/eggs/egg_12.png +0 -0
- easterobot/resources/eggs/egg_13.png +0 -0
- easterobot/resources/eggs/egg_14.png +0 -0
- easterobot/resources/eggs/egg_15.png +0 -0
- easterobot/resources/eggs/egg_16.png +0 -0
- easterobot/resources/eggs/egg_17.png +0 -0
- easterobot/resources/eggs/egg_18.png +0 -0
- easterobot/resources/eggs/egg_19.png +0 -0
- easterobot/resources/eggs/egg_20.png +0 -0
- easterobot/resources/logging.conf +47 -0
- easterobot/resources/logo.png +0 -0
- easterobot-1.0.0.dist-info/METADATA +242 -0
- easterobot-1.0.0.dist-info/RECORD +48 -0
- easterobot-1.0.0.dist-info/WHEEL +4 -0
- easterobot-1.0.0.dist-info/entry_points.txt +2 -0
- 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
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
|