-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbot.py
More file actions
655 lines (564 loc) · 26.7 KB
/
bot.py
File metadata and controls
655 lines (564 loc) · 26.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
"""
bot.py — Bot Discord BotDiff.
Contient :
• Les 4 commandes slash (/track, /untrack, /list, /setup_channel)
• La boucle de tracking (tasks.loop toutes les 2 min)
• La déduplication premade (un seul embed par match partagé)
"""
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
from typing import Any
import discord
from discord import app_commands
from discord.ext import commands, tasks
from database import Database, TrackedPlayer
from embeds import build_history_embed, build_match_embed, build_profile_embed
from embeds.images import build_rank_change_image
from riot_api import RiotAPI, RiotAPIError
logger = logging.getLogger("botdiff.bot")
TIER_ORDER = {
"IRON": 0,
"BRONZE": 1,
"SILVER": 2,
"GOLD": 3,
"PLATINUM": 4,
"EMERALD": 5,
"DIAMOND": 6,
"MASTER": 7,
"GRANDMASTER": 8,
"CHALLENGER": 9,
}
RANK_ORDER = {"IV": 0, "III": 1, "II": 2, "I": 3}
def get_rank_value(tier: str, rank: str) -> int:
return TIER_ORDER.get(tier, 0) * 10 + RANK_ORDER.get(rank, 0)
class BotDiff(commands.Bot):
"""Bot Discord qui surveille les parties League of Legends."""
def __init__(
self,
riot_api: RiotAPI,
db: Database,
platform: str = "euw1",
**kwargs: Any,
) -> None:
intents = discord.Intents.default()
intents.message_content = False
super().__init__(command_prefix="!", intents=intents, **kwargs)
self.riot = riot_api
self.db = db
self.platform = platform
# ── Lifecycle ───────────────────────────────────────────
async def setup_hook(self) -> None:
"""Appelé automatiquement au démarrage. Synchronise les commandes."""
self.tree.add_command(add)
self.tree.add_command(remove)
self.tree.add_command(list_players)
self.tree.add_command(setup_channel)
self.tree.add_command(history)
self.tree.add_command(profile)
self.tree.add_command(test_alert)
await self.tree.sync()
logger.info("Commandes slash synchronisées.")
# Démarre la boucle de tracking.
self.check_matches_loop.start()
async def on_ready(self) -> None:
logger.info("Connecté en tant que %s (ID: %s)", self.user, self.user.id)
async def close(self) -> None:
self.check_matches_loop.cancel()
await self.riot.close()
self.db.close()
await super().close()
# ── Boucle de Tracking ──────────────────────────────────
@tasks.loop(minutes=2)
async def check_matches_loop(self) -> None:
"""Vérifie toutes les 2 min si de nouvelles parties sont terminées."""
logger.debug("Début du cycle de vérification des matchs.")
all_players = self.db.get_all_players()
if not all_players:
return
# Regroupe les joueurs par guild pour traiter chaque serveur.
guilds: dict[int, list[TrackedPlayer]] = defaultdict(list)
for p in all_players:
guilds[p.guild_id].append(p)
for guild_id, players in guilds.items():
channel_id = self.db.get_channel(guild_id)
if channel_id is None:
continue
channel = self.get_channel(channel_id)
if channel is None:
continue
# Collecte les nouveaux matchs par joueur.
# new_matches_map : match_id -> [{riot_id, tag, puuid}, ...]
new_matches_map: dict[str, list[dict[str, str]]] = defaultdict(list)
pending_rank_messages: list[tuple[str, str | None, discord.File | None]] = []
for player in players:
try:
match_ids = await self.riot.get_match_ids(player.puuid, count=5)
except RiotAPIError as exc:
logger.error(
"Erreur API pour %s#%s : %s", player.riot_id, player.tag, exc
)
continue
if not match_ids:
continue
last_known = player.last_match_id
# Initialisation : on enregistre le dernier match sans alerter.
if last_known is None:
self.db.update_last_match_id(player.puuid, guild_id, match_ids[0])
# Initialiser le rang
try:
league_entries = await self.riot.get_league_entries_by_puuid(
self.platform, player.puuid
)
solo_q = next(
(
q
for q in league_entries
if q.get("queueType") == "RANKED_SOLO_5x5"
),
None,
)
if solo_q:
self.db.update_rank(
player.puuid,
guild_id,
solo_q.get("tier", ""),
solo_q.get("rank", ""),
)
except RiotAPIError:
pass
continue
new_matches_found = False
# Identifie les matchs plus récents que le dernier connu.
for mid in match_ids:
if mid == last_known:
break
new_matches_found = True
new_matches_map[mid].append(
{
"riot_id": player.riot_id,
"tag": player.tag,
"puuid": player.puuid,
}
)
if new_matches_found:
# Met à jour le dernier match traité.
self.db.update_last_match_id(player.puuid, guild_id, match_ids[0])
# Vérifie les changements de rang
try:
league_entries = await self.riot.get_league_entries_by_puuid(
self.platform, player.puuid
)
solo_q = next(
(
q
for q in league_entries
if q.get("queueType") == "RANKED_SOLO_5x5"
),
None,
)
if solo_q:
current_tier = solo_q.get("tier", "")
current_rank = solo_q.get("rank", "")
# Si on avait un rang stocké
if player.solo_tier and player.solo_rank:
old_val = get_rank_value(
player.solo_tier, player.solo_rank
)
new_val = get_rank_value(current_tier, current_rank)
tier_changed = player.solo_tier.upper() != current_tier.upper()
if new_val > old_val:
# Rank UP
if tier_changed:
# Changement de tier → embed visuel avec les images de rang
img_buf = build_rank_change_image(
player.solo_tier, current_tier,
player.solo_rank, current_rank,
is_promotion=True,
)
rank_file = (
discord.File(img_buf, filename="rank_change.png")
if img_buf else None
)
msg = (
f"🏆 **{player.riot_id}#{player.tag}** vient de "
f"**MONTER de division** ! "
f"{player.solo_tier.title()} {player.solo_rank} "
f"➔ **{current_tier.title()} {current_rank}** 🎉"
)
pending_rank_messages.append(
(msg, "rank_change.png" if rank_file else None, rank_file)
)
else:
# Même tier, subdivision → message texte léger
msg = (
f"📈 **{player.riot_id}#{player.tag}** a rank up ! "
f"({player.solo_tier.title()} {player.solo_rank} "
f"➔ **{current_tier.title()} {current_rank}**)"
)
pending_rank_messages.append((msg, None, None))
elif new_val < old_val:
# Rank DOWN
if tier_changed:
# Changement de tier → embed visuel avec les images de rang
img_buf = build_rank_change_image(
player.solo_tier, current_tier,
player.solo_rank, current_rank,
is_promotion=False,
)
rank_file = (
discord.File(img_buf, filename="rank_change.png")
if img_buf else None
)
msg = (
f"💀 **{player.riot_id}#{player.tag}** vient de "
f"**DESCENDRE de division**... "
f"Decidement, LoL c'est pas fait pour tout le monde "
f"🥶 Bienvenu en **{current_tier.title()} {current_rank}** !"
)
pending_rank_messages.append(
(msg, "rank_change.png" if rank_file else None, rank_file)
)
else:
# Même tier → message texte troll
msg = (
f"📉 **{player.riot_id}#{player.tag}** a rank down !\n"
f"Decidement, LoL c'est pas fait pour tout le monde "
f"🥶... Bienvenu en **{current_tier.title()} {current_rank}** !"
)
pending_rank_messages.append((msg, None, None))
# Met à jour la DB si le rang a changé ou si c'est la première fois
if (
not player.solo_tier
or player.solo_tier != current_tier
or player.solo_rank != current_rank
):
self.db.update_rank(
player.puuid, guild_id, current_tier, current_rank
)
player.solo_tier = current_tier
player.solo_rank = current_rank
except RiotAPIError as exc:
logger.error(
"Erreur récupération rank pour %s : %s", player.riot_id, exc
)
continue
# Identifie les matchs plus récents que le dernier connu.
for mid in match_ids:
if mid == last_known:
break
new_matches_map[mid].append(
{
"riot_id": player.riot_id,
"tag": player.tag,
"puuid": player.puuid,
}
)
# Met à jour le dernier match traité.
self.db.update_last_match_id(player.puuid, guild_id, match_ids[0])
# Envoie un embed par match unique (déduplication premade).
# Tri chrologique par match_id si le format est Region_Timestamp/ID
ordered_matches = sorted(
new_matches_map.items(),
key=lambda x: int(x[0].split("_")[1]) if "_" in x[0] else 0,
)
for match_id, tracked_in_match in ordered_matches:
try:
match_data = await self.riot.get_match_detail(match_id)
except RiotAPIError as exc:
logger.error(
"Impossible de récupérer le match %s : %s", match_id, exc
)
continue
# Mise à jour du streak pour chaque joueur surveillé dans ce match
for p_dict in tracked_in_match:
player_obj = next(
(p for p in players if p.puuid == p_dict["puuid"]), None
)
if player_obj:
participant = next(
(
x
for x in match_data["info"]["participants"]
if x["puuid"] == player_obj.puuid
),
None,
)
if participant:
is_remake = (
participant.get("gameEndedInEarlySurrender", False)
or match_data["info"].get("gameDuration", 0) < 240
)
if not is_remake:
won = participant["win"]
if won:
player_obj.streak = (
1
if player_obj.streak < 0
else player_obj.streak + 1
)
else:
player_obj.streak = (
-1
if player_obj.streak > 0
else player_obj.streak - 1
)
self.db.update_streak(
player_obj.puuid, guild_id, player_obj.streak
)
p_dict["streak"] = player_obj.streak
embeds, files, view = await build_match_embed(
match_data, tracked_in_match, platform=self.platform
)
# Message de notification.
names = ", ".join(
f"**{p['riot_id']}#{p['tag']}**" for p in tracked_in_match
)
content = f"🎮 {names} vient de terminer une partie !"
try:
await channel.send(
content=content, embeds=embeds, files=files, view=view
) # type: ignore[union-attr]
logger.info(
"Alerte envoyée pour le match %s dans le guild %s.",
match_id,
guild_id,
)
except discord.HTTPException as exc:
logger.error("Impossible d'envoyer le message : %s", exc)
for msg, img_filename, rank_file in pending_rank_messages:
try:
if rank_file is not None and img_filename is not None:
# Changement de tier → embed avec bannière visuelle
embed = discord.Embed(description=msg, color=0xFFD700)
embed.set_image(url=f"attachment://{img_filename}")
await channel.send(embed=embed, file=rank_file) # type: ignore[union-attr]
else:
await channel.send(msg) # type: ignore[union-attr]
except discord.HTTPException as exc:
logger.error("Impossible d'envoyer le message de rank : %s", exc)
@check_matches_loop.before_loop
async def before_check(self) -> None:
"""Attend que le bot soit prêt avant de lancer la boucle."""
await self.wait_until_ready()
# ════════════════════════════════════════════════════════════
# Commandes Slash
# ════════════════════════════════════════════════════════════
@app_commands.command(
name="add", description="Ajouter un joueur LoL à la surveillance."
)
@app_commands.describe(riot_id="Nom Riot du joueur (ex: Faker)", tag="Tagline (ex: T1)")
async def add(interaction: discord.Interaction, riot_id: str, tag: str) -> None:
"""Ajoute un joueur à la base de données en résolvant son PUUID."""
assert interaction.guild is not None
bot: BotDiff = interaction.client # type: ignore[assignment]
await interaction.response.defer(thinking=True)
try:
puuid = await bot.riot.get_puuid(riot_id, tag)
except RiotAPIError as exc:
await interaction.followup.send(
f"❌ Impossible de résoudre **{riot_id}#{tag}** : `{exc}`"
)
return
added = bot.db.add_player(riot_id, tag, puuid, interaction.guild.id)
if added:
await interaction.followup.send(
f"✅ **{riot_id}#{tag}** est maintenant surveillé !\n`PUUID : {puuid[:16]}…`"
)
else:
await interaction.followup.send(
f"⚠️ **{riot_id}#{tag}** est déjà dans la liste de surveillance."
)
@app_commands.command(
name="remove", description="Retirer un joueur de la surveillance."
)
@app_commands.describe(riot_id="Nom Riot du joueur", tag="Tagline")
async def remove(interaction: discord.Interaction, riot_id: str, tag: str) -> None:
"""Retire un joueur de la surveillance."""
assert interaction.guild is not None
bot: BotDiff = interaction.client # type: ignore[assignment]
removed = bot.db.remove_player(riot_id, tag, interaction.guild.id)
if removed:
await interaction.response.send_message(
f"🗑️ **{riot_id}#{tag}** a été retiré de la surveillance."
)
else:
await interaction.response.send_message(
f"⚠️ **{riot_id}#{tag}** n'est pas dans la liste."
)
@app_commands.command(
name="list", description="Afficher la liste des joueurs surveillés."
)
async def list_players(interaction: discord.Interaction) -> None:
"""Affiche tous les joueurs traqués pour ce serveur."""
assert interaction.guild is not None
bot: BotDiff = interaction.client # type: ignore[assignment]
players = bot.db.list_players(interaction.guild.id)
if not players:
await interaction.response.send_message(
"📋 Aucun joueur surveillé pour le moment.\nUtilise `/track` pour en ajouter."
)
return
lines = [f"• **{p.riot_id}#{p.tag}**" for p in players]
embed = discord.Embed(
title="📋 Joueurs surveillés",
description="\n".join(lines),
color=0x3498DB,
)
embed.set_footer(text=f"{len(players)} joueur(s)")
await interaction.response.send_message(embed=embed)
@app_commands.command(
name="setup_channel",
description="Définir ce salon comme canal d'alertes de fin de partie.",
)
async def setup_channel(interaction: discord.Interaction) -> None:
"""Définit le salon actuel comme canal d'alerte."""
assert interaction.guild is not None
bot: BotDiff = interaction.client # type: ignore[assignment]
bot.db.set_channel(interaction.guild.id, interaction.channel_id)
await interaction.response.send_message(
f"📢 Les alertes de fin de partie seront envoyées dans <#{interaction.channel_id}>."
)
@app_commands.command(
name="history",
description="Afficher les 5 dernières parties d'un joueur.",
)
@app_commands.describe(riot_id="Nom Riot du joueur (ex: Faker)", tag="Tagline (ex: T1)")
async def history(interaction: discord.Interaction, riot_id: str, tag: str) -> None:
"""Récupère et affiche les 5 dernières parties d'un joueur."""
bot: BotDiff = interaction.client # type: ignore[assignment]
await interaction.response.defer(thinking=True)
# Résoudre le PUUID.
try:
puuid = await bot.riot.get_puuid(riot_id, tag)
except RiotAPIError as exc:
await interaction.followup.send(
f"❌ Impossible de résoudre **{riot_id}#{tag}** : `{exc}`"
)
return
# Récupérer les 5 derniers match IDs.
try:
match_ids = await bot.riot.get_match_ids(puuid, count=5)
except RiotAPIError as exc:
await interaction.followup.send(
f"❌ Erreur lors de la récupération des matchs : `{exc}`"
)
return
if not match_ids:
await interaction.followup.send(
f"📭 Aucune partie récente trouvée pour **{riot_id}#{tag}**."
)
return
# Récupérer le détail de chaque match en parallèle.
results = await asyncio.gather(
*[bot.riot.get_match_detail(mid) for mid in match_ids],
return_exceptions=True,
)
matches: list[dict] = []
for mid, res in zip(match_ids, results):
if isinstance(res, Exception):
logger.warning("Impossible de récupérer le match %s : %s", mid, res)
else:
matches.append(res)
if not matches:
await interaction.followup.send(
f"❌ Impossible de récupérer les détails des parties pour **{riot_id}#{tag}**."
)
return
embeds, files, view = await build_history_embed(
riot_id, tag, puuid, matches, platform=bot.platform
)
await interaction.followup.send(embeds=embeds, files=files, view=view)
@app_commands.command(
name="profile",
description="Afficher le profil complet et les statistiques d'un joueur.",
)
@app_commands.describe(riot_id="Nom Riot du joueur (ex: Faker)", tag="Tagline (ex: T1)")
async def profile(interaction: discord.Interaction, riot_id: str, tag: str) -> None:
"""Récupère les infos de rang et les stats moyennes d'un joueur."""
bot: BotDiff = interaction.client # type: ignore[assignment]
await interaction.response.defer(thinking=True)
try:
# 1. Résoudre le PUUID
puuid = await bot.riot.get_puuid(riot_id, tag)
# 2. Récupérer les infos Summoner (pour level et icon) et les Match IDs en parallèle
summoner_task = bot.riot.get_summoner_by_puuid(bot.platform, puuid)
match_ids_task = bot.riot.get_match_ids(puuid, count=10)
summoner, match_ids = await asyncio.gather(summoner_task, match_ids_task)
# 3. Récupérer le classement et les détails des matchs en parallèle
league_task = bot.riot.get_league_entries_by_puuid(bot.platform, puuid)
matches_task = asyncio.gather(
*[bot.riot.get_match_detail(mid) for mid in match_ids],
return_exceptions=True,
)
league_entries, matches_results = await asyncio.gather(
league_task, matches_task
)
# Filtrer les matchs valides
valid_matches = [m for m in matches_results if not isinstance(m, Exception)]
# 4. Construire l'embed
embed, files, view = await build_profile_embed(
riot_id,
tag,
summoner,
league_entries,
valid_matches,
puuid,
platform=bot.platform,
)
await interaction.followup.send(embed=embed, files=files, view=view)
except RiotAPIError as exc:
await interaction.followup.send(f"❌ Erreur API Riot : `{exc}`")
except Exception as exc:
logger.exception("Erreur lors de la commande /profile")
await interaction.followup.send(f"❌ Une erreur interne est survenue : `{exc}`")
@app_commands.command(
name="test_alert",
description="Simuler une alerte de fin de partie (pour tester le rendu).",
)
@app_commands.describe(riot_id="Nom Riot du joueur (ex: Faker)", tag="Tagline (ex: T1)")
async def test_alert(interaction: discord.Interaction, riot_id: str, tag: str) -> None:
"""Récupère la dernière partie d'un joueur et envoie l'alerte comme si elle venait d'être détectée."""
bot: BotDiff = interaction.client # type: ignore[assignment]
await interaction.response.defer(thinking=True)
# Résoudre le PUUID.
try:
puuid = await bot.riot.get_puuid(riot_id, tag)
except RiotAPIError as exc:
await interaction.followup.send(
f"❌ Impossible de résoudre **{riot_id}#{tag}** : `{exc}`"
)
return
# Récupérer le dernier match ID.
try:
match_ids = await bot.riot.get_match_ids(puuid, count=1)
except RiotAPIError as exc:
await interaction.followup.send(
f"❌ Erreur lors de la récupération des matchs : `{exc}`"
)
return
if not match_ids:
await interaction.followup.send(
f"📭 Aucune partie récente pour **{riot_id}#{tag}**."
)
return
# Récupérer le détail du match.
try:
match_data = await bot.riot.get_match_detail(match_ids[0])
except RiotAPIError as exc:
await interaction.followup.send(
f"❌ Impossible de récupérer le match : `{exc}`"
)
return
tracked_info = [{"riot_id": riot_id, "tag": tag, "puuid": puuid}]
embeds, files, view = await build_match_embed(
match_data, tracked_info, platform=bot.platform
)
content = f"🎮 **{riot_id}#{tag}** vient de terminer une partie !"
await interaction.followup.send(
content=content,
embeds=embeds,
files=files,
view=view,
)