2026-05-06 17:07:09 +02:00
import disnake
from disnake . ext import commands
2026-07-11 23:11:48 +02:00
from utils . forgejo_client import gitlab_client
2026-03-14 17:05:20 +01:00
from aiohttp import web
2026-02-07 16:03:16 +01:00
import logging
import json
import os
import asyncio
2026-07-12 18:12:16 +02:00
import re
2026-06-07 16:22:17 +02:00
from typing import Dict , Any , Optional
2026-07-06 16:08:14 +02:00
from utils . premium import check_premium_tier
2026-02-07 16:03:16 +01:00
kuby_logger = logging . getLogger ( " KubyBot " )
2026-03-29 19:29:17 +02:00
BASE_DIR = os . path . dirname ( os . path . dirname ( os . path . abspath ( __file__ ) ) )
2026-06-04 08:40:14 +02:00
# ⚡ NOUVEAU: Utilise le même fichier que gitlab_integration.py
TRACKING_FILE = os . path . join ( BASE_DIR , " commandes " , " gitlab_issues.json " )
LEGACY_REPORTS_FILE = os . path . join ( BASE_DIR , " data " , " gitlab_reports.json " )
2026-05-19 18:50:16 +02:00
2026-07-11 23:50:19 +02:00
def classify_webhook_event ( payload : Dict [ str , Any ] ) - > Optional [ str ] :
""" Détermine si un webhook correspond à une issue ou à un commentaire. """
object_kind = payload . get ( " object_kind " )
if object_kind in { " issue " , " note " } :
return object_kind
2026-07-12 00:10:09 +02:00
action = str ( payload . get ( " action " ) or payload . get ( " event " ) or " " ) . lower ( )
if action in { " comment_created " , " comment_updated " , " commented " , " issue_comment_created " , " issue_comment_updated " } :
2026-07-11 23:50:19 +02:00
return " note "
2026-07-12 00:10:09 +02:00
if " comment " in payload or payload . get ( " comment " ) or " comment " in action :
return " note "
2026-07-12 15:01:47 +02:00
if payload . get ( " issue " ) or payload . get ( " object_attributes " ) or action in { " open " , " reopen " , " close " , " closed " , " updated " , " created " , " edited " , " label_updated " , " label_added " , " label_removed " , " label_changed " } :
2026-07-11 23:50:19 +02:00
return " issue "
return None
def extract_issue_iid ( payload : Dict [ str , Any ] ) - > Optional [ str ] :
""" Extrait l’ identifiant d’ issue depuis plusieurs formats Forgejo/GitLab. """
for container in ( payload . get ( " object_attributes " , { } ) , payload . get ( " issue " , { } ) , payload . get ( " comment " , { } ) ) :
if not isinstance ( container , dict ) :
continue
for key in ( " iid " , " number " , " id " ) :
value = container . get ( key )
if value not in ( None , " " ) :
return str ( value )
return None
2026-07-12 18:12:16 +02:00
def extract_issue_identifier ( issue_payload : Optional [ Any ] ) - > Optional [ str ] :
""" Récupère un identifiant stable à stocker dans le tracking, même si le payload est imbriqué. """
if issue_payload is None :
2026-07-11 23:50:19 +02:00
return None
2026-07-12 18:12:16 +02:00
if isinstance ( issue_payload , dict ) :
for key in ( " iid " , " number " , " id " , " issue_id " ) :
value = issue_payload . get ( key )
if value not in ( None , " " ) :
return str ( value )
for key in ( " html_url " , " web_url " , " url " ) :
value = issue_payload . get ( key )
if isinstance ( value , str ) :
match = re . search ( r " /(?:issues|tickets)/([0-9]+) " , value )
if match :
return match . group ( 1 )
for value in issue_payload . values ( ) :
found = extract_issue_identifier ( value )
if found :
return found
return None
if isinstance ( issue_payload , list ) :
for item in issue_payload :
found = extract_issue_identifier ( item )
if found :
return found
2026-07-11 23:50:19 +02:00
return None
2026-07-12 12:26:01 +02:00
def normalize_labels ( labels : Any ) - > list [ str ] :
""" Normalise une liste de labels provenant de Forgejo/GitLab. """
if not labels :
return [ ]
if isinstance ( labels , str ) :
return [ labels ]
if isinstance ( labels , list ) :
normalized = [ ]
for item in labels :
if isinstance ( item , dict ) :
name = item . get ( " name " ) or item . get ( " title " ) or item . get ( " label " ) or item . get ( " id " )
else :
name = item
if name is None :
continue
name = str ( name ) . strip ( )
if name and name not in normalized :
normalized . append ( name )
return normalized
return [ ]
def extract_label_change ( payload : Dict [ str , Any ] ) - > Optional [ Dict [ str , Any ] ] :
""" Extrait le changement de labels d’ un payload d’ issue. """
2026-07-12 15:01:47 +02:00
action = str ( payload . get ( " action " ) or payload . get ( " event " ) or " " ) . lower ( )
if not action . startswith ( " label " ) :
2026-07-12 12:26:01 +02:00
return None
2026-07-12 15:01:47 +02:00
issue = payload . get ( " issue " ) or { }
current_labels = normalize_labels ( issue . get ( " labels " ) or payload . get ( " labels " ) )
if not current_labels :
2026-07-12 12:26:01 +02:00
return None
2026-07-12 15:01:47 +02:00
label_payload = payload . get ( " label " )
label_name = None
if isinstance ( label_payload , dict ) :
label_name = label_payload . get ( " name " ) or label_payload . get ( " title " ) or label_payload . get ( " label " )
elif isinstance ( label_payload , str ) :
label_name = label_payload
previous_labels = [ ]
if isinstance ( payload . get ( " changes " ) , dict ) :
prev = payload [ " changes " ] . get ( " labels " ) or payload [ " changes " ] . get ( " label " )
if isinstance ( prev , dict ) :
previous_labels = normalize_labels ( prev . get ( " previous " ) )
if not previous_labels and issue . get ( " labels " ) :
previous_labels = [ ]
if action in { " label_removed " , " label_deleted " } :
return {
" previous " : previous_labels or ( [ label_name ] if label_name else [ ] ) ,
" current " : current_labels ,
" added " : [ ] ,
" removed " : [ label_name ] if label_name else [ ] ,
}
added = [ label for label in current_labels if label not in previous_labels ]
removed = [ label for label in previous_labels if label not in current_labels ]
if not added and not removed and label_name :
added = [ label_name ]
2026-07-12 12:26:01 +02:00
return {
2026-07-12 15:01:47 +02:00
" previous " : previous_labels ,
" current " : current_labels ,
2026-07-12 12:26:01 +02:00
" added " : added ,
" removed " : removed ,
}
def calculate_progress_from_labels ( labels : list [ str ] ) - > float :
""" Estime une progression visuelle à partir des labels présents. """
if not labels :
return 0.1
normalized = [ label . lower ( ) for label in labels ]
2026-07-12 15:59:29 +02:00
if any ( token in normalized for token in [ " done " , " resolved " , " fixed " , " closed " , " terminé " , " résolu " , " ok " , " completed " ] ) :
2026-07-12 12:26:01 +02:00
return 1.0
2026-07-12 15:59:29 +02:00
if any ( token in normalized for token in [ " review " , " ready " , " qa " , " testing " , " test " , " validated " , " validation " , " verif " , " vérif " ] ) :
2026-07-12 12:26:01 +02:00
return 0.8
2026-07-12 15:59:29 +02:00
if any ( token in normalized for token in [ " in progress " , " working " , " wip " , " develop " , " dev " , " investigating " , " progress " , " travail " ] ) :
2026-07-12 12:26:01 +02:00
return 0.6
2026-07-12 15:59:29 +02:00
if any ( token in normalized for token in [ " confirmed " , " accepted " , " triaged " , " priority " , " urgent " , " prioritaire " ] ) :
2026-07-12 12:26:01 +02:00
return 0.4
2026-07-12 15:59:29 +02:00
if any ( token in normalized for token in [ " bug " , " enhancement " , " feature " , " manual " , " new " , " suggestion " ] ) :
2026-07-12 12:26:01 +02:00
return 0.2
return 0.3
2026-07-12 15:59:29 +02:00
def build_status_message_from_labels ( labels : list [ str ] ) - > str :
""" Traduit les labels en un message utilisateur lisible. """
if not labels :
return " ⏳ La demande évolue et l ' équipe suit son avancement. "
normalized = [ str ( label ) . lower ( ) for label in labels if str ( label ) . strip ( ) ]
if any ( token in normalized for token in [ " done " , " resolved " , " fixed " , " closed " , " terminé " , " résolu " , " completed " , " ok " ] ) :
return " ✅ La demande a été traitée et est prête à être déployée. "
if any ( token in normalized for token in [ " review " , " qa " , " testing " , " test " , " validated " , " validation " , " verif " , " vérif " ] ) :
return " 🔎 La demande est en cours de vérification par l ' équipe. "
if any ( token in normalized for token in [ " in progress " , " working " , " wip " , " develop " , " dev " , " investigating " , " progress " , " travail " ] ) :
return " 🛠️ L ' équipe est en train de travailler dessus. "
if any ( token in normalized for token in [ " confirmed " , " accepted " , " triaged " , " priority " , " urgent " , " prioritaire " ] ) :
return " 🗂️ La demande a bien été prise en compte et est en cours d ' analyse. "
if any ( token in normalized for token in [ " bug " , " enhancement " , " feature " , " manual " , " new " , " suggestion " ] ) :
return " 📝 La demande a été reçue et sera étudiée prochainement. "
return " ⏳ La demande évolue et l ' équipe suit son avancement. "
2026-07-12 12:26:01 +02:00
def build_progress_bar ( progress : float , width : int = 18 ) - > str :
""" Construit une barre de progression stylée en Unicode. """
progress = max ( 0.0 , min ( 1.0 , progress ) )
filled = int ( round ( progress * width ) )
filled = max ( 0 , min ( width , filled ) )
empty = width - filled
percent = int ( progress * 100 )
return f " { ' ▰ ' * filled } { ' ▱ ' * empty } { percent } % "
def color_from_progress ( progress : float ) - > disnake . Color :
""" Retourne une couleur violet→bleu selon la progression. """
progress = max ( 0.0 , min ( 1.0 , progress ) )
purple = ( 117 , 82 , 255 )
blue = ( 74 , 144 , 255 )
r = int ( purple [ 0 ] + ( blue [ 0 ] - purple [ 0 ] ) * progress )
g = int ( purple [ 1 ] + ( blue [ 1 ] - purple [ 1 ] ) * progress )
b = int ( purple [ 2 ] + ( blue [ 2 ] - purple [ 2 ] ) * progress )
return disnake . Color . from_rgb ( r , g , b )
2026-07-12 18:12:16 +02:00
def normalize_tracking_issue_id ( issue_iid : Any ) - > Optional [ str ] :
""" Normalise un identifiant d’ issue pour le tracking, en ne conservant que les valeurs numériques valides. """
if issue_iid is None :
return None
if isinstance ( issue_iid , int ) :
return str ( issue_iid )
if isinstance ( issue_iid , str ) :
normalized = issue_iid . strip ( )
if not normalized :
return None
if normalized . lower ( ) in { " none " , " null " , " nan " , " undefined " } :
return None
if re . fullmatch ( r " \ d+ " , normalized ) :
return normalized
return None
2026-07-12 18:23:30 +02:00
def extract_requester_id_from_payload ( payload : Dict [ str , Any ] ) - > Optional [ int ] :
""" Essaie d ' extraire l ' ID Discord du demandeur depuis la description du ticket ou le payload webhook. """
text_candidates = [ ]
for key in ( " description " , " body " , " content " ) :
value = payload . get ( key )
if isinstance ( value , str ) and value . strip ( ) :
text_candidates . append ( value )
issue_payload = payload . get ( " issue " ) or { }
for key in ( " description " , " body " , " content " ) :
value = issue_payload . get ( key )
if isinstance ( value , str ) and value . strip ( ) :
text_candidates . append ( value )
object_attributes = payload . get ( " object_attributes " ) or { }
for key in ( " description " , " body " , " content " ) :
value = object_attributes . get ( key )
if isinstance ( value , str ) and value . strip ( ) :
text_candidates . append ( value )
for text in text_candidates :
matches = re . findall ( r " \ b( \ d { 15,20}) \ b " , text )
if matches :
return int ( matches [ 0 ] )
return None
2026-06-04 08:40:14 +02:00
def load_tracking ( ) :
""" Charge le tracking depuis gitlab_issues.json (avec migration automatique) """
tracking = { }
if os . path . exists ( TRACKING_FILE ) :
try :
with open ( TRACKING_FILE , " r " , encoding = " utf-8 " ) as f :
tracking = json . load ( f )
except Exception as e :
kuby_logger . error ( f " Erreur chargement gitlab_issues.json: { e } " )
# Migration automatique depuis l'ancien fichier
if os . path . exists ( LEGACY_REPORTS_FILE ) :
try :
with open ( LEGACY_REPORTS_FILE , " r " , encoding = " utf-8 " ) as f :
legacy_data = json . load ( f )
2026-07-12 18:12:16 +02:00
migrated_count = 0
for raw_issue_iid , user_id in legacy_data . items ( ) :
issue_iid = normalize_tracking_issue_id ( raw_issue_iid )
if not issue_iid :
kuby_logger . debug ( f " Ignoré pendant la migration de tracking: issue_id= { raw_issue_iid } " )
continue
if issue_iid in tracking :
continue
tracking [ issue_iid ] = {
" requester_id " : user_id ,
" last_note_id " : 0 ,
" type " : " legacy_report "
}
migrated_count + = 1
kuby_logger . info ( f " Migration automatique: { migrated_count } issue(s) fusionnée(s) depuis l ' historique " )
2026-06-04 08:40:14 +02:00
except Exception as e :
kuby_logger . error ( f " Erreur migration gitlab_reports.json: { e } " )
return tracking
2026-05-19 18:50:16 +02:00
2026-06-04 08:40:14 +02:00
def save_tracking ( data : Dict [ str , Any ] ) - > None :
""" Sauvegarde dans gitlab_issues.json """
os . makedirs ( os . path . dirname ( TRACKING_FILE ) , exist_ok = True )
temp_file = f " { TRACKING_FILE } .tmp "
with open ( temp_file , " w " , encoding = " utf-8 " ) as f :
json . dump ( data , f , indent = 2 )
os . replace ( temp_file , TRACKING_FILE )
2026-02-07 16:03:16 +01:00
2026-05-19 18:50:16 +02:00
2026-05-06 17:07:09 +02:00
class BugReportModal ( disnake . ui . Modal ) :
2026-06-04 08:40:14 +02:00
def __init__ ( self , cog ) :
self . cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking
components = [
disnake . ui . TextInput (
label = " Titre du bug " ,
placeholder = " Décrivez brièvement le problème... " ,
custom_id = " bug_title " ,
required = True ,
max_length = 100 ,
) ,
disnake . ui . TextInput (
label = " Description détaillée " ,
style = disnake . TextInputStyle . paragraph ,
placeholder = " Que s ' est-il passé ? " ,
custom_id = " bug_description " ,
required = True ,
max_length = 1000 ,
) ,
]
2026-05-19 20:05:23 +02:00
super ( ) . __init__ (
title = " Signaler un Bug " ,
2026-06-04 08:40:14 +02:00
custom_id = " bug_modal " ,
components = components ,
2026-05-19 20:05:23 +02:00
)
async def callback ( self , interaction : disnake . ModalInteraction ) :
2026-02-07 16:03:16 +01:00
await interaction . response . send_message ( " Envoi de votre rapport de bug à GitLab... " , ephemeral = True )
2026-05-19 18:50:16 +02:00
2026-05-19 20:05:23 +02:00
bug_title = ( interaction . text_values . get ( " bug_title " ) or " " ) . strip ( )
2026-05-19 18:50:16 +02:00
if not bug_title :
bug_title = " Sans titre "
2026-05-23 15:11:47 +02:00
priority_name = " Normale "
priority_value = " Normal "
2026-05-19 20:05:23 +02:00
description_value = interaction . text_values . get ( " bug_description " ) or " "
2026-05-19 18:50:16 +02:00
2026-02-07 16:03:16 +01:00
description_text = f " **Rapporté par:** { interaction . user } ( { interaction . user . id } ) \n "
2026-05-19 18:50:16 +02:00
description_text + = f " **Priorité:** { priority_name } \n \n "
description_text + = f " **Description:** \n { description_value } "
labels = [ " bug " , " manual-report " , f " Priority:: { priority_value } " ]
2026-02-07 16:03:16 +01:00
result = await gitlab_client . create_issue (
2026-05-19 18:50:16 +02:00
title = f " [MANUAL] { bug_title } " ,
2026-02-07 16:03:16 +01:00
description = description_text ,
2026-05-19 18:50:16 +02:00
labels = labels ,
2026-02-07 16:03:16 +01:00
)
2026-05-19 18:50:16 +02:00
2026-02-07 16:03:16 +01:00
if result :
2026-07-11 23:25:25 +02:00
issue_iid = extract_issue_identifier ( result )
if not issue_iid :
2026-07-12 18:12:16 +02:00
kuby_logger . warning ( " Impossible de déterminer l’ ID de l’ issue créée depuis le retour Forgejo/GitLab. Tentative de fallback par titre. " )
lookup = await gitlab_client . find_issue_by_title ( f " [MANUAL] { bug_title } " , state = " open " )
issue_iid = extract_issue_identifier ( lookup )
if not issue_iid :
kuby_logger . warning ( " Impossible de déterminer l’ ID de l’ issue créée après fallback. Payload reçu: %s " , result )
2026-07-11 23:25:25 +02:00
await interaction . edit_original_response ( content = " ✅ Votre rapport a été envoyé, mais l’ identifiant de l’ issue n’ a pas pu être récupéré automatiquement. " )
return
2026-06-04 08:40:14 +02:00
self . cog . issue_data [ issue_iid ] = {
" requester_id " : interaction . user . id ,
" last_note_id " : 0 ,
" type " : " manual-report "
}
save_tracking ( self . cog . issue_data )
2026-05-19 18:50:16 +02:00
await interaction . edit_original_response (
content = (
" ✅ Votre bug a été signalé avec succès ! "
2026-05-19 21:19:43 +02:00
" Nous avons bien été averti et le bug sera réglé dans la prochaine mise à jour. "
2026-07-11 23:25:25 +02:00
f " [Voir l ' issue]( { result . get ( ' web_url ' ) or result . get ( ' html_url ' ) } ) "
2026-05-19 18:50:16 +02:00
)
)
2026-02-07 16:03:16 +01:00
else :
2026-05-19 18:50:16 +02:00
await interaction . edit_original_response (
content = " ❌ Une erreur est survenue lors de l ' envoi du rapport à GitLab. Veuillez contacter un administrateur. "
)
2026-02-07 16:03:16 +01:00
2026-05-06 17:07:09 +02:00
class FeatureSuggestionModal ( disnake . ui . Modal ) :
2026-06-04 08:40:14 +02:00
def __init__ ( self , cog ) :
self . cog = cog # ⚡ NOUVEAU: Reçoit le cog pour tracking
components = [
disnake . ui . TextInput (
label = " Titre de la suggestion " ,
placeholder = " Que voulez-vous ajouter ? " ,
custom_id = " suggestion_title " ,
required = True ,
max_length = 100 ,
) ,
disnake . ui . TextInput (
label = " Détails de la fonctionnalité " ,
style = disnake . TextInputStyle . paragraph ,
placeholder = " Décrivez comment cela devrait fonctionner... " ,
custom_id = " suggestion_description " ,
required = True ,
max_length = 1000 ,
) ,
]
2026-05-19 20:05:23 +02:00
super ( ) . __init__ (
title = " Suggérer une fonctionnalité " ,
2026-06-04 08:40:14 +02:00
custom_id = " suggestion_modal " ,
components = components ,
2026-05-19 20:05:23 +02:00
)
async def callback ( self , interaction : disnake . ModalInteraction ) :
2026-02-08 14:42:40 +01:00
await interaction . response . send_message ( " Envoi de votre suggestion à GitLab... " , ephemeral = True )
2026-05-19 18:50:16 +02:00
2026-05-19 20:05:23 +02:00
suggestion_title = ( interaction . text_values . get ( " suggestion_title " ) or " " ) . strip ( ) or " Sans titre "
description_value = interaction . text_values . get ( " suggestion_description " ) or " "
2026-05-19 18:50:16 +02:00
2026-05-23 15:13:56 +02:00
description_text = f " **Sugéré par:** { interaction . user } ( { interaction . user . id } ) \n \n "
2026-05-19 18:50:16 +02:00
description_text + = f " **Description:** \n { description_value } "
2026-02-08 14:42:40 +01:00
labels = [ " enhancement " , " manual-suggestion " ]
2026-05-19 18:50:16 +02:00
2026-02-08 14:42:40 +01:00
result = await gitlab_client . create_issue (
2026-05-19 18:50:16 +02:00
title = f " [SUGGESTION] { suggestion_title } " ,
2026-02-08 14:42:40 +01:00
description = description_text ,
2026-05-19 18:50:16 +02:00
labels = labels ,
2026-02-08 14:42:40 +01:00
)
2026-05-19 18:50:16 +02:00
2026-02-08 14:42:40 +01:00
if result :
2026-07-11 23:25:25 +02:00
issue_iid = extract_issue_identifier ( result )
if not issue_iid :
2026-07-12 18:12:16 +02:00
kuby_logger . warning ( " Impossible de déterminer l’ ID de l’ issue créée depuis le retour Forgejo/GitLab. Tentative de fallback par titre. " )
lookup = await gitlab_client . find_issue_by_title ( f " [SUGGESTION] { suggestion_title } " , state = " open " )
issue_iid = extract_issue_identifier ( lookup )
if not issue_iid :
kuby_logger . warning ( " Impossible de déterminer l’ ID de l’ issue créée après fallback. Payload reçu: %s " , result )
2026-07-11 23:25:25 +02:00
await interaction . edit_original_response ( content = " ✅ Votre suggestion a été envoyée, mais l’ identifiant de l’ issue n’ a pas pu être récupéré automatiquement. " )
return
2026-06-04 08:40:14 +02:00
self . cog . issue_data [ issue_iid ] = {
" requester_id " : interaction . user . id ,
" last_note_id " : 0 ,
" type " : " manual-suggestion "
}
save_tracking ( self . cog . issue_data )
2026-05-19 18:50:16 +02:00
await interaction . edit_original_response (
content = (
" ✅ Votre suggestion a été envoyée avec succès ! "
" Merci de contribuer à l ' amélioration du bot. "
2026-07-11 23:25:25 +02:00
f " [Voir l ' issue]( { result . get ( ' web_url ' ) or result . get ( ' html_url ' ) } ) "
2026-05-19 18:50:16 +02:00
)
)
2026-02-08 14:42:40 +01:00
else :
2026-05-19 18:50:16 +02:00
await interaction . edit_original_response (
content = " ❌ Une erreur est survenue lors de l ' envoi de la suggestion à GitLab. "
)
2026-02-08 14:42:40 +01:00
2026-06-04 08:40:14 +02:00
class BugReportView ( disnake . ui . View ) :
def __init__ ( self , cog ) :
self . cog = cog # ⚡ NOUVEAU: Reçoit le cog
super ( ) . __init__ ( timeout = None )
@disnake.ui.button ( label = " 🎫 Remplir le formulaire " , style = disnake . ButtonStyle . primary , custom_id = " open_bug_form " )
async def open_bug_form ( self , button : disnake . ui . Button , interaction : disnake . MessageInteraction ) :
modal = BugReportModal ( self . cog )
await interaction . response . send_modal ( modal )
2026-05-23 15:13:56 +02:00
class FeatureSuggestionView ( disnake . ui . View ) :
2026-06-04 08:40:14 +02:00
def __init__ ( self , cog ) :
self . cog = cog # ⚡ NOUVEAU: Reçoit le cog
2026-05-23 15:13:56 +02:00
super ( ) . __init__ ( timeout = None )
@disnake.ui.button ( label = " 🎫 Remplir le formulaire " , style = disnake . ButtonStyle . green , custom_id = " open_suggestion_form " )
async def open_suggestion_form ( self , button : disnake . ui . Button , interaction : disnake . MessageInteraction ) :
2026-06-04 08:40:14 +02:00
modal = FeatureSuggestionModal ( self . cog )
2026-05-23 15:13:56 +02:00
await interaction . response . send_modal ( modal )
2026-02-07 16:03:16 +01:00
class BugReport ( commands . Cog ) :
def __init__ ( self , bot ) :
self . bot = bot
2026-06-04 08:40:14 +02:00
self . issue_data = load_tracking ( ) # ⚡ NOUVEAU: Charge le tracking unifié
2026-05-20 15:49:40 +02:00
async def cog_load ( self ) :
2026-06-03 16:10:36 +02:00
kuby_logger . info ( " BugReport cog chargé. Démarrage du serveur de relais interne (port 5001)... " )
2026-06-04 08:40:14 +02:00
REPORTS_DIR = os . path . dirname ( LEGACY_REPORTS_FILE )
2026-05-25 19:08:45 +02:00
os . makedirs ( REPORTS_DIR , exist_ok = True )
2026-06-03 16:10:36 +02:00
await self . _start_webhook_server ( )
2026-05-20 15:23:09 +02:00
2026-07-12 18:17:40 +02:00
pending_count = await self . _send_pending_deployment_notifications ( )
if pending_count :
kuby_logger . info ( f " Notifications de déploiement en attente traitées au démarrage: { pending_count } " )
2026-06-07 16:22:17 +02:00
# ⚡ Enregistrement des vues persistantes (boutons panel)
# Sans ça, après un redémarrage les boutons "Remplir le formulaire"
# dans les anciens messages de panel ne fonctionnent plus.
self . bot . add_view ( BugReportView ( self ) )
self . bot . add_view ( FeatureSuggestionView ( self ) )
kuby_logger . info ( " ✅ Vues persistantes BugReport enregistrées (open_bug_form, open_suggestion_form) " )
2026-05-20 15:23:09 +02:00
async def _start_webhook_server ( self ) :
2026-06-03 16:10:36 +02:00
try :
app = web . Application ( )
app . router . add_post ( ' /bot_event ' , self . handle_bot_event )
2026-07-12 15:59:29 +02:00
app . router . add_post ( ' /deployment_complete ' , self . handle_deployment_complete )
2026-06-03 16:10:36 +02:00
self . runner = web . AppRunner ( app )
await self . runner . setup ( )
2026-06-07 16:40:46 +02:00
self . site = web . TCPSite ( self . runner , ' 127.0.0.1 ' , 5001 , reuse_address = True )
2026-06-03 16:10:36 +02:00
await self . site . start ( )
kuby_logger . info ( " Internal Bot Webhook Server started on 127.0.0.1:5001 " )
except Exception as e :
kuby_logger . error ( f " Failed to start Internal Bot Webhook Server: { e } " )
2026-02-07 16:03:16 +01:00
2026-03-14 17:05:20 +01:00
def cog_unload ( self ) :
if hasattr ( self , ' runner ' ) :
2026-03-28 21:58:44 +01:00
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 ( ) )
2026-02-07 16:03:16 +01:00
2026-07-12 18:17:40 +02:00
async def _send_pending_deployment_notifications ( self ) - > int :
""" Envoie les notifications de déploiement en attente depuis le tracking. """
notified_count = 0
for issue_iid , issue_data in list ( self . issue_data . items ( ) ) :
if not issue_data . get ( " pending_deployment_notification " ) :
kuby_logger . debug ( f " No pending deployment notification for issue # { issue_iid } " )
continue
requester_id = issue_data . get ( " requester_id " )
if not requester_id :
kuby_logger . warning ( f " Missing requester_id for pending deployment notification on issue # { issue_iid } " )
continue
kuby_logger . info ( f " Processing deployment notification for issue # { issue_iid } to requester_id= { requester_id } " )
user = self . bot . get_user ( requester_id )
if not user :
try :
user = await self . bot . fetch_user ( requester_id )
kuby_logger . info ( f " Fetched user { user } for issue # { issue_iid } " )
except Exception as fetch_error :
kuby_logger . warning ( f " Unable to fetch user { requester_id } for issue # { issue_iid } : { fetch_error } " )
continue
else :
kuby_logger . info ( f " Resolved cached user { user } for issue # { issue_iid } " )
try :
embed = disnake . Embed (
title = " 🚀 Fonctionnalité disponible ! " ,
description = (
" ✅ Votre demande est désormais fonctionnelle sur le bot. \n \n "
" Merci encore pour votre contribution et votre patience. "
) ,
color = disnake . Color . blurple ( )
)
avatar = getattr ( self . bot . user , " display_avatar " , None )
if avatar :
embed . set_thumbnail ( url = avatar . url )
kuby_logger . info ( f " Attempting to DM deployment notification to { user } for issue # { issue_iid } " )
await user . send ( embed = embed )
issue_data [ " pending_deployment_notification " ] = False
notified_count + = 1
kuby_logger . info ( f " Notification de déploiement envoyée à { user } pour l ' issue # { issue_iid } " )
except disnake . Forbidden as forbidden_error :
kuby_logger . warning ( f " DM forbidden for { user } on issue # { issue_iid } : { forbidden_error } " )
except disnake . HTTPException as http_error :
kuby_logger . warning ( f " Discord HTTP error while sending deployment notification to { user } on issue # { issue_iid } : { http_error } " )
except Exception as e :
kuby_logger . warning ( f " Impossible d ' envoyer la notification de déploiement à { user } pour l ' issue # { issue_iid } : { e } " )
save_tracking ( self . issue_data )
kuby_logger . info ( f " Déploiement terminé: { notified_count } notification(s) envoyée(s) " )
return notified_count
2026-07-12 15:59:29 +02:00
async def handle_deployment_complete ( self , request ) :
""" Notifie les demandeurs dont la fonctionnalité a été intégrée et attend le prochain déploiement. """
try :
2026-07-12 16:44:47 +02:00
try :
await request . json ( )
except Exception :
try :
raw_body = await request . text ( )
if raw_body and raw_body . strip ( ) :
json . loads ( raw_body )
except Exception :
pass
2026-07-12 18:17:40 +02:00
notified_count = await self . _send_pending_deployment_notifications ( )
2026-07-12 17:47:52 +02:00
return web . json_response ( { " status " : " notified " , " sent " : notified_count } , status = 200 )
2026-07-12 15:59:29 +02:00
except Exception as e :
kuby_logger . error ( f " Error handling deployment complete: { e } " )
return web . json_response ( { " status " : " error " , " message " : str ( e ) } , status = 500 )
2026-03-14 17:05:20 +01:00
async def handle_bot_event ( self , request ) :
2026-07-11 23:11:48 +02:00
""" Gère les webhooks (adapté pour GitLab ET Forgejo) """
2026-02-07 16:03:16 +01:00
try :
2026-03-29 19:29:17 +02:00
if not self . bot . is_ready ( ) :
kuby_logger . info ( " Webhook received but bot is not ready yet. Waiting up to 10s... " )
try :
await asyncio . wait_for ( self . bot . wait_until_ready ( ) , timeout = 10.0 )
except asyncio . TimeoutError :
2026-07-11 23:11:48 +02:00
kuby_logger . warning ( " Bot still not ready after 10s. Proceeding anyway. " )
2026-03-29 19:29:17 +02:00
2026-03-14 17:05:20 +01:00
payload = await request . json ( )
2026-07-12 00:10:09 +02:00
event_type = classify_webhook_event ( payload )
2026-03-14 17:05:20 +01:00
if event_type not in [ " issue " , " note " ] :
2026-07-12 00:10:09 +02:00
kuby_logger . info (
" Webhook ignoré : type d’ événement non reconnu (keys= %s action= %s object_kind= %s ) " ,
list ( payload . keys ( ) ) ,
payload . get ( " action " ) or payload . get ( " event " ) ,
payload . get ( " object_kind " ) ,
)
2026-03-14 17:05:20 +01:00
return web . json_response ( { " status " : " ignored " } , status = 200 )
2026-07-11 23:31:34 +02:00
# Récupère l'IID de l'issue
2026-07-12 00:10:09 +02:00
issue_iid = extract_issue_iid ( payload )
2026-03-14 17:05:20 +01:00
if not issue_iid :
2026-07-11 23:11:48 +02:00
kuby_logger . warning ( " Webhook ignoré : aucun identifiant d’ issue trouvé dans le payload " )
2026-03-14 17:05:20 +01:00
return web . json_response ( { " status " : " no issue id " } , status = 200 )
2026-06-04 08:40:14 +02:00
issue_data = self . issue_data . get ( issue_iid )
if not issue_data :
2026-07-12 18:23:30 +02:00
inferred_requester_id = extract_requester_id_from_payload ( payload )
if inferred_requester_id is None :
kuby_logger . warning ( f " Webhook issue # { issue_iid } without tracked requester_id and no Discord ID found in payload " )
return web . json_response ( { " status " : " untracked issue " } , status = 200 )
self . issue_data [ issue_iid ] = {
" requester_id " : inferred_requester_id ,
" last_note_id " : 0 ,
" type " : " webhook-recovered "
}
save_tracking ( self . issue_data )
issue_data = self . issue_data [ issue_iid ]
kuby_logger . info ( f " Tracking entry recreated for issue # { issue_iid } from webhook payload " )
2026-06-04 08:40:14 +02:00
user_id = issue_data . get ( " requester_id " )
if not user_id :
return web . json_response ( { " status " : " no requester " } , status = 200 )
2026-03-14 17:05:20 +01:00
user = self . bot . get_user ( user_id )
if not user :
try :
user = await self . bot . fetch_user ( user_id )
except :
2026-06-04 08:40:14 +02:00
return web . json_response ( { " status " : " user not found " } , status = 200 )
2026-07-11 23:11:48 +02:00
# 🕵️ 3. RÉCUPÉRATION DU TITRE
issue_title = (
2026-07-12 00:10:09 +02:00
payload . get ( " object_attributes " , { } ) . get ( " title " ) or
payload . get ( " issue " , { } ) . get ( " title " ) or
payload . get ( " issue " , { } ) . get ( " name " ) or
f " # { issue_iid } "
2026-07-11 23:11:48 +02:00
)
2026-07-12 00:10:09 +02:00
2026-03-14 17:05:20 +01:00
if event_type == " note " :
2026-06-04 08:40:14 +02:00
# ⚡ NOUVEAU: Notifie les commentaires via le webhook (instantané)
2026-07-12 00:10:09 +02:00
comment_payload = payload . get ( " comment " )
if isinstance ( comment_payload , dict ) :
note_body = comment_payload . get ( " body " ) or comment_payload . get ( " content " ) or " "
author_name = (
payload . get ( " sender " , { } ) . get ( " username " )
or payload . get ( " sender " , { } ) . get ( " login " )
or payload . get ( " user " , { } ) . get ( " name " )
or " Développeur "
)
is_system = False
else :
note_attr = payload . get ( " object_attributes " , { } )
is_system = note_attr . get ( " system " , False )
2026-06-04 08:40:14 +02:00
note_body = (
note_attr . get ( " note " ) or
note_attr . get ( " body " ) or
2026-07-11 23:11:48 +02:00
payload . get ( " note " , " " ) or " "
2026-06-04 08:40:14 +02:00
)
author_name = payload . get ( " user " , { } ) . get ( " name " , " Développeur " )
2026-07-12 00:10:09 +02:00
if not is_system and note_body :
try :
embed = disnake . Embed (
title = " 💬 Nouveau commentaire sur votre signalement " ,
description = f " Le développeur a répondu à votre rapport ** { issue_title } ** : \n \n >>> { note_body } \n \n ✍️ **Par :** { author_name } " ,
color = disnake . Color . orange ( )
)
embed . set_thumbnail ( url = self . bot . user . display_avatar . url )
await user . send ( embed = embed )
kuby_logger . info ( f " Commentaire envoyé à { user } pour l ' issue # { issue_iid } " )
except ( disnake . Forbidden , disnake . HTTPException ) as e :
2026-06-04 08:40:14 +02:00
kuby_logger . warning ( f " Impossible d ' envoyer le DM à { user } : { e } " )
if dev and dev . id != user . id :
try :
await dev . send ( f " ⚠️ [DM BLOQUÉ] Impossible d ' envoyer le commentaire à { user } pour l ' issue # { issue_iid } " )
except :
pass
2026-07-11 23:11:48 +02:00
elif event_type == " issue " :
2026-07-12 18:05:37 +02:00
object_attributes = payload . get ( " object_attributes " ) or { }
issue_payload = payload . get ( " issue " ) or { }
action = ( object_attributes . get ( " action " ) or issue_payload . get ( " action " ) or payload . get ( " action " ) or " " ) . lower ( )
state = ( object_attributes . get ( " state " ) or issue_payload . get ( " state " ) or payload . get ( " state " ) or " " ) . lower ( )
2026-07-12 12:26:01 +02:00
label_change = extract_label_change ( payload )
2026-07-12 18:05:37 +02:00
kuby_logger . debug (
" Issue webhook for # %s : action= %s state= %s labels= %s " ,
issue_iid ,
action ,
state ,
label_change [ " current " ] if label_change else [ ] ,
)
2026-07-12 12:26:01 +02:00
if label_change :
current_labels = label_change [ " current " ]
progress = calculate_progress_from_labels ( current_labels )
bar = build_progress_bar ( progress )
2026-07-12 15:59:29 +02:00
status_message = build_status_message_from_labels ( current_labels )
2026-07-12 12:26:01 +02:00
try :
embed = disnake . Embed (
title = " 📈 Évolution du ticket " ,
2026-07-12 15:59:29 +02:00
description = f " Le suivi de ** { issue_title } ** a été mis à jour. \n \n { status_message } " ,
2026-07-12 12:26:01 +02:00
color = color_from_progress ( progress ) ,
)
embed . add_field ( name = " Progression " , value = bar , inline = False )
embed . set_thumbnail ( url = self . bot . user . display_avatar . url )
await user . send ( embed = embed )
kuby_logger . info ( f " Notification de progression envoyée à { user } pour l ' issue # { issue_iid } " )
except Exception as e :
kuby_logger . error ( f " Erreur notification progression: { e } " )
2026-07-12 18:05:37 +02:00
is_closing_now = (
action in { " close " , " closed " , " closing " }
or state in { " closed " , " close " , " resolved " , " resolved_closed " }
or payload . get ( " event_action " ) in { " close " , " closed " }
or payload . get ( " object_kind " ) == " issue " and payload . get ( " action " ) in { " close " , " closed " }
)
2026-05-01 16:16:33 +02:00
if is_closing_now :
2026-07-12 15:59:29 +02:00
self . issue_data [ issue_iid ] [ " pending_deployment_notification " ] = True
save_tracking ( self . issue_data )
2026-07-12 18:05:37 +02:00
kuby_logger . info ( f " Marked issue # { issue_iid } as pending deployment notification " )
2026-03-14 17:05:20 +01:00
try :
2026-05-20 15:49:40 +02:00
embed = disnake . Embed (
2026-07-12 15:59:29 +02:00
title = " 🛠️ Demande traitée ! " ,
description = (
f " Bonne nouvelle ! Votre demande ** { issue_title } ** a été intégrée au code du bot. \n \n "
" ✅ Elle n ' est pas encore disponible au public, mais elle est maintenant prise en charge côté développement. \n \n "
" 🔜 Lors du prochain déploiement, Kuby vous informera qu ' elle est désormais fonctionnelle. "
) ,
2026-05-20 15:49:40 +02:00
color = disnake . Color . green ( )
)
2026-07-11 23:11:48 +02:00
if self . bot . user . display_avatar :
embed . set_thumbnail ( url = self . bot . user . display_avatar . url )
2026-06-04 08:40:14 +02:00
await user . send ( embed = embed )
2026-03-14 17:05:20 +01:00
except Exception as e :
2026-06-04 08:40:14 +02:00
kuby_logger . error ( f " Erreur notification clôture: { e } " )
2026-03-14 17:05:20 +01:00
return web . json_response ( { " status " : " success " } , status = 200 )
2026-02-07 16:03:16 +01:00
except Exception as e :
2026-03-14 17:05:20 +01:00
kuby_logger . error ( f " Error handling bot event: { e } " )
return web . json_response ( { " status " : " error " , " message " : str ( e ) } , status = 500 )
2026-02-07 16:03:16 +01:00
2026-06-04 08:40:14 +02:00
@commands.slash_command ( name = " signaler_bug " , description = " Signaler un bug aux développeurs " )
2026-07-06 16:08:14 +02:00
@check_premium_tier ( )
2026-05-23 15:11:47 +02:00
async def signifier_bug ( self , interaction : disnake . ApplicationCommandInteraction ) :
2026-06-04 08:40:14 +02:00
modal = BugReportModal ( self )
2026-05-23 15:17:35 +02:00
await interaction . response . send_modal ( modal )
2026-02-07 16:03:16 +01:00
2026-06-04 08:40:14 +02:00
@commands.slash_command ( name = " suggerer_fonctionnalite " , description = " Proposer une nouvelle fonctionnalité " )
2026-07-06 16:08:14 +02:00
@check_premium_tier ( )
2026-05-06 17:07:09 +02:00
async def suggerer_fonctionnalite ( self , interaction : disnake . ApplicationCommandInteraction ) :
2026-06-04 08:40:14 +02:00
modal = FeatureSuggestionModal ( self )
2026-05-23 15:17:35 +02:00
await interaction . response . send_modal ( modal )
2026-02-08 14:42:40 +01:00
2026-05-19 18:50:16 +02:00
2026-05-09 18:42:42 +02:00
def setup ( bot ) :
2026-06-04 08:40:14 +02:00
bot . add_cog ( BugReport ( bot ) )