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.web_app.router.add_post('/bot_event', self.handle_bot_event)
self.runner = web.AppRunner(self.web_app) self.runner = web.AppRunner(self.web_app)
await self.runner.setup() await self.runner.setup()
self.site = web.TCPSite(self.runner, '127.0.0.1', 5001)
await self.site.start() max_retries = 5
kuby_logger.info("Internal Bot Webhook Server started on 127.0.0.1:5001") 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(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): def cog_unload(self):
if hasattr(self, 'runner'): 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): async def handle_bot_event(self, request):
try: try:

View file

@ -1707,14 +1707,14 @@ class ConfigPanelView(discord.ui.View):
self.category_btn.callback = self._category_callback self.category_btn.callback = self._category_callback
self.add_item(self.category_btn) self.add_item(self.category_btn)
# Ajouter catégorie # Gérer les catégories
self.add_cat_btn = discord.ui.Button( self.manage_cat_btn = discord.ui.Button(
style=discord.ButtonStyle.blurple, style=discord.ButtonStyle.blurple,
label="Ajouter Catégorie", label="📂 Gérer les catégories",
custom_id="config_add_cat" custom_id="config_manage_cat"
) )
self.add_cat_btn.callback = self._add_cat_callback self.manage_cat_btn.callback = self._manage_cat_callback
self.add_item(self.add_cat_btn) self.add_item(self.manage_cat_btn)
# Staff permissions # Staff permissions
staff_btn = discord.ui.Button( 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 item.style = discord.ButtonStyle.green if self.config.enabled else discord.ButtonStyle.red
await interaction.message.edit(view=self) await interaction.message.edit(view=self)
async def _add_cat_callback(self, interaction: discord.Interaction): async def _manage_cat_callback(self, interaction: discord.Interaction):
modal = AddCategoryModal(self.bot, self.storage, self.config) view = CategoryManagerView(self.bot, self.storage, self.config)
await interaction.response.send_modal(modal) await interaction.response.edit_message(embed=view._get_embed(), view=view)
async def _staff_perms_callback(self, interaction: discord.Interaction): async def _staff_perms_callback(self, interaction: discord.Interaction):
view = PermissionConfigView(self.bot, self.storage, self.config, interaction.guild, "staff") 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) 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): class SetChannelModal(discord.ui.Modal):
"""Modal pour définir le canal de logs""" """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 from discord import app_commands, ui
import json import json
import os import os
import asyncio
from typing import Optional from typing import Optional
from src.logger import kuby_logger
TICKET_WHITELIST_SETTINGS_FILE = "data/ticket_whitelist_settings.json" TICKET_WHITELIST_SETTINGS_FILE = "data/ticket_whitelist_settings.json"
@ -43,12 +45,6 @@ class TicketWhitelistConfigView(ui.View):
def check(m): def check(m):
return m.author == interaction.user and m.channel == interaction.channel 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: try:
msg = await interaction.client.wait_for('message', check=check, timeout=30) msg = await interaction.client.wait_for('message', check=check, timeout=30)
target = None target = None
@ -322,6 +318,23 @@ class TicketManagementView(discord.ui.View):
def __init__(self): def __init__(self):
super().__init__(timeout=None) 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") @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): 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: 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) return await interaction.response.send_message("❌ Seuls les staffs peuvent fermer un ticket.", ephemeral=True)
topic = interaction.channel.topic or "" owner = await self.get_ticket_owner(interaction)
if not topic.startswith("Ticket de "): if not owner:
return await interaction.response.send_message("❌ Impossible de trouver le propriétaire du ticket à fermer.", ephemeral=True) 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.channel.edit(name="ticket-fermé")
await interaction.response.send_message("🔒 Le ticket a été fermé. Vous ne pouvez plus le voir.", ephemeral=True) 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): def __init__(self):
super().__init__(timeout=None) 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") @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): async def reopen_ticket(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Réouvre le ticket pour le membre""" """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: 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) return await interaction.response.send_message("❌ Seuls les staffs peuvent réouvrir un ticket.", ephemeral=True)
topic = interaction.channel.topic or "" owner = await self.get_ticket_owner(interaction)
if not topic.startswith("Ticket de "): if not owner:
return await interaction.response.send_message("❌ Impossible de trouver le propriétaire du ticket à réouvrir.", ephemeral=True) return await interaction.response.send_message("❌ Impossible de trouver le propriétaire du ticket à réouvrir.", ephemeral=True)
try: await interaction.channel.set_permissions(owner, read_messages=True, send_messages=True)
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.response.send_message("✅ Le ticket a été réouvert.", ephemeral=True) await interaction.response.send_message("✅ Le ticket a été réouvert.", ephemeral=True)
# Mettre à jour la vue pour afficher tous les boutons # Mettre à jour la vue pour afficher tous les boutons
@ -433,6 +446,12 @@ class Ticket(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = 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.command(name="ticket_whitelist", description="Envoie l'embed pour ouvrir un ticket de whitelist.")
@app_commands.checks.has_permissions(administrator=True) @app_commands.checks.has_permissions(administrator=True)
async def ticket_whitelist(self, interaction: discord.Interaction): 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-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", "trigger": "auto",
"serversCount": 1, "serversCount": 1,
"membersCount": 13, "membersCount": 13,
@ -70,6 +182,7 @@
"guildName": "Omega Kube Bêta Test", "guildName": "Omega Kube Bêta Test",
"errors": [], "errors": [],
"success": true "success": true
<<<<<<< Updated upstream
}, },
{ {
"date": "2026-03-15T15:46:47.376868+00:00", "date": "2026-03-15T15:46:47.376868+00:00",
@ -178,5 +291,7 @@
"guildName": "Omega Kube Bêta Test", "guildName": "Omega Kube Bêta Test",
"errors": [], "errors": [],
"success": true "success": true
=======
>>>>>>> Stashed changes
} }
] ]

View file

@ -3,10 +3,14 @@ import sys
import time import time
import functools import functools
import asyncio import asyncio
import json
import os
from datetime import datetime from datetime import datetime
from typing import Any, Callable from typing import Any, Callable
import traceback import traceback
REPORTED_ERRORS_FILE = "data/automated_reports.json"
class KubyLogger: class KubyLogger:
def __init__(self, name="KubyBot"): def __init__(self, name="KubyBot"):
self.logger = logging.getLogger(name) self.logger = logging.getLogger(name)
@ -53,37 +57,69 @@ class KubyLogger:
def __init__(self, client): def __init__(self, client):
super().__init__() super().__init__()
self.client = client self.client = client
self.last_errors = {}
self.cooldown = 3600 # 1 hour cooldown per unique error 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): def emit(self, record):
if record.levelno >= logging.ERROR: if record.levelno >= logging.ERROR:
# Extract message and first line of traceback if available
msg = record.getMessage() msg = record.getMessage()
error_key = msg # Use message + location + simplified traceback for unique key
if record.exc_info: # This key is used for the local cooldown
error_key += str(record.exc_info[1]) error_key = f"{record.name}.{record.funcName}:{record.lineno} - {msg}"
current_time = time.time() current_time = time.time()
if error_key in self.last_errors and current_time - self.last_errors[error_key] < self.cooldown: last_time = self.last_errors.get(error_key, 0)
return # Skip if same error within cooldown
if current_time - last_time < self.cooldown:
return
self.last_errors[error_key] = current_time 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"**Type:** {record.levelname}\n"
description += f"**Message:** {msg}\n" description += f"**Message:** {msg}\n"
description += f"**Source:** {record.name}.{record.funcName}:{record.lineno}\n\n" description += f"**Source:** {record.name}.{record.funcName}:{record.lineno}\n\n"
if record.exc_info: if record.exc_info:
description += f"**Traceback:**\n```python\n{traceback.format_exc()}\n```" 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() loop = asyncio.get_event_loop()
if loop.is_running(): if loop.is_running():
loop.create_task(self.client.create_issue(title, description, labels=["bug", "auto-report"])) loop.create_task(report_flow(self.client, title, description, labels))
else:
# Fallback if loop is not running (rare for this bot)
pass
gitlab_handler = GitLabHandler(gitlab_client) gitlab_handler = GitLabHandler(gitlab_client)
gitlab_handler.setLevel(logging.ERROR) gitlab_handler.setLevel(logging.ERROR)

View file

@ -63,6 +63,35 @@ class GitLabClient:
gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}") gitlab_internal_logger.error(f"❌ Error connecting to GitLab API: {e}")
return None 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]: async def get_closed_issues(self) -> List[dict]:
""" """
Fetches recently closed issues. Fetches recently closed issues.