fix: resolve merge conflicts in ticket_whitelist, add retry logic for port 5001 in bug_report, and implement live GitLab issue deduplication in logger

This commit is contained in:
Mathis 2026-03-28 21:58:44 +01:00
parent c40c3f1f12
commit 16263d88bc
6 changed files with 525 additions and 54 deletions

View file

@ -116,13 +116,40 @@ class BugReport(commands.Cog):
self.web_app.router.add_post('/bot_event', self.handle_bot_event)
self.runner = web.AppRunner(self.web_app)
await self.runner.setup()
max_retries = 5
retry_delay = 2
for attempt in range(1, max_retries + 1):
try:
self.site = web.TCPSite(self.runner, '127.0.0.1', 5001)
await self.site.start()
kuby_logger.info("Internal Bot Webhook Server started on 127.0.0.1:5001")
kuby_logger.info(f"Internal Bot Webhook Server started on 127.0.0.1:5001 (Attempt {attempt})")
return
except OSError as e:
if e.errno == 98: # Address already in use
if attempt < max_retries:
kuby_logger.warning(f"Port 5001 already in use, retrying in {retry_delay}s... ({attempt}/{max_retries})")
await asyncio.sleep(retry_delay)
else:
kuby_logger.error(f"Failed to bind to port 5001 after {max_retries} attempts.")
raise e
else:
raise e
def cog_unload(self):
if hasattr(self, 'runner'):
self.bot.loop.create_task(self.runner.cleanup())
# On utilise create_task car cog_unload est synchrone
async def cleanup():
try:
if hasattr(self, 'site'):
await self.site.stop()
await self.runner.cleanup()
kuby_logger.info("Internal Bot Webhook Server stopped and cleaned up.")
except Exception as e:
kuby_logger.error(f"Error during webhook server cleanup: {e}")
self.bot.loop.create_task(cleanup())
async def handle_bot_event(self, request):
try:

View file

