Compare commits

...

12 commits
dev ... main

Author SHA1 Message Date
LE BERRE Mickael
6621599fc6 i18n: translate all French comments, docstrings and logs to English
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 29s
discord.py's built-in !help command exposes cog docstrings directly to
Discord members — leaving them in French made the output partially French.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:56:36 +02:00
LE BERRE Mickael
ca8d761293 docs: update CLAUDE.md community description to TEA rebrand
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:25:09 +02:00
LE BERRE Mickael
5cfa8db270 docs: explicit comment on StrictHostKeyChecking=no in deploy workflow
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 26s
Runner Alpine stateless, pas de known_hosts persistant.
Cible fixe sur LAN interne (192.168.1.208) — risque MITM inexistant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:17:30 +02:00
LE BERRE Mickael
20b86144b4 fix: mystats username lookup and multi-word name support
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 28s
- Résolution du nom via find_user_by_name avant get_user_all_pbs
  (l'ancien code passait le nom en guise de discord_id → aucun résultat)
- Affichage du nom de la cible dans le titre de l'embed
- * pour capturer les noms avec espaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:06:59 +02:00
LE BERRE Mickael
57cd2517ad fix: support multi-word usernames in pb lookup commands
Sans le *, discord.py tronquait les noms avec espaces au premier mot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:06:59 +02:00
LE BERRE Mickael
cebd1f2e0a fix: replace str | None with Optional[str] for Python 3.9 compat
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 16s
str | None union syntax requires Python 3.10+, bot runs on 3.9-slim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:47:20 +02:00
LE BERRE Mickael
acabc7d409 feat: rebrand RTF→TEA, clan detection via Discord roles
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 26s
Replaces display-name prefix parsing ([RTF]/[RTFC]/[RTFR]) with
Discord role-based clan detection (TEAI/TEAF/TEAC/TEACO).

- config: new CLAN_CONFIG with 4 TEA clans, CLAN_ROLE_IDS, CLAN_MIGRATION
- helpers: get_user_clan() replaced by get_clan_from_member()
- DatabaseManager: adds clan column on startup, auto-migrates existing
  records from old username prefixes, filters leaderboard by clan column
- pb_handler: detects clan from roles on submission, passes it to DB
- leaderboard_handler: reads clan from DB column instead of username
- top10: new commands !teai*/!teaf*/!teac*/!teaco* (removes !rtf*)
- guide: updated command list and bot title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 10:38:01 +02:00
0cc1a4139d chore: add .gitignore (was only on dev)
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 23s
2026-04-30 16:54:05 +02:00
49ffd17528 chore: remove old GitHub Actions workflow
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 26s
2026-04-30 16:47:49 +02:00
0c46ef5a81 docs: update CLAUDE.md with CI/CD lessons, update README deployment section
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 28s
2026-04-30 16:46:57 +02:00
66cabe2fec fix: exclude __pycache__ and .github from rsync
All checks were successful
Deploy Bot on NAS / deploy (push) Successful in 24s
2026-04-30 16:44:36 +02:00
cf6f68c31c fix: sync CI/CD fixes from dev to main
Some checks failed
Deploy Bot on NAS / deploy (push) Failing after 2s
2026-04-30 16:42:57 +02:00
21 changed files with 1236 additions and 1219 deletions

View file

@ -8,8 +8,13 @@ jobs:
deploy: deploy:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- name: Install dependencies
run: apk add --no-cache rsync openssh-client
- name: Checkout - name: Checkout
uses: actions/checkout@v4 run: |
git clone --depth 1 --branch ${{ github.ref_name }} \
${{ github.server_url }}/${{ github.repository }}.git .
- name: Set deployment path - name: Set deployment path
run: | run: |
@ -23,29 +28,29 @@ jobs:
- name: Configure SSH - name: Configure SSH
run: | run: |
mkdir -p ~/.ssh mkdir -p /root/.ssh
echo "${{ secrets.NAS_SSH_KEY }}" > ~/.ssh/id_deploy echo "${{ secrets.NAS_SSH_KEY }}" > /root/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy chmod 600 /root/.ssh/id_deploy
cat >> ~/.ssh/config << 'EOF'
Host nas
HostName 192.168.1.208
User Elewyn
IdentityFile ~/.ssh/id_deploy
StrictHostKeyChecking no
EOF
- name: Sync files to NAS - name: Sync files to NAS
run: | run: |
# StrictHostKeyChecking=no : runner Alpine stateless, pas de known_hosts persistant.
# Cible fixe sur LAN interne (192.168.1.208) — risque MITM inexistant.
rsync -av --delete \ rsync -av --delete \
-e "ssh -i /root/.ssh/id_deploy -o StrictHostKeyChecking=no" \
--exclude='.git' \ --exclude='.git' \
--exclude='.github' \
--exclude='.env' \ --exclude='.env' \
--exclude='data/' \ --exclude='data/' \
--exclude='screenshots/' \ --exclude='screenshots/' \
--exclude='logs/' \ --exclude='logs/' \
./ nas:${{ env.DEPLOY_PATH }}/ --exclude='__pycache__/' \
./ Elewyn@192.168.1.208:${{ env.DEPLOY_PATH }}/
- name: Restart bot on NAS - name: Restart bot on NAS
run: | run: |
ssh nas "cd ${{ env.DEPLOY_PATH }} && \ # StrictHostKeyChecking=no : runner Alpine stateless, pas de known_hosts persistant.
docker compose down || true && \ # Cible fixe sur LAN interne (192.168.1.208) — risque MITM inexistant.
docker compose up --build -d" ssh -i /root/.ssh/id_deploy -o StrictHostKeyChecking=no \
Elewyn@192.168.1.208 \
"cd ${{ env.DEPLOY_PATH }} && /share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin/docker compose down || true && /share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin/docker compose up -d"

View file

@ -1,45 +0,0 @@
name: Deploy Bot on NAS
on:
push:
branches: [ main, dev ]
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Set deployment path
id: set-path
run: |
if [ "${GITHUB_REF_NAME}" = "main" ]; then
echo "DEPLOY_PATH=/share/CACHEDEV1_DATA/discord-bot-prod" >> $GITHUB_ENV
elif [ "${GITHUB_REF_NAME}" = "dev" ]; then
echo "DEPLOY_PATH=/share/CACHEDEV1_DATA/discord-bot-dev" >> $GITHUB_ENV
else
echo "Unsupported branch"
exit 1
fi
- name: Update bot files
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd $DEPLOY_PATH
git config --global --add safe.directory $DEPLOY_PATH
if [ ! -d ".git" ]; then
git init
git remote add origin https://x-access-token:$GITHUB_TOKEN@github.com/ArcElewyn/Discord.git
else
git remote remove origin || true
git remote add origin https://x-access-token:$GITHUB_TOKEN@github.com/ArcElewyn/Discord.git
fi
git fetch origin $GITHUB_REF_NAME
git reset --hard origin/$GITHUB_REF_NAME
- name: Restart bot
run: |
cd $DEPLOY_PATH
docker compose -f $DEPLOY_PATH/docker-compose.yml down || true
docker compose -f $DEPLOY_PATH/docker-compose.yml up --build -d

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.env
data/
screenshots/
logs/
__pycache__/
*.pyc
*.pyo
*.db

View file

@ -3,7 +3,7 @@
## Contexte ## Contexte
Bot Discord de tracking de Personal Best (PB) pour les clan bosses du jeu Raid Shadow Legends. Bot Discord de tracking de Personal Best (PB) pour les clan bosses du jeu Raid Shadow Legends.
Communauté RTF (3 clans : RTF, RTFC, RTFR). Communauté TEA — The Ember Accord (4 clans : TEAI, TEAF, TEAC, TEACO).
## Branches ## Branches
@ -12,13 +12,19 @@ Communauté RTF (3 clans : RTF, RTFC, RTFR).
| `main` | Production | `/share/CACHEDEV1_DATA/discord-bot-prod` | | `main` | Production | `/share/CACHEDEV1_DATA/discord-bot-prod` |
| `dev` | Développement | `/share/CACHEDEV1_DATA/discord-bot-dev` | | `dev` | Développement | `/share/CACHEDEV1_DATA/discord-bot-dev` |
`dev` est une branche permanente — ne jamais merger dans `main`.
## Déploiement ## Déploiement
CI/CD via Forgejo Actions → runner `vm-runner` (192.168.1.53) → rsync vers NAS QNAP (192.168.1.208). CI/CD via Forgejo Actions → runner `vm-runner` (192.168.1.53, Alpine) → rsync vers NAS QNAP (192.168.1.208).
Push sur `main` ou `dev` déclenche le déploiement automatique. Push sur `main` ou `dev` déclenche le déploiement automatique.
Secret requis dans Forgejo : `NAS_SSH_KEY` (clé privée ed25519 pour SSH Elewyn@NAS). Secret requis dans Forgejo : `NAS_SSH_KEY`**doit être encodé en base64** :
```bash
base64 -w 0 ~/.ssh/runner-nas
```
Coller le résultat dans le secret (pas le contenu brut du fichier).
## Persistance des données (NE PAS écraser) ## Persistance des données (NE PAS écraser)
@ -50,14 +56,28 @@ Dockerfile # Image Python 3.9-slim
docker-compose.yml # Déploiement container docker-compose.yml # Déploiement container
``` ```
## Langue du code
Tout le code source doit être **exclusivement en anglais** :
- Docstrings et commentaires
- Messages Discord affichés aux utilisateurs
- Logs (`print`, `logging`)
Le `!help` de discord.py affiche les docstrings des cogs directement — les laisser en français les rend visibles aux membres.
## Boss supportés ## Boss supportés
- **Hydra** : normal, hard, brutal, nightmare - **Hydra** : normal, hard, brutal, nightmare
- **Chimera** : easy, normal, hard, brutal, nightmare, ultra - **Chimera** : easy, normal, hard, brutal, nightmare, ultra
- **CvC** : Clan vs Clan - **CvC** : Clan vs Clan
## Pièges connus ## Pièges connus — CI/CD
- Le `.env` ne doit jamais être commité — il contient le token Discord - **NAS_SSH_KEY doit être en base64**`echo "..." > fichier` dans Alpine sh casse les sauts de ligne de la clé brute
- `data/`, `screenshots/`, `logs/` sont gitignorés — données persistantes sur le NAS - **Runner tourne dans Alpine**`actions/checkout@v4` ne fonctionne pas (pas de Node.js) → utiliser `git clone`; `rsync` et `openssh-client` doivent être installés via `apk add`
- Le rsync exclut ces dossiers pour ne pas écraser les données en prod - **Chemin Docker sur QNAP** : `/share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin/docker``docker` n'est pas dans le `$PATH` SSH
- **Pas de `--build`** — le code est monté en volume (`./:/app`), un simple redémarrage suffit
- **Pas de `container_name`** — les envs prod et dev partagent le même NAS, un nom hardcodé crée un conflit ; Docker Compose dérive le nom depuis le dossier
- **rsync exclut `__pycache__/`** — créés par Docker (root), l'user Elewyn ne peut pas les supprimer via rsync `--delete`
- **rsync exclut `.github/`** — vestige de l'ancien CI GitHub Actions, ne pas envoyer sur le NAS
- **Clé SSH workstation** : `~/.ssh/runner-nas` (ed25519, autorisée dans `~/.ssh/authorized_keys` sur le NAS pour l'user Elewyn)

View file

@ -107,8 +107,6 @@ CMD ["python", "bot.py"]
### 4. Create `docker-compose.yml` ### 4. Create `docker-compose.yml`
```yaml ```yaml
version: '3.8'
services: services:
discord-bot: discord-bot:
build: . build: .
@ -116,14 +114,12 @@ services:
env_file: env_file:
- .env - .env
volumes: volumes:
- ./:/app
- ./screenshots:/app/screenshots - ./screenshots:/app/screenshots
- ./bot_data.db:/app/bot_data.db - ./data:/app/data
- ./logs:/app/logs - ./logs:/app/logs
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
container_name: rtf-discord-bot
# Optional: Resource limits
deploy: deploy:
resources: resources:
limits: limits:
@ -163,36 +159,31 @@ Add Reactions
URL : `https://discord.com/api/oauth2/authorize?client_id=YOUR_BOT_ID&permissions=378944&scope=bot` URL : `https://discord.com/api/oauth2/authorize?client_id=YOUR_BOT_ID&permissions=378944&scope=bot`
## 🚀 Déploiement sur QNAP ## 🚀 Déploiement
### Via Container Station (Recommandé) Le déploiement est automatisé via Forgejo Actions.
1. Install Container Station via App Center Un push sur `main` déploie en production, un push sur `dev` déploie en développement.
2. Create /share/Container/discord-bot/
3. Copy all files into this folder
4. Edit .env with your tokens
5. Container Station → "Create" → "Create Application via docker-compose"
6. Select your docker-compose.yml
7. Start the container
### Via SSH (Alternative) ### Prérequis (une seule fois)
1. Créer le `.env` sur le NAS dans chaque `DEPLOY_PATH` :
```env
DISCORD_TOKEN=your_bot_token_here
AUTHORIZED_CHANNEL_ID=your_channel_id_here
```
2. Ajouter le secret `NAS_SSH_KEY` dans Forgejo (clé ed25519 encodée en base64) :
```bash
base64 -w 0 ~/.ssh/runner-nas
```
### Démarrage initial (première fois sur le NAS)
```bash ```bash
# Connect to QNAP ssh Elewyn@192.168.1.208
ssh admin@YOUR_QNAP_IP cd /share/CACHEDEV1_DATA/discord-bot-prod # ou discord-bot-dev
/share/CACHEDEV1_DATA/.qpkg/container-station/usr/bin/docker compose up -d
# Navigate to folder
cd /share/Container/discord-bot/
# Install dependencies (if Python is installed)
pip3 install -r requirements.txt
# Test run
python3 bot.py
# Create auto-start service via QNAP interface
# Control Panel → Applications → Autorun
# Add: cd /share/Container/discord-bot && python3 bot.py &
``` ```
## 📊 SQLite Database ## 📊 SQLite Database

18
bot.py
View file

@ -16,20 +16,20 @@ from utils.leaderboard_handler import set_db_manager
os.environ["PYTHONIOENCODING"] = "utf-8" os.environ["PYTHONIOENCODING"] = "utf-8"
sys.stdout.reconfigure(encoding='utf-8') sys.stdout.reconfigure(encoding='utf-8')
# Définir les intents # Define intents
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
# Initialisation des managers # Initialize managers
db_manager = DatabaseManager() db_manager = DatabaseManager()
screenshot_manager = ScreenshotManager() screenshot_manager = ScreenshotManager()
mercy_manager = MercyManager() mercy_manager = MercyManager()
# Injection des managers dans les handlers # Inject managers into handlers
set_managers(db_manager, screenshot_manager) # pb_handler set_managers(db_manager, screenshot_manager) # pb_handler
set_db_manager(db_manager) # leaderboard_handler set_db_manager(db_manager) # leaderboard_handler
# Liste des cogs # Cog list
initial_cogs = [ initial_cogs = [
"cogs.guide", "cogs.guide",
"cogs.pbhydra", "cogs.pbhydra",
@ -40,7 +40,7 @@ initial_cogs = [
"cogs.mercy", "cogs.mercy",
] ]
# Liste des dossiers # Directory list
folders = [ folders = [
"screenshots/hydra/normal", "screenshots/hydra/normal",
"screenshots/hydra/hard", "screenshots/hydra/hard",
@ -55,7 +55,7 @@ folders = [
"screenshots/cvc", "screenshots/cvc",
] ]
# Création des dossiers si nécessaire (exist_ok=True évite d'écraser) # Create directories if needed (exist_ok=True avoids overwriting)
for f in folders: for f in folders:
os.makedirs(f, exist_ok=True) os.makedirs(f, exist_ok=True)
@ -70,12 +70,12 @@ class MyBot(commands.Bot):
for cog in initial_cogs: for cog in initial_cogs:
try: try:
await self.load_extension(cog) await self.load_extension(cog)
print(f"[OK] Cog {cog} chargé") print(f"[OK] Cog {cog} loaded")
except Exception as e: except Exception as e:
print(f"[ERREUR] Impossible de charger {cog}: {e}") print(f"[ERROR] Failed to load {cog}: {e}")
async def on_ready(self): async def on_ready(self):
print(f"{self.user.name} est connecté !") print(f"{self.user.name} connected!")
bot = MyBot() bot = MyBot()
bot.run(DISCORD_TOKEN) bot.run(DISCORD_TOKEN)

View file

@ -4,24 +4,24 @@ from discord.ext import commands
from config import AUTHORIZED_CHANNEL_ID from config import AUTHORIZED_CHANNEL_ID
class Guide(commands.Cog): class Guide(commands.Cog):
"""Affiche la liste des commandes disponibles""" """Shows the list of available commands"""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.command(name="guide") @commands.command(name="guide")
async def guide(self, ctx): async def guide(self, ctx):
"""Affiche toutes les commandes disponibles avec les difficultés""" """Shows all available commands with difficulties"""
if ctx.channel.id != AUTHORIZED_CHANNEL_ID: if ctx.channel.id != AUTHORIZED_CHANNEL_ID:
return return
embed = discord.Embed( embed = discord.Embed(
title="🧐 RTF Bot - Commands Guide", title="🧐 TEA Bot - Commands Guide",
description="Here are all available commands for tracking your Personal Bests!", description="Here are all available commands for tracking your Personal Bests!",
color=0x00bfff color=0x00bfff
) )
# Info sur les formats de dégâts # Damage format info
embed.add_field( embed.add_field(
name="💠 Damage Formats", name="💠 Damage Formats",
value="**Accepted formats:** `1500000`, `1.5M`, `500K`, `2B`\n" value="**Accepted formats:** `1500000`, `1.5M`, `500K`, `2B`\n"
@ -30,7 +30,7 @@ class Guide(commands.Cog):
inline=False inline=False
) )
# Commandes PB Hydra # Hydra PB commands
embed.add_field( embed.add_field(
name="🐍 Hydra Commands", name="🐍 Hydra Commands",
value="**Difficulties:** Normal | Hard | Brutal | Nightmare (nm)\n" value="**Difficulties:** Normal | Hard | Brutal | Nightmare (nm)\n"
@ -40,7 +40,7 @@ class Guide(commands.Cog):
inline=False inline=False
) )
# Commandes PB Chimera # Chimera PB commands
embed.add_field( embed.add_field(
name="🦁 Chimera Commands", name="🦁 Chimera Commands",
value="**Difficulties:** Easy | Normal | Hard | Brutal | Nightmare (nm) | Ultra (unm)\n" value="**Difficulties:** Easy | Normal | Hard | Brutal | Nightmare (nm) | Ultra (unm)\n"
@ -50,7 +50,7 @@ class Guide(commands.Cog):
inline=False inline=False
) )
# Commandes PB CvC # CvC PB commands
embed.add_field( embed.add_field(
name="⚔️ CvC Commands", name="⚔️ CvC Commands",
value="`!pbcvc <damage>` - Submit PB + screenshot\n" value="`!pbcvc <damage>` - Submit PB + screenshot\n"
@ -59,7 +59,7 @@ class Guide(commands.Cog):
inline=False inline=False
) )
# Commandes Mercy # Mercy commands
embed.add_field( embed.add_field(
name="🎲 Mercy Commands", name="🎲 Mercy Commands",
value="`!mercy show` - Show your current mercy pulls\n" value="`!mercy show` - Show your current mercy pulls\n"
@ -69,7 +69,7 @@ class Guide(commands.Cog):
inline=False inline=False
) )
# Classements globaux # Global leaderboards
embed.add_field( embed.add_field(
name="🌍 Global Leaderboards", name="🌍 Global Leaderboards",
value="`!top10hydra <difficulty>` - Global Hydra rankings\n" value="`!top10hydra <difficulty>` - Global Hydra rankings\n"
@ -78,16 +78,17 @@ class Guide(commands.Cog):
inline=False inline=False
) )
# Classements par clan # Clan leaderboards
embed.add_field( embed.add_field(
name="🏆 Clan Leaderboards", name="🏆 Clan Leaderboards",
value="**RTF:** `!rtfhydra <diff>` `!rtfchimera <diff>` `!rtfcvc`\n" value="**🔥 TEAI (Inferno):** `!teaihydra <diff>` `!teaichimera <diff>` `!teaicvc`\n"
"**RTFC:** `!rtfchydra <diff>` `!rtfcchimera <diff>` `!rtfccvc`\n" "**🛡️ TEAF (Flame):** `!teafhydra <diff>` `!teafchimera <diff>` `!teafcvc`\n"
"**RTFR:** `!rtfrhydra <diff>` `!rtfrchimera <diff>` `!rtfrcvc`", "**⚔️ TEAC (Cinder):** `!teachydra <diff>` `!teachimera <diff>` `!teaccvc`\n"
"**👑 TEACO (Corrupted Olympians):** `!teacohydra <diff>` `!teacochimera <diff>` `!teacocvc`",
inline=False inline=False
) )
# Stats et aide # Stats and help
embed.add_field( embed.add_field(
name="📈 Stats & Info", name="📈 Stats & Info",
value="`!mystats` - View all your PBs\n" value="`!mystats` - View all your PBs\n"
@ -96,7 +97,7 @@ class Guide(commands.Cog):
inline=False inline=False
) )
# Instructions # Examples
embed.add_field( embed.add_field(
name="⚡ Examples", name="⚡ Examples",
value="`!pbhydra brutal 1.5M` - Submit Brutal Hydra PB\n" value="`!pbhydra brutal 1.5M` - Submit Brutal Hydra PB\n"
@ -104,7 +105,7 @@ class Guide(commands.Cog):
"`!pbcvc 2.3M` - Submit CvC PB\n" "`!pbcvc 2.3M` - Submit CvC PB\n"
"`!mercy add 50 primal` - Add 50 pulls to Primal shard\n" "`!mercy add 50 primal` - Add 50 pulls to Primal shard\n"
"`!mercy show` - Show your mercy pulls\n" "`!mercy show` - Show your mercy pulls\n"
"`!rtfhydra nm` - RTF clan Nightmare rankings\n" "`!teaihydra nm` - TEAI clan Nightmare rankings\n"
"**Always attach screenshot when submitting PBs!**", "**Always attach screenshot when submitting PBs!**",
inline=False inline=False
) )

View file

@ -8,7 +8,7 @@ from utils.helpers import calc_chance_and_guarantee
VALID_SHARDS = ["ancient", "void", "sacred", "primal", "remnant"] VALID_SHARDS = ["ancient", "void", "sacred", "primal", "remnant"]
class Mercy(commands.Cog): class Mercy(commands.Cog):
"""Cog pour gérer les pulls de Mercy""" """Cog for managing Mercy pulls"""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot

View file

@ -3,34 +3,46 @@ import discord
from discord.ext import commands from discord.ext import commands
from config import AUTHORIZED_CHANNEL_ID, BOSS_CONFIG from config import AUTHORIZED_CHANNEL_ID, BOSS_CONFIG
from utils.helpers import format_damage_display, format_date_only from utils.helpers import format_damage_display, format_date_only
from utils.pb_handler import db_manager # Assurez-vous que db_manager est initialisé correctement from utils.pb_handler import db_manager # Make sure db_manager is initialized correctly
class MyStats(commands.Cog): class MyStats(commands.Cog):
"""Cog pour afficher tous les PB d'un utilisateur""" """Cog for displaying all PBs for a user"""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.command(name="mystats") @commands.command(name="mystats")
async def mystats(self, ctx, target_user: str = None): async def mystats(self, ctx, *, target_user: str = None):
"""Affiche tous les PB d'un utilisateur avec les nouvelles difficultés""" """Shows all PBs for a user across all difficulties"""
if ctx.channel.id != AUTHORIZED_CHANNEL_ID: if ctx.channel.id != AUTHORIZED_CHANNEL_ID:
return return
try: try:
username = target_user if target_user else ctx.author.id if target_user:
user_data = db_manager.get_user_all_pbs(username) matches = db_manager.find_user_by_name(target_user)
if not matches:
await ctx.send(f"❌ No data found for **{target_user}**.")
return
if len(matches) > 1:
await ctx.send(f"⚠️ Multiple users found for **{target_user}**. Please be more specific.")
return
user_id, display_name = matches[0]
else:
user_id = ctx.author.id
display_name = ctx.author.display_name
user_data = db_manager.get_user_all_pbs(user_id)
if not user_data: if not user_data:
await ctx.send(f"❌ No data found for **{ctx.author.display_name}**.") await ctx.send(f"❌ No data found for **{display_name}**.")
return return
embed = discord.Embed( embed = discord.Embed(
title=f"📊 {ctx.author.display_name}'s Complete Stats", title=f"📊 {display_name}'s Complete Stats",
color=0x00bfff color=0x00bfff
) )
# Hydra - toutes les difficultés # Hydra - all difficulties
hydra_stats = [] hydra_stats = []
for difficulty in BOSS_CONFIG['hydra']['difficulties']: for difficulty in BOSS_CONFIG['hydra']['difficulties']:
pb_key = f'pb_hydra_{difficulty}' pb_key = f'pb_hydra_{difficulty}'
@ -45,7 +57,7 @@ class MyStats(commands.Cog):
hydra_text = "\n".join(hydra_stats) if hydra_stats else "No records" hydra_text = "\n".join(hydra_stats) if hydra_stats else "No records"
embed.add_field(name="⚔️ Hydra PBs", value=hydra_text, inline=False) embed.add_field(name="⚔️ Hydra PBs", value=hydra_text, inline=False)
# Chimera - toutes les difficultés # Chimera - all difficulties
chimera_stats = [] chimera_stats = []
for difficulty in BOSS_CONFIG['chimera']['difficulties']: for difficulty in BOSS_CONFIG['chimera']['difficulties']:
pb_key = f'pb_chimera_{difficulty}' pb_key = f'pb_chimera_{difficulty}'
@ -71,7 +83,7 @@ class MyStats(commands.Cog):
cvc_text += f"{formatted_date}" cvc_text += f"{formatted_date}"
embed.add_field(name="🗡️ CvC PB", value=cvc_text, inline=False) embed.add_field(name="🗡️ CvC PB", value=cvc_text, inline=False)
# Total combiné # Combined total
total_damage = sum(user_data.get(f'pb_hydra_{d}', 0) for d in BOSS_CONFIG['hydra']['difficulties']) total_damage = sum(user_data.get(f'pb_hydra_{d}', 0) for d in BOSS_CONFIG['hydra']['difficulties'])
total_damage += sum(user_data.get(f'pb_chimera_{d}', 0) for d in BOSS_CONFIG['chimera']['difficulties']) total_damage += sum(user_data.get(f'pb_chimera_{d}', 0) for d in BOSS_CONFIG['chimera']['difficulties'])
total_damage += user_data.get('pb_cvc', 0) total_damage += user_data.get('pb_cvc', 0)

View file

@ -4,14 +4,14 @@ from discord.ext import commands
from utils.pb_handler import handle_pb_command from utils.pb_handler import handle_pb_command
class Pbchimera(commands.Cog): class Pbchimera(commands.Cog):
"""Cog pour gérer les Personal Bests Chimera""" """Cog for managing Chimera Personal Bests"""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.command(name="pbchimera") @commands.command(name="pbchimera")
async def pbchimera(self, ctx, arg1: str = None, arg2: str = None): async def pbchimera(self, ctx, arg1: str = None, *, arg2: str = None):
"""Commande !pbchimera avec gestion des difficultés""" """!pbchimera command with difficulty handling"""
await handle_pb_command(ctx, 'chimera', arg1, arg2) await handle_pb_command(ctx, 'chimera', arg1, arg2)
async def setup(bot): async def setup(bot):

View file

@ -4,14 +4,14 @@ from discord.ext import commands
from utils.pb_handler import handle_pb_command from utils.pb_handler import handle_pb_command
class Pbcvc(commands.Cog): class Pbcvc(commands.Cog):
"""Cog pour gérer les Personal Bests CvC (sans difficultés)""" """Cog for managing CvC Personal Bests (no difficulties)"""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.command(name="pbcvc") @commands.command(name="pbcvc")
async def pbcvc(self, ctx, target_user: str = None): async def pbcvc(self, ctx, *, target_user: str = None):
"""Commande !pbcvc""" """!pbcvc command"""
await handle_pb_command(ctx, 'cvc', target_user) await handle_pb_command(ctx, 'cvc', target_user)
async def setup(bot): async def setup(bot):

View file

@ -4,14 +4,14 @@ from discord.ext import commands
from utils.pb_handler import handle_pb_command from utils.pb_handler import handle_pb_command
class Pbhydra(commands.Cog): class Pbhydra(commands.Cog):
"""Cog pour gérer les Personal Bests Hydra""" """Cog for managing Hydra Personal Bests"""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.command(name="pbhydra") @commands.command(name="pbhydra")
async def pbhydra(self, ctx, arg1: str = None, arg2: str = None): async def pbhydra(self, ctx, arg1: str = None, *, arg2: str = None):
"""Commande !pbhydra avec gestion des difficultés""" """!pbhydra command with difficulty handling"""
await handle_pb_command(ctx, 'hydra', arg1, arg2) await handle_pb_command(ctx, 'hydra', arg1, arg2)
async def setup(bot): async def setup(bot):

View file

@ -6,12 +6,12 @@ from utils.helpers import normalize_difficulty
from config import BOSS_CONFIG from config import BOSS_CONFIG
class Top10(commands.Cog): class Top10(commands.Cog):
"""Cog regroupant toutes les commandes de leaderboard globales et par clan""" """Cog grouping all global and per-clan leaderboard commands"""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
# --- Commandes globales --- # --- Global commands ---
@commands.command() @commands.command()
async def top10hydra(self, ctx, difficulty: str = None): async def top10hydra(self, ctx, difficulty: str = None):
if difficulty and normalize_difficulty(difficulty) in BOSS_CONFIG['hydra']['difficulties']: if difficulty and normalize_difficulty(difficulty) in BOSS_CONFIG['hydra']['difficulties']:
@ -32,51 +32,64 @@ class Top10(commands.Cog):
async def top10cvc(self, ctx): async def top10cvc(self, ctx):
await show_leaderboard(ctx, 'cvc') await show_leaderboard(ctx, 'cvc')
# --- Commandes par clan RTF --- # --- TEAI clan commands (Inferno) ---
@commands.command() @commands.command()
async def rtfhydra(self, ctx, difficulty: str = None): async def teaihydra(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'hydra', difficulty, 'RTF') await self._show_clan_leaderboard(ctx, 'hydra', difficulty, 'TEAI')
@commands.command() @commands.command()
async def rtfchimera(self, ctx, difficulty: str = None): async def teaichimera(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'chimera', difficulty, 'RTF') await self._show_clan_leaderboard(ctx, 'chimera', difficulty, 'TEAI')
@commands.command() @commands.command()
async def rtfcvc(self, ctx): async def teaicvc(self, ctx):
await show_leaderboard(ctx, 'cvc', clan='RTF') await show_leaderboard(ctx, 'cvc', clan='TEAI')
# --- Commandes par clan RTFC --- # --- TEAF clan commands (Flame) ---
@commands.command() @commands.command()
async def rtfchydra(self, ctx, difficulty: str = None): async def teafhydra(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'hydra', difficulty, 'RTFC') await self._show_clan_leaderboard(ctx, 'hydra', difficulty, 'TEAF')
@commands.command() @commands.command()
async def rtfcchimera(self, ctx, difficulty: str = None): async def teafchimera(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'chimera', difficulty, 'RTFC') await self._show_clan_leaderboard(ctx, 'chimera', difficulty, 'TEAF')
@commands.command() @commands.command()
async def rtfccvc(self, ctx): async def teafcvc(self, ctx):
await show_leaderboard(ctx, 'cvc', clan='RTFC') await show_leaderboard(ctx, 'cvc', clan='TEAF')
# --- Commandes par clan RTFR --- # --- TEAC clan commands (Cinder) ---
@commands.command() @commands.command()
async def rtfrhydra(self, ctx, difficulty: str = None): async def teachydra(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'hydra', difficulty, 'RTFR') await self._show_clan_leaderboard(ctx, 'hydra', difficulty, 'TEAC')
@commands.command() @commands.command()
async def rtfrchimera(self, ctx, difficulty: str = None): async def teachimera(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'chimera', difficulty, 'RTFR') await self._show_clan_leaderboard(ctx, 'chimera', difficulty, 'TEAC')
@commands.command() @commands.command()
async def rtfrcvc(self, ctx): async def teaccvc(self, ctx):
await show_leaderboard(ctx, 'cvc', clan='RTFR') await show_leaderboard(ctx, 'cvc', clan='TEAC')
# --- Méthode interne pour éviter la répétition --- # --- TEACO clan commands (Corrupted Olympians) ---
@commands.command()
async def teacohydra(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'hydra', difficulty, 'TEACO')
@commands.command()
async def teacochimera(self, ctx, difficulty: str = None):
await self._show_clan_leaderboard(ctx, 'chimera', difficulty, 'TEACO')
@commands.command()
async def teacocvc(self, ctx):
await show_leaderboard(ctx, 'cvc', clan='TEACO')
# --- Internal helper method ---
async def _show_clan_leaderboard(self, ctx, boss_type, difficulty, clan): async def _show_clan_leaderboard(self, ctx, boss_type, difficulty, clan):
"""Affiche le leaderboard pour un boss et un clan spécifique""" """Shows the leaderboard for a specific boss and clan"""
if difficulty and normalize_difficulty(difficulty) in BOSS_CONFIG[boss_type]['difficulties']: if difficulty and normalize_difficulty(difficulty) in BOSS_CONFIG[boss_type]['difficulties']:
await show_leaderboard(ctx, boss_type, difficulty, clan) await show_leaderboard(ctx, boss_type, difficulty, clan)
elif boss_type != 'cvc': # CvC na pas de difficultés elif boss_type != 'cvc': # CvC has no difficulties
difficulties = " | ".join(BOSS_CONFIG[boss_type]['difficulties']) difficulties = " | ".join(BOSS_CONFIG[boss_type]['difficulties'])
await ctx.send( await ctx.send(
f"❌ Please specify difficulty: `!{ctx.command.name} <difficulty>`\n" f"❌ Please specify difficulty: `!{ctx.command.name} <difficulty>`\n"

View file

@ -4,22 +4,38 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Token et channel autorisé # Token and authorized channel
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
AUTHORIZED_CHANNEL_ID = int(os.getenv("AUTHORIZED_CHANNEL_ID")) AUTHORIZED_CHANNEL_ID = int(os.getenv("AUTHORIZED_CHANNEL_ID"))
# Chemins # Paths
SCREENSHOTS_BASE_PATH = "/app/screenshots" SCREENSHOTS_BASE_PATH = "/app/screenshots"
DATABASE_PATH = "/app/data/bot_data.db" DATABASE_PATH = "/app/data/bot_data.db"
# Configuration des clans # TEA clan configuration - The Ember Accord
CLAN_CONFIG = { CLAN_CONFIG = {
'RTF': {'name': 'RTF', 'emoji': '🛡️', 'color': 0x00ff00}, 'TEAI': {'name': 'TEAI', 'full_name': 'Inferno', 'emoji': '🔥', 'color': 0xff4500},
'RTFC': {'name': 'RTFC', 'emoji': '🔥', 'color': 0xff4500}, 'TEAF': {'name': 'TEAF', 'full_name': 'Flame', 'emoji': '🛡️', 'color': 0x00ff00},
'RTFR': {'name': 'RTFR', 'emoji': '⚔️', 'color': 0x1e90ff} 'TEAC': {'name': 'TEAC', 'full_name': 'Cinder', 'emoji': '⚔️', 'color': 0x1e90ff},
'TEACO': {'name': 'TEACO', 'full_name': 'Corrupted Olympians', 'emoji': '👑', 'color': 0x9932cc},
} }
# Configuration des boss avec difficultés # Discord role ID to clan key mapping
CLAN_ROLE_IDS = {
1190674529731747901: 'TEAI',
1197646966599983185: 'TEAF',
1220014404809261076: 'TEAC',
1496965820868198550: 'TEACO',
}
# Old clan to new clan mapping (existing database migration)
CLAN_MIGRATION = {
'RTF': 'TEAI',
'RTFC': 'TEAF',
'RTFR': 'TEAC',
}
# Boss configuration with difficulties
BOSS_CONFIG = { BOSS_CONFIG = {
'hydra': {'name': 'Hydra', 'emoji': '📍', 'color': 0xff6b35, 'hydra': {'name': 'Hydra', 'emoji': '📍', 'color': 0xff6b35,
'difficulties': ['normal', 'hard', 'brutal', 'nightmare']}, 'difficulties': ['normal', 'hard', 'brutal', 'nightmare']},
@ -28,7 +44,7 @@ BOSS_CONFIG = {
'cvc': {'name': 'Clan vs Clan', 'emoji': '✔️', 'color': 0xff0000, 'difficulties': []} 'cvc': {'name': 'Clan vs Clan', 'emoji': '✔️', 'color': 0xff0000, 'difficulties': []}
} }
# Mappings pour diminutifs de difficultés # Difficulty shortcut mappings
DIFFICULTY_SHORTCUTS = { DIFFICULTY_SHORTCUTS = {
'nm': 'nightmare', 'nm': 'nightmare',
'unm': 'ultra' 'unm': 'ultra'

View file

@ -1,5 +1,3 @@
version: '3.8'
services: services:
discord-bot: discord-bot:
build: . build: .
@ -15,7 +13,6 @@ services:
- ./logs:/app/logs - ./logs:/app/logs
environment: environment:
- TZ=Europe/Paris - TZ=Europe/Paris
container_name: rtf-discord-bot
deploy: deploy:
resources: resources:
limits: limits:

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from config import DATABASE_PATH from config import DATABASE_PATH, CLAN_MIGRATION
import sqlite3, os import sqlite3, os
class DatabaseManager: class DatabaseManager:
@ -8,12 +8,12 @@ class DatabaseManager:
self.init_database() self.init_database()
def init_database(self): def init_database(self):
"""Initialise la base de données avec les nouvelles colonnes pour les difficultés""" """Initializes the database with all columns for difficulties"""
os.makedirs(os.path.dirname(self.db_path), exist_ok=True) os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
# Table principale avec toutes les difficultés # Main table with all difficulties
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -54,7 +54,7 @@ class DatabaseManager:
pb_chimera_ultra_screenshot TEXT, pb_chimera_ultra_screenshot TEXT,
pb_chimera_ultra_date TIMESTAMP, pb_chimera_ultra_date TIMESTAMP,
-- CvC (inchangé) -- CvC (unchanged)
pb_cvc INTEGER DEFAULT 0, pb_cvc INTEGER DEFAULT 0,
pb_cvc_screenshot TEXT, pb_cvc_screenshot TEXT,
pb_cvc_date TIMESTAMP, pb_cvc_date TIMESTAMP,
@ -64,15 +64,24 @@ class DatabaseManager:
) )
''') ''')
# Migration des données existantes (si nécessaire) # Existing data migration (if needed)
cursor.execute("PRAGMA table_info(users)") cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
if 'discord_id' not in columns: if 'discord_id' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN discord_id TEXT') cursor.execute('ALTER TABLE users ADD COLUMN discord_id TEXT')
# Note: Vous devrez peut-être faire une migration manuelle pour les données existantes
# Table pour l'historique global if 'clan' not in columns:
cursor.execute('ALTER TABLE users ADD COLUMN clan TEXT')
# Auto-migration: derive clan from old username prefix
for old_tag, new_clan in CLAN_MIGRATION.items():
cursor.execute(
"UPDATE users SET clan = ? WHERE clan IS NULL AND ("
"discord_username LIKE ? OR discord_username LIKE ?)",
(new_clan, f'[{old_tag}] %', f'[{old_tag}]%')
)
# Global history table
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS pb_history ( CREATE TABLE IF NOT EXISTS pb_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -90,7 +99,7 @@ class DatabaseManager:
conn.close() conn.close()
def get_user_pb(self, user_id, boss_type, difficulty=None): def get_user_pb(self, user_id, boss_type, difficulty=None):
"""Récupère le PB d'un utilisateur pour un boss et difficulté spécifique""" """Returns PB for a user on a specific boss and difficulty"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
@ -108,12 +117,12 @@ class DatabaseManager:
return result if result else (0, None, None) return result if result else (0, None, None)
def update_user_pb(self, user_id, username, boss_type, damage, screenshot_filename, difficulty=None): def update_user_pb(self, user_id, username, boss_type, damage, screenshot_filename, difficulty=None, clan=None):
"""Met à jour le PB d'un utilisateur et supprime l'ancien screenshot""" """Updates a user's PB and deletes the previous screenshot"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
# Récupérer l'ancien screenshot pour le supprimer # Get old screenshot for deletion
old_data = self.get_user_pb(user_id, boss_type, difficulty) old_data = self.get_user_pb(user_id, boss_type, difficulty)
old_screenshot = old_data[1] if old_data else None old_screenshot = old_data[1] if old_data else None
@ -122,20 +131,21 @@ class DatabaseManager:
else: else:
column_prefix = f"pb_{boss_type}" column_prefix = f"pb_{boss_type}"
# Créer l'utilisateur s'il n'existe pas, sinon mettre à jour # COALESCE(?, clan): keeps existing clan if detection returns None
cursor.execute(f''' cursor.execute(f'''
INSERT INTO users (discord_id, discord_username, {column_prefix}, {column_prefix}_screenshot, {column_prefix}_date, total_attempts) INSERT INTO users (discord_id, discord_username, clan, {column_prefix}, {column_prefix}_screenshot, {column_prefix}_date, total_attempts)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, 1) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 1)
ON CONFLICT(discord_id) ON CONFLICT(discord_id)
DO UPDATE SET DO UPDATE SET
discord_username = ?, discord_username = ?,
clan = COALESCE(?, clan),
{column_prefix} = ?, {column_prefix} = ?,
{column_prefix}_screenshot = ?, {column_prefix}_screenshot = ?,
{column_prefix}_date = CURRENT_TIMESTAMP, {column_prefix}_date = CURRENT_TIMESTAMP,
total_attempts = total_attempts + 1 total_attempts = total_attempts + 1
''', (str(user_id), username, damage, screenshot_filename, username, damage, screenshot_filename)) ''', (str(user_id), username, clan, damage, screenshot_filename, username, clan, damage, screenshot_filename))
# Ajouter à l'historique # Add to history
cursor.execute(''' cursor.execute('''
INSERT INTO pb_history (discord_id, username, boss_type, difficulty, damage, screenshot_filename) INSERT INTO pb_history (discord_id, username, boss_type, difficulty, damage, screenshot_filename)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
@ -147,7 +157,7 @@ class DatabaseManager:
return old_screenshot return old_screenshot
def get_leaderboard(self, boss_type, difficulty=None, limit=10, clan=None): def get_leaderboard(self, boss_type, difficulty=None, limit=10, clan=None):
"""Récupère le classement pour un boss et difficulté spécifique""" """Returns the leaderboard for a specific boss and difficulty"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
@ -157,30 +167,30 @@ class DatabaseManager:
column_prefix = f"pb_{boss_type}" column_prefix = f"pb_{boss_type}"
base_query = f''' base_query = f'''
SELECT discord_username, {column_prefix}, {column_prefix}_date SELECT discord_username, {column_prefix}, {column_prefix}_date, clan
FROM users FROM users
WHERE {column_prefix} > 0 WHERE {column_prefix} > 0
''' '''
params = []
if clan: if clan:
base_query += ''' AND ( base_query += ' AND clan = ?'
discord_username LIKE '[''' + clan + '''] %' OR params.append(clan)
discord_username LIKE '[''' + clan + ''']%'
)'''
base_query += f' ORDER BY {column_prefix} DESC LIMIT ?' base_query += f' ORDER BY {column_prefix} DESC LIMIT ?'
params.append(limit)
cursor.execute(base_query, (limit,)) cursor.execute(base_query, params)
results = cursor.fetchall() results = cursor.fetchall()
conn.close() conn.close()
return results return results
def get_user_all_pbs(self, user_id): def get_user_all_pbs(self, user_id):
"""Récupère tous les PB d'un utilisateur""" """Returns all PBs for a user"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
# Récupérer toutes les colonnes de PB # Retrieve all PB columns
cursor.execute('SELECT * FROM users WHERE discord_id = ?', (str(user_id),)) cursor.execute('SELECT * FROM users WHERE discord_id = ?', (str(user_id),))
result = cursor.fetchone() result = cursor.fetchone()
columns = [desc[0] for desc in cursor.description] columns = [desc[0] for desc in cursor.description]
@ -192,7 +202,7 @@ class DatabaseManager:
return dict(zip(columns, result)) return dict(zip(columns, result))
def find_user_by_name(self, username): def find_user_by_name(self, username):
"""Trouve un utilisateur par son nom (pour rétrocompatibilité)""" """Finds a user by name (for backwards compatibility)"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()

View file

@ -3,7 +3,7 @@ import sqlite3
from datetime import datetime from datetime import datetime
from config import DATABASE_PATH from config import DATABASE_PATH
# Règles de mercy pour stockage # Mercy rules for storage
MERCY_RULES = { MERCY_RULES = {
"ancient": {"threshold": 200, "increment": 0.5, "base": 0}, "ancient": {"threshold": 200, "increment": 0.5, "base": 0},
"void": {"threshold": 200, "increment": 0.5, "base": 0}, "void": {"threshold": 200, "increment": 0.5, "base": 0},
@ -19,7 +19,7 @@ class MercyManager:
self.init_table() self.init_table()
def init_table(self): def init_table(self):
"""Initialise la table des compteurs de mercy""" """Initializes the mercy counters table"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
@ -35,7 +35,7 @@ class MercyManager:
conn.close() conn.close()
def get_pulls(self, user_id, shard_type): def get_pulls(self, user_id, shard_type):
"""Retourne le nombre de pulls actuels pour un utilisateur et un type de shard""" """Returns current pull count for a user and shard type"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
@ -47,11 +47,11 @@ class MercyManager:
return row[0] if row else 0 return row[0] if row else 0
def add_pulls(self, user_id, shard_type, pulls): def add_pulls(self, user_id, shard_type, pulls):
"""Ajoute des pulls pour un utilisateur en gérant correctement l'INSERT/UPDATE""" """Adds pulls for a user, handling INSERT/UPDATE correctly"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
# Vérifie si l'enregistrement existe # Check if record exists
cursor.execute( cursor.execute(
"SELECT pulls FROM mercy_counters WHERE user_id = ? AND shard_type = ?", "SELECT pulls FROM mercy_counters WHERE user_id = ? AND shard_type = ?",
(user_id, shard_type) (user_id, shard_type)
@ -76,7 +76,7 @@ class MercyManager:
return new_pulls return new_pulls
def reset_pulls(self, user_id, shard_type): def reset_pulls(self, user_id, shard_type):
"""Réinitialise les pulls d'un utilisateur pour un shard""" """Resets pull count for a user on a shard"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
@ -87,7 +87,7 @@ class MercyManager:
conn.close() conn.close()
def get_all_pulls(self, user_id): def get_all_pulls(self, user_id):
"""Retourne tous les pulls d'un utilisateur pour tous les shards""" """Returns all pull counts for a user across all shards"""
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
@ -99,14 +99,14 @@ class MercyManager:
return {shard_type: pulls for shard_type, pulls in rows} return {shard_type: pulls for shard_type, pulls in rows}
def get_mercy_chance(self, shard_type, pulls): def get_mercy_chance(self, shard_type, pulls):
"""Calcule la probabilité de mercy selon le nombre de pulls""" """Calculates mercy probability based on pull count"""
rule = MERCY_RULES[shard_type] rule = MERCY_RULES[shard_type]
if pulls <= rule["threshold"]: if pulls <= rule["threshold"]:
return rule["base"] return rule["base"]
return rule["base"] + (pulls - rule["threshold"]) * rule["increment"] return rule["base"] + (pulls - rule["threshold"]) * rule["increment"]
def pulls_until_guaranteed(self, shard_type, pulls): def pulls_until_guaranteed(self, shard_type, pulls):
"""Retourne combien de pulls restent avant un loot garanti""" """Returns pulls remaining until guaranteed loot"""
rules = { rules = {
"ancient": {"start": 200, "increment": 5, "base": 0.5}, "ancient": {"start": 200, "increment": 5, "base": 0.5},
"void": {"start": 200, "increment": 5, "base": 0.5}, "void": {"start": 200, "increment": 5, "base": 0.5},

View file

@ -7,18 +7,18 @@ from config import SCREENSHOTS_BASE_PATH, BOSS_CONFIG
class ScreenshotManager: class ScreenshotManager:
def __init__(self, base_path=SCREENSHOTS_BASE_PATH): def __init__(self, base_path=SCREENSHOTS_BASE_PATH):
self.base_path = base_path self.base_path = base_path
# Créer les dossiers pour chaque boss et difficulté # Create directories for each boss and difficulty
for boss_type in BOSS_CONFIG.keys(): for boss_type in BOSS_CONFIG.keys():
boss_path = os.path.join(base_path, boss_type) boss_path = os.path.join(base_path, boss_type)
os.makedirs(boss_path, exist_ok=True) os.makedirs(boss_path, exist_ok=True)
# Créer sous-dossiers pour les difficultés # Create subdirectories for difficulties
for difficulty in BOSS_CONFIG[boss_type]['difficulties']: for difficulty in BOSS_CONFIG[boss_type]['difficulties']:
difficulty_path = os.path.join(boss_path, difficulty) difficulty_path = os.path.join(boss_path, difficulty)
os.makedirs(difficulty_path, exist_ok=True) os.makedirs(difficulty_path, exist_ok=True)
async def save_screenshot(self, attachment, username, damage, boss_type, difficulty=None): async def save_screenshot(self, attachment, username, damage, boss_type, difficulty=None):
"""Sauvegarde le screenshot localement""" """Saves the screenshot locally"""
try: try:
timestamp = int(datetime.now().timestamp()) timestamp = int(datetime.now().timestamp())
file_extension = attachment.filename.split('.')[-1].lower() file_extension = attachment.filename.split('.')[-1].lower()
@ -34,18 +34,18 @@ class ScreenshotManager:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(attachment.url) as resp: async with session.get(attachment.url) as resp:
if resp.status == 200: if resp.status == 200:
# Ouverture en binaire, pas de problème d'encodage # Binary mode, no encoding issues
with open(filepath, 'wb') as f: with open(filepath, 'wb') as f:
f.write(await resp.read()) f.write(await resp.read())
return filename return filename
return None return None
except Exception as e: except Exception as e:
print(f"Erreur sauvegarde screenshot: {str(e)}") print(f"Screenshot save error: {str(e)}")
return None return None
def get_screenshot_path(self, filename, boss_type, difficulty=None): def get_screenshot_path(self, filename, boss_type, difficulty=None):
"""Retourne le chemin complet du screenshot""" """Returns the full path to the screenshot"""
if filename: if filename:
if difficulty: if difficulty:
return os.path.join(self.base_path, boss_type, difficulty, filename) return os.path.join(self.base_path, boss_type, difficulty, filename)
@ -54,12 +54,12 @@ class ScreenshotManager:
return None return None
def delete_old_screenshot(self, filename, boss_type, difficulty=None): def delete_old_screenshot(self, filename, boss_type, difficulty=None):
"""Supprime l'ancien screenshot""" """Deletes the old screenshot"""
if filename: if filename:
old_path = self.get_screenshot_path(filename, boss_type, difficulty) old_path = self.get_screenshot_path(filename, boss_type, difficulty)
if old_path and os.path.exists(old_path): if old_path and os.path.exists(old_path):
try: try:
os.remove(old_path) os.remove(old_path)
print(f"Ancien screenshot supprimé: {filename}") print(f"Old screenshot deleted: {filename}")
except Exception as e: except Exception as e:
print(f"Erreur suppression screenshot: {str(e)}") print(f"Screenshot deletion error: {str(e)}")

View file

@ -1,10 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
from datetime import datetime from datetime import datetime
from config import AUTHORIZED_CHANNEL_ID, DIFFICULTY_SHORTCUTS from typing import Optional
from config import AUTHORIZED_CHANNEL_ID, DIFFICULTY_SHORTCUTS, CLAN_ROLE_IDS
def parse_damage_amount(damage_str): def parse_damage_amount(damage_str):
"""Convertit les montants avec suffixes (K, M, B) en nombres entiers""" """Converts amounts with suffixes (K, M, B) to integers"""
if not damage_str: if not damage_str:
return None return None
damage_str = damage_str.strip().upper() damage_str = damage_str.strip().upper()
@ -22,7 +23,7 @@ def parse_damage_amount(damage_str):
return int(number * multipliers[suffix]) return int(number * multipliers[suffix])
def format_damage_display(damage): def format_damage_display(damage):
"""Formate un montant de dégâts avec le suffixe approprié""" """Formats a damage amount with the appropriate suffix"""
if damage >= 1_000_000_000: if damage >= 1_000_000_000:
billions = damage / 1_000_000_000 billions = damage / 1_000_000_000
return f"{int(billions)}B" if billions == int(billions) else f"{billions:.1f}B" return f"{int(billions)}B" if billions == int(billions) else f"{billions:.1f}B"
@ -35,7 +36,7 @@ def format_damage_display(damage):
return str(damage) return str(damage)
def normalize_difficulty(difficulty): def normalize_difficulty(difficulty):
"""Normalise une difficulté en gérant les diminutifs""" """Normalizes a difficulty string, handling shortcuts"""
if not difficulty: if not difficulty:
return None return None
difficulty_lower = difficulty.lower() difficulty_lower = difficulty.lower()
@ -43,27 +44,15 @@ def normalize_difficulty(difficulty):
return DIFFICULTY_SHORTCUTS[difficulty_lower] return DIFFICULTY_SHORTCUTS[difficulty_lower]
return difficulty_lower return difficulty_lower
def get_user_clan(username): def get_clan_from_member(member) -> Optional[str]:
"""Détermine le clan d'un utilisateur basé sur son pseudo""" """Detects a member's clan via their Discord roles"""
if not username: for role in member.roles:
return None if role.id in CLAN_ROLE_IDS:
username_upper = username.upper() return CLAN_ROLE_IDS[role.id]
# Tags avec crochets et espace
for clan_tag in ['[RTF] ', '[RTFC] ', '[RTFR] ']:
if username_upper.startswith(clan_tag):
return clan_tag.replace('[', '').replace(']', '').strip()
# Tags avec crochets sans espace
for clan_tag in ['[RTF]', '[RTFC]', '[RTFR]']:
if username_upper.startswith(clan_tag):
return clan_tag.replace('[', '').replace(']', '')
return None return None
def get_user_clan_from_ctx(ctx):
"""Détermine le clan d'un utilisateur depuis le contexte Discord"""
return get_user_clan(ctx.author.display_name)
def format_datetime(date_str): def format_datetime(date_str):
"""Formate une date en format AM/PM""" """Formats a date in AM/PM format"""
if not date_str: if not date_str:
return None return None
try: try:
@ -73,7 +62,7 @@ def format_datetime(date_str):
return None return None
def format_date_only(date_str): def format_date_only(date_str):
"""Formate une date sans l'heure""" """Formats a date without the time component"""
if not date_str: if not date_str:
return None return None
try: try:
@ -83,7 +72,7 @@ def format_date_only(date_str):
return None return None
def get_difficulty_display_name(difficulty): def get_difficulty_display_name(difficulty):
"""Convertit le nom de difficulté en nom d'affichage""" """Converts an internal difficulty name to display name"""
difficulty_names = { difficulty_names = {
'ultra': 'Ultra Nightmare', 'ultra': 'Ultra Nightmare',
'nightmare': 'Nightmare', 'nightmare': 'Nightmare',
@ -107,7 +96,7 @@ MERCY_RULES = {
} }
def calc_chance_and_guarantee(shard_type, pulls): def calc_chance_and_guarantee(shard_type, pulls):
"""Retourne chance, pull garanti et pulls restants""" """Returns chance, guaranteed pull count, and remaining pulls"""
if shard_type not in MERCY_RULES: if shard_type not in MERCY_RULES:
return 0, None, None return 0, None, None
rule = MERCY_RULES[shard_type] rule = MERCY_RULES[shard_type]

View file

@ -3,7 +3,7 @@ import os
import discord import discord
from discord.ext import commands from discord.ext import commands
from config import AUTHORIZED_CHANNEL_ID, BOSS_CONFIG, CLAN_CONFIG from config import AUTHORIZED_CHANNEL_ID, BOSS_CONFIG, CLAN_CONFIG
from utils.helpers import normalize_difficulty, get_difficulty_display_name, format_damage_display, format_date_only, get_user_clan from utils.helpers import normalize_difficulty, get_difficulty_display_name, format_damage_display, format_date_only
db_manager = None db_manager = None
@ -12,12 +12,12 @@ def set_db_manager(db):
db_manager = db db_manager = db
async def show_leaderboard(ctx, boss_type, difficulty=None, clan=None): async def show_leaderboard(ctx, boss_type, difficulty=None, clan=None):
"""Fonction générique pour afficher les classements""" """Generic function to display leaderboards"""
if ctx.channel.id != AUTHORIZED_CHANNEL_ID: if ctx.channel.id != AUTHORIZED_CHANNEL_ID:
return return
try: try:
# Normaliser la difficulté si spécifiée # Normalize difficulty if specified
if difficulty: if difficulty:
difficulty = normalize_difficulty(difficulty) difficulty = normalize_difficulty(difficulty)
if difficulty not in BOSS_CONFIG[boss_type]['difficulties']: if difficulty not in BOSS_CONFIG[boss_type]['difficulties']:
@ -34,7 +34,7 @@ async def show_leaderboard(ctx, boss_type, difficulty=None, clan=None):
await ctx.send(f"⚠️ No{difficulty_text} {boss_info['name']} records found{clan_text} yet!") await ctx.send(f"⚠️ No{difficulty_text} {boss_info['name']} records found{clan_text} yet!")
return return
# Titre avec clan et difficulté si spécifiés # Title with clan and difficulty if specified
difficulty_name = get_difficulty_display_name(difficulty) if difficulty else "" difficulty_name = get_difficulty_display_name(difficulty) if difficulty else ""
title = f"🏆 {difficulty_name} {boss_info['name']} Leaderboard - Top 10" title = f"🏆 {difficulty_name} {boss_info['name']} Leaderboard - Top 10"
@ -49,20 +49,18 @@ async def show_leaderboard(ctx, boss_type, difficulty=None, clan=None):
medals = ["🥇", "🥈", "🥉"] + ["🏅"] * 7 medals = ["🥇", "🥈", "🥉"] + ["🏅"] * 7
for i, (username, damage, date) in enumerate(leaderboard): for i, (username, damage, date, row_clan) in enumerate(leaderboard):
date_text = "" date_text = ""
if date: if date:
formatted_date = format_date_only(date) formatted_date = format_date_only(date)
if formatted_date: if formatted_date:
date_text = f"{formatted_date}" date_text = f"{formatted_date}"
# Afficher le clan dans le nom si pas de filtre par clan # Show clan emoji if leaderboard is global (not filtered by clan)
display_name = username display_name = username
if not clan: if not clan and row_clan:
user_clan = get_user_clan(username) clan_emoji = CLAN_CONFIG.get(row_clan, {'emoji': '🏛️'})['emoji']
if user_clan: display_name = f"{clan_emoji} {username}"
clan_emoji = CLAN_CONFIG.get(user_clan, {'emoji': '🏛️'})['emoji']
display_name = f"{clan_emoji} {username}"
embed.add_field( embed.add_field(
name=f"{medals[i]} #{i+1} {display_name}", name=f"{medals[i]} #{i+1} {display_name}",

View file

@ -8,19 +8,20 @@ from utils.helpers import (
get_difficulty_display_name, get_difficulty_display_name,
format_damage_display, format_damage_display,
format_datetime, format_datetime,
get_clan_from_member,
) )
db_manager = None db_manager = None
screenshot_manager = None screenshot_manager = None
def set_managers(db, ss): def set_managers(db, ss):
"""Injection des managers (appelée une seule fois depuis bot.py)""" """Injects managers (called once from bot.py)"""
global db_manager, screenshot_manager global db_manager, screenshot_manager
db_manager = db db_manager = db
screenshot_manager = ss screenshot_manager = ss
async def handle_pb_command(ctx, boss_type, arg1=None, arg2=None): async def handle_pb_command(ctx, boss_type, arg1=None, arg2=None):
"""Fonction générique pour gérer toutes les commandes PB avec difficultés""" """Generic handler for all PB commands"""
if ctx.channel.id != AUTHORIZED_CHANNEL_ID: if ctx.channel.id != AUTHORIZED_CHANNEL_ID:
return return
@ -28,7 +29,7 @@ async def handle_pb_command(ctx, boss_type, arg1=None, arg2=None):
difficulties = boss_info['difficulties'] difficulties = boss_info['difficulties']
try: try:
# Pour CvC (pas de difficultés) # For CvC (no difficulties)
if not difficulties: if not difficulties:
if arg1: if arg1:
damage = parse_damage_amount(arg1) damage = parse_damage_amount(arg1)
@ -40,7 +41,7 @@ async def handle_pb_command(ctx, boss_type, arg1=None, arg2=None):
await show_user_pb(ctx, boss_type, None, ctx.author.display_name) await show_user_pb(ctx, boss_type, None, ctx.author.display_name)
return return
# Pour Hydra et Chimera (avec difficultés) # For Hydra and Chimera (with difficulties)
if not arg1: if not arg1:
difficulty_list = " | ".join([d.title() for d in difficulties]) difficulty_list = " | ".join([d.title() for d in difficulties])
await ctx.send( await ctx.send(
@ -80,7 +81,7 @@ async def handle_pb_command(ctx, boss_type, arg1=None, arg2=None):
await ctx.send(f"⚠️ Error: {str(e)}") await ctx.send(f"⚠️ Error: {str(e)}")
async def handle_pb_submission(ctx, boss_type, difficulty, damage): async def handle_pb_submission(ctx, boss_type, difficulty, damage):
"""Gère la soumission d'un nouveau PB""" """Handles submission of a new PB"""
if not ctx.message.attachments: if not ctx.message.attachments:
await ctx.send("⚠️ Please attach a screenshot to validate your PB!") await ctx.send("⚠️ Please attach a screenshot to validate your PB!")
return return
@ -92,6 +93,7 @@ async def handle_pb_submission(ctx, boss_type, difficulty, damage):
user_id = ctx.author.id user_id = ctx.author.id
username = ctx.author.display_name username = ctx.author.display_name
clan = get_clan_from_member(ctx.author)
current_pb, _, _ = db_manager.get_user_pb(user_id, boss_type, difficulty) current_pb, _, _ = db_manager.get_user_pb(user_id, boss_type, difficulty)
if damage > current_pb: if damage > current_pb:
@ -101,7 +103,7 @@ async def handle_pb_submission(ctx, boss_type, difficulty, damage):
if screenshot_filename: if screenshot_filename:
old_screenshot = db_manager.update_user_pb( old_screenshot = db_manager.update_user_pb(
user_id, username, boss_type, damage, screenshot_filename, difficulty user_id, username, boss_type, damage, screenshot_filename, difficulty, clan
) )
if old_screenshot: if old_screenshot:
@ -118,7 +120,7 @@ async def handle_pb_submission(ctx, boss_type, difficulty, damage):
) )
embed.add_field(name="📈 Improvement", value=f"+{format_damage_display(improvement)} damage", inline=True) embed.add_field(name="📈 Improvement", value=f"+{format_damage_display(improvement)} damage", inline=True)
# Envoi du screenshot correctement pour Discord # Send screenshot to Discord
screenshot_path = screenshot_manager.get_screenshot_path(screenshot_filename, boss_type, difficulty) screenshot_path = screenshot_manager.get_screenshot_path(screenshot_filename, boss_type, difficulty)
if screenshot_path and os.path.exists(screenshot_path): if screenshot_path and os.path.exists(screenshot_path):
file = discord.File(screenshot_path, filename=screenshot_filename) file = discord.File(screenshot_path, filename=screenshot_filename)
@ -129,19 +131,19 @@ async def handle_pb_submission(ctx, boss_type, difficulty, damage):
else: else:
await ctx.send("⚠️ Failed to save screenshot. Please try again.") await ctx.send("⚠️ Failed to save screenshot. Please try again.")
else: else:
# Si le PB n'est pas battu, on montre le PB existant # PB not beaten, show current PB
await show_user_pb(ctx, boss_type, difficulty, username) await show_user_pb(ctx, boss_type, difficulty, username)
async def show_user_pb(ctx, boss_type, difficulty, target_user): async def show_user_pb(ctx, boss_type, difficulty, target_user):
"""Affiche le PB actuel d'un utilisateur""" """Displays the current PB for a user"""
# Si target_user est un nom d'utilisateur, on essaie de le trouver # If target_user is a username, try to find them
if isinstance(target_user, str) and not target_user.isdigit(): if isinstance(target_user, str) and not target_user.isdigit():
# D'abord, vérifier si c'est l'utilisateur actuel # First check if it's the current user
if target_user.lower() == ctx.author.display_name.lower(): if target_user.lower() == ctx.author.display_name.lower():
user_id = ctx.author.id user_id = ctx.author.id
display_name = ctx.author.display_name display_name = ctx.author.display_name
else: else:
# Chercher dans la base de données # Search in database
matches = db_manager.find_user_by_name(target_user) matches = db_manager.find_user_by_name(target_user)
if not matches: if not matches:
await ctx.send(f"⚠️ User **{target_user}** not found in database.") await ctx.send(f"⚠️ User **{target_user}** not found in database.")
@ -152,7 +154,7 @@ async def show_user_pb(ctx, boss_type, difficulty, target_user):
else: else:
user_id, display_name = matches[0] user_id, display_name = matches[0]
else: else:
# Si c'est l'utilisateur actuel # Current user
user_id = ctx.author.id user_id = ctx.author.id
display_name = ctx.author.display_name display_name = ctx.author.display_name
@ -169,7 +171,7 @@ async def show_user_pb(ctx, boss_type, difficulty, target_user):
if date: if date:
embed.add_field(name="📅 Date", value=format_datetime(date), inline=True) embed.add_field(name="📅 Date", value=format_datetime(date), inline=True)
# Envoi du screenshot local correctement # Send local screenshot to Discord
if screenshot: if screenshot:
screenshot_path = screenshot_manager.get_screenshot_path(screenshot, boss_type, difficulty) screenshot_path = screenshot_manager.get_screenshot_path(screenshot, boss_type, difficulty)
if screenshot_path and os.path.exists(screenshot_path): if screenshot_path and os.path.exists(screenshot_path):