@ -1707,14 +1707,14 @@ class ConfigPanelView(discord.ui.View):
self.category_btn.callback = self._category_callback
self.add_item(self.category_btn)
# Ajouter catégorie
self.add_cat_btn = discord.ui.Button(
# Gérer les catégories
self.manage_cat_btn = discord.ui.Button(
style=discord.ButtonStyle.blurple,
label="Ajouter Catégorie",
custom_id="config_add_cat"
label="📂 Gérer les catégories",
custom_id="config_manage_cat"
)
self.add_cat_btn.callback = self._add_cat_callback
self.add_item(self.add_cat_btn)
self.manage_cat_btn.callback = self._manage_cat_callback
self.add_item(self.manage_cat_btn)
# Staff permissions
staff_btn = discord.ui.Button(
@ -1756,9 +1756,9 @@ class ConfigPanelView(discord.ui.View):
item.style = discord.ButtonStyle.green if self.config.enabled else discord.ButtonStyle.red
await interaction.message.edit(view=self)
async def _add_cat_callback(self, interaction: discord.Interaction):
modal = AddCategoryModal(self.bot, self.storage, self.config)
await interaction.response.send_modal(modal)
async def _manage_cat_callback(self, interaction: discord.Interaction):
view = CategoryManagerView(self.bot, self.storage, self.config)
await interaction.response.edit_message(embed=view._get_embed(), view=view)
async def _staff_perms_callback(self, interaction: discord.Interaction):
view = PermissionConfigView(self.bot, self.storage, self.config, interaction.guild, "staff")
@ -1849,6 +1849,251 @@ class AddCategoryModal(discord.ui.Modal):
await interaction.response.send_message(f"Catégorie **{self.name.value}** créée!", ephemeral=True)
class EditCategoryModal(discord.ui.Modal):
"""Modal pour modifier une catégorie existante"""
def __init__(self, bot, storage, config, category: TicketCategory, parent_view):
super().__init__(title=f"Modifier: {category.name}")
self.bot = bot
self.storage = storage
self.config = config
self.category = category
self.parent_view = parent_view
self.name = discord.ui.TextInput(
label="Nom",
default=category.name,
placeholder="Ex: Support",
required=True
)
self.add_item(self.name)
self.description = discord.ui.TextInput(
label="Description",
default=category.description,
placeholder="Description...",
required=False,
style=discord.TextStyle.paragraph
)
self.add_item(self.description)
self.emoji = discord.ui.TextInput(
label="Emoji",
default=category.emoji,
placeholder="🎫",
required=False,
max_length=5
)
self.add_item(self.emoji)
async def on_submit(self, interaction: discord.Interaction):
# Update category object
self.category.name = self.name.value
self.category.description = self.description.value or ""
self.category.emoji = self.emoji.value or "🎫"
# Save config
self.storage.save_config(interaction.guild_id, self.config)
# Refresh the parent view's message
await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view)
await interaction.followup.send(f"✅ Catégorie **{self.category.name}** mise à jour !", ephemeral=True)
class CategoryManagerView(discord.ui.View):
"""Vue pour lister et gérer les catégories"""
def __init__(self, bot, storage, config: GuildTicketConfig):
super().__init__(timeout=None)
self.bot = bot
self.storage = storage
self.config = config
self._add_category_select()
def _add_category_select(self):
self.clear_items()
if self.config.categories:
options = []
for cat in self.config.categories[:25]: # Limite Select menu
options.append(discord.SelectOption(
label=cat.name,
value=cat.id,
description=cat.description[:100] if cat.description else "Aucune description",
emoji=cat.emoji
))
select = discord.ui.Select(
placeholder="Sélectionnez une catégorie à gérer...",
options=options,
custom_id="manage_cat_select"
)
select.callback = self._category_select_callback
self.add_item(select)
# Bouton Ajouter
add_btn = discord.ui.Button(
style=discord.ButtonStyle.success,
label=" Ajouter une catégorie",
custom_id="manage_cat_add"
)
add_btn.callback = self._add_cat_callback
self.add_item(add_btn)
# Bouton Retour
back_btn = discord.ui.Button(
style=discord.ButtonStyle.secondary,
label="⬅️ Retour",
custom_id="manage_cat_back"
)
back_btn.callback = self._back_callback
self.add_item(back_btn)
def _get_embed(self):
embed = discord.Embed(
title="📂 Gestion des Catégories",
description=f"Il y a actuellement **{len(self.config.categories)}** catégorie(s) configurée(s).\n\n"
"Sélectionnez une catégorie dans le menu pour la modifier ou la supprimer.",
color=discord.Color.blue()
)
if not self.config.categories:
embed.description += "\n\n⚠️ **Aucune catégorie configurée.** Commencez par en ajouter une !"
for cat in self.config.categories[:10]:
embed.add_field(
name=f"{cat.emoji} {cat.name}",
value=cat.description or "Pas de description",
inline=False
)
return embed
async def _category_select_callback(self, interaction: discord.Interaction):
# En utilisant interaction.data.get('values') pour obtenir la valeur sélectionnée
cat_id = interaction.data.get('values', [None])[0]
category = self.config.get_category(cat_id)
if not category:
await interaction.response.send_message("Catégorie introuvable.", ephemeral=True)
return
view = CategoryActionView(self.bot, self.storage, self.config, category, self)
await interaction.response.edit_message(embed=view._get_embed(), view=view)
async def _add_cat_callback(self, interaction: discord.Interaction):
# Utilisation de la classe AddCategoryModal existante
class AddCategoryManagerModal(AddCategoryModal):
def __init__(self, bot, storage, config, parent_view):
super().__init__(bot, storage, config)
self.parent_view = parent_view
async def on_submit(self, interaction: discord.Interaction):
import uuid
cat_id = str(uuid.uuid4())[:8]
category = TicketCategory(
id=cat_id,
name=self.name.value,
description=self.description.value or "",
emoji=self.emoji.value or "🎫"
)
self.config.categories.append(category)
self.storage.save_config(interaction.guild_id, self.config)
# Refresh parent view
self.parent_view._add_category_select()
# On doit utiliser edit_message sur l'interaction d'origine ou renvoyer un embed
await interaction.response.edit_message(
embed=self.parent_view._get_embed(),
view=self.parent_view
)
await interaction.followup.send(f"Catégorie **{self.name.value}** créée!", ephemeral=True)
modal = AddCategoryManagerModal(self.bot, self.storage, self.config, self)
await interaction.response.send_modal(modal)
async def _back_callback(self, interaction: discord.Interaction):
view = ConfigPanelView(self.bot, self.storage, self.config)
embed = discord.Embed(
title="Configuration des Tickets",
description="Gérez la configuration du système de tickets",
color=discord.Color.blue()
)
await interaction.response.edit_message(embed=embed, view=view)
class CategoryActionView(discord.ui.View):
"""Actions pour une catégorie spécifique"""
def __init__(self, bot, storage, config, category, parent_view):
super().__init__(timeout=None)
self.bot = bot
self.storage = storage
self.config = config
self.category = category
self.parent_view = parent_view
def _get_embed(self):
embed = discord.Embed(
title=f"Gestion: {self.category.name}",
description=f"**ID:** `{self.category.id}`\n"
f"**Emoji:** {self.category.emoji}\n"
f"**Description:** {self.category.description or 'Aucune'}\n"
f"**Couleur:** `{self.category.color}`",
color=discord.Color.blue()
)
return embed
@discord.ui.button(label="📝 Modifier", style=discord.ButtonStyle.primary)
async def edit_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
modal = EditCategoryModal(self.bot, self.storage, self.config, self.category, self)
await interaction.response.send_modal(modal)
@discord.ui.button(label="🗑️ Supprimer", style=discord.ButtonStyle.danger)
async def delete_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
view = DeleteCategoryConfirmationView(self.bot, self.storage, self.config, self.category, self.parent_view)
embed = discord.Embed(
title="⚠️ Confirmer la suppression",
description=f"Êtes-vous sûr de vouloir supprimer la catégorie **{self.category.name}** ?\n"
"Cette action est irréversible.",
color=discord.Color.red()
)
await interaction.response.edit_message(embed=embed, view=view)
@discord.ui.button(label="⬅️ Retour", style=discord.ButtonStyle.secondary)
async def back_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
self.parent_view._add_category_select()
await interaction.response.edit_message(embed=self.parent_view._get_embed(), view=self.parent_view)
class DeleteCategoryConfirmationView(discord.ui.View):
def __init__(self, bot, storage, config, category, parent_view):
super().__init__(timeout=None)
self.bot = bot
self.storage = storage
self.config = config
self.category = category
self.parent_view = parent_view
@discord.ui.button(label="✅ Confirmer", style=discord.ButtonStyle.danger)
async def confirm_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
if self.category in self.config.categories:
self.config.categories.remove(self.category)
self.storage.save_config(interaction.guild_id, self.config)
self.parent_view._add_category_select()
await interaction.response.edit_message(
embed=self.parent_view._get_embed(),
view=self.parent_view
)
await interaction.followup.send(f"✅ Catégorie **{self.category.name}** supprimée.", ephemeral=True)
@discord.ui.button(label="❌ Annuler", style=discord.ButtonStyle.secondary)
async def cancel_callback(self, interaction: discord.Interaction, button: discord.ui.Button):
view = CategoryActionView(self.bot, self.storage, self.config, self.category, self.parent_view)
await interaction.response.edit_message(embed=view._get_embed(), view=view)
class SetChannelModal(discord.ui.Modal):
"""Modal pour définir le canal de logs"""

View file

@ -3,7 +3,9 @@ from discord.ext import commands
from discord import app_commands, ui
import json
import os
import asyncio
from typing import Optional
from src.logger import kuby_logger
TICKET_WHITELIST_SETTINGS_FILE = "data/ticket_whitelist_settings.json"
@ -43,12 +45,6 @@ class TicketWhitelistConfigView(ui.View):
def check(m):
return m.author == interaction.user and m.channel == interaction.channel
try:
# We use an explicit import here to avoid clashing if __import__ syntax is weird, but using standard asyncio module is fine if we import it at top. Let's add import asyncio at the top instead!
pass
except __import__("asyncio").TimeoutError:
pass
try:
msg = await interaction.client.wait_for('message', check=check, timeout=30)
target = None
@ -322,6 +318,23 @@ class TicketManagementView(discord.ui.View):
def __init__(self):
super().__init__(timeout=None)
self.claimed_by = None # Stocke le staff qui claim le ticket
async def get_ticket_owner(self, interaction: discord.Interaction):
"""Récupère le propriétaire du ticket via le topic du salon"""
if interaction.channel.topic and interaction.channel.topic.startswith("Ticket de "):
try:
owner_id = int(interaction.channel.topic.split("Ticket de ")[1])
member = interaction.guild.get_member(owner_id)
if not member:
try:
member = await interaction.guild.fetch_member(owner_id)
except discord.NotFound:
return None
return member
except (ValueError, IndexError):
return None
return None
@discord.ui.button(label="🔒 Fermer le Ticket", style=discord.ButtonStyle.red, custom_id="close_ticket")
async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
@ -332,19 +345,11 @@ class TicketManagementView(discord.ui.View):
if not discord.utils.get(interaction.user.roles, id=int(staff_role_id) if staff_role_id else 0) and not interaction.user.guild_permissions.administrator:
return await interaction.response.send_message("❌ Seuls les staffs peuvent fermer un ticket.", ephemeral=True)
topic = interaction.channel.topic or ""
if not topic.startswith("Ticket de "):
owner = await self.get_ticket_owner(interaction)
if not owner:
return await interaction.response.send_message("❌ Impossible de trouver le propriétaire du ticket à fermer.", ephemeral=True)
try:
owner_id = int(topic.split("Ticket de ")[1])
ticket_owner = interaction.guild.get_member(owner_id)
if not ticket_owner:
ticket_owner = await interaction.guild.fetch_member(owner_id)
except (IndexError, ValueError, discord.NotFound):
return await interaction.response.send_message("❌ Membre introuvable.", ephemeral=True)
await interaction.channel.set_permissions(ticket_owner, read_messages=False, send_messages=False)
await interaction.channel.set_permissions(owner, read_messages=False, send_messages=False)
await interaction.channel.edit(name="ticket-fermé")
await interaction.response.send_message("🔒 Le ticket a été fermé. Vous ne pouvez plus le voir.", ephemeral=True)
@ -376,6 +381,22 @@ class ClosedTicketView(discord.ui.View):
def __init__(self):
super().__init__(timeout=None)
async def get_ticket_owner(self, interaction: discord.Interaction):
"""Récupère le propriétaire du ticket via le topic du salon"""
if interaction.channel.topic and interaction.channel.topic.startswith("Ticket de "):
try:
owner_id = int(interaction.channel.topic.split("Ticket de ")[1])
member = interaction.guild.get_member(owner_id)
if not member:
try:
member = await interaction.guild.fetch_member(owner_id)
except discord.NotFound:
return None
return member
except (ValueError, IndexError):
return None
return None
@discord.ui.button(label="♻ Réouvrir le Ticket", style=discord.ButtonStyle.green, custom_id="reopen_ticket")
async def reopen_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Réouvre le ticket pour le membre"""
@ -385,19 +406,11 @@ class ClosedTicketView(discord.ui.View):
if not discord.utils.get(interaction.user.roles, id=int(staff_role_id) if staff_role_id else 0) and not interaction.user.guild_permissions.administrator:
return await interaction.response.send_message("❌ Seuls les staffs peuvent réouvrir un ticket.", ephemeral=True)
topic = interaction.channel.topic or ""
if not topic.startswith("Ticket de "):
owner = await self.get_ticket_owner(interaction)
if not owner:
return await interaction.response.send_message("❌ Impossible de trouver le propriétaire du ticket à réouvrir.", ephemeral=True)
try:
owner_id = int(topic.split("Ticket de ")[1])
ticket_owner = interaction.guild.get_member(owner_id)
if not ticket_owner:
ticket_owner = await interaction.guild.fetch_member(owner_id)
except (IndexError, ValueError, discord.NotFound):
return await interaction.response.send_message("❌ Membre introuvable.", ephemeral=True)
await interaction.channel.set_permissions(ticket_owner, read_messages=True, send_messages=True)
await interaction.channel.set_permissions(owner, read_messages=True, send_messages=True)
await interaction.response.send_message("✅ Le ticket a été réouvert.", ephemeral=True)
# Mettre à jour la vue pour afficher tous les boutons
@ -433,6 +446,12 @@ class Ticket(commands.Cog):
def __init__(self, bot):
self.bot = bot
async def cog_load(self):
"""Enregistre les vues persistantes"""
self.bot.add_view(TicketView())
self.bot.add_view(TicketManagementView())
self.bot.add_view(ClosedTicketView())
@app_commands.command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.")
@app_commands.checks.has_permissions(administrator=True)
async def ticket_whitelist(self, interaction: discord.Interaction):

View file

@ -1,6 +1,118 @@
[
{
<<<<<<< Updated upstream
"date": "2026-03-15T15:50:47.091919+00:00",
=======
"date": "2026-03-28T20:17:38.126055+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:17:08.105930+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:16:37.851812+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:16:08.471789+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:15:38.321159+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:15:08.114852+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:14:38.114389+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:14:07.816703+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:13:37.862896+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:13:07.844778+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:12:37.876620+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-28T20:12:08.173233+00:00",
"trigger": "auto",
"serversCount": 1,
"membersCount": 12,
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
},
{
"date": "2026-03-01T00:26:14.746054+00:00",
>>>>>>> Stashed changes
"trigger": "auto",
"serversCount": 1,
"membersCount": 13,
@ -70,6 +182,7 @@
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
<<<<<<< Updated upstream
},
{
"date": "2026-03-15T15:46:47.376868+00:00",
@ -178,5 +291,7 @@
"guildName": "Omega Kube Bêta Test",
"errors": [],
"success": true
=======
>>>>>>> Stashed changes
}
]

View file

@ -3,10 +3,14 @@ import sys
import time
import functools
import asyncio
import json
import os
from datetime import datetime
from typing import Any, Callable
import traceback
REPORTED_ERRORS_FILE = "data/automated_reports.json"
class KubyLogger:
def __init__(self, name="KubyBot"):
self.logger = logging.getLogger(name)
@ -53,37 +57,69 @@ class KubyLogger:
def __init__(self, client):
super().__init__()
self.client = client
self.last_errors = {}
self.cooldown = 3600 # 1 hour cooldown per unique error
self.last_errors = self._load_reported_errors()
def _load_reported_errors(self):
if os.path.exists(REPORTED_ERRORS_FILE):
try:
with open(REPORTED_ERRORS_FILE, "r") as f:
return json.load(f)
except Exception as e:
print(f"Error loading reported errors: {e}")
return {}
def _save_reported_errors(self):
try:
os.makedirs(os.path.dirname(REPORTED_ERRORS_FILE), exist_ok=True)
with open(REPORTED_ERRORS_FILE, "w") as f:
json.dump(self.last_errors, f, indent=4)
except Exception as e:
print(f"Error saving reported errors: {e}")
def emit(self, record):
if record.levelno >= logging.ERROR:
# Extract message and first line of traceback if available
msg = record.getMessage()
error_key = msg
if record.exc_info:
error_key += str(record.exc_info[1])
# Use message + location + simplified traceback for unique key
# This key is used for the local cooldown
error_key = f"{record.name}.{record.funcName}:{record.lineno} - {msg}"
current_time = time.time()
if error_key in self.last_errors and current_time - self.last_errors[error_key] < self.cooldown:
return # Skip if same error within cooldown
last_time = self.last_errors.get(error_key, 0)
if current_time - last_time < self.cooldown:
return
self.last_errors[error_key] = current_time
self._save_reported_errors()
# Create issue (Fire and forget, we don't want to block logging)
# Prepare issue data
description = f"**Type:** {record.levelname}\n"
description += f"**Message:** {msg}\n"
description += f"**Source:** {record.name}.{record.funcName}:{record.lineno}\n\n"
if record.exc_info:
description += f"**Traceback:**\n```python\n{traceback.format_exc()}\n```"
title = f"[AUTO] Error: {msg[:50]}..."
title = f"[AUTO] Error: {msg[:100]}..."
labels = ["bug", "auto-report"]
# Async reporting flow
async def report_flow(client, t, d, l):
try:
# Check if an open issue with the SAME title already exists on GitLab
existing = await client.find_issue_by_title(t, state="opened")
if existing:
# If it exists, we don't create a new one to avoid clutter
return
# Otherwise, create it
await client.create_issue(t, d, l)
except Exception as e:
print(f"Error in automated reporting flow: {e}")
loop = asyncio.get_event_loop()
if loop.is_running():
loop.create_task(self.client.create_issue(title, description, labels=["bug", "auto-report"]))
else:
# Fallback if loop is not running (rare for this bot)
pass
loop.create_task(report_flow(self.client, title, description, labels))
gitlab_handler = GitLabHandler(gitlab_client)
gitlab_handler.setLevel(logging.ERROR)

View file

@ -63,6 +63,35 @@ class GitLabClient:
gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}")
return None
async def find_issue_by_title(self, title: str, state: str = "opened") -> Optional[dict]:
"""
Searches for an issue by its exact title and state.
"""
if not self.token or not self.project_id:
return None
headers = {"PRIVATE-TOKEN": self.token}
params = {
"search": title,
"state": state
}
async with aiohttp.ClientSession() as session:
try:
# search param is fuzzy on GitLab, so we must double-check for exact match
async with session.get(self.api_url, headers=headers, params=params) as response:
if response.status == 200:
issues = await response.json()
for issue in issues:
if issue.get("title") == title:
return issue
return None
else:
return None
except Exception as e:
gitlab_internal_logger.error(f"❌ Error searching GitLab issues: {e}")
return None
async def get_closed_issues(self) -> List[dict]:
"""
Fetches recently closed issues.