diff --git a/discordbot/__init__.py b/discordbot/__init__.py index 65ee04c..64ac489 100644 --- a/discordbot/__init__.py +++ b/discordbot/__init__.py @@ -84,6 +84,10 @@ class DiscordBot(discord.Client): async def updateStatus(self): while not self.is_closed(): + bot_status = webapp.config.get("BOT_STATUS", {}) + if bot_status.get("twitch_is_live") or bot_status.get("discord_streaming_activity"): + await asyncio.sleep(60) + continue humeurs = Humeur.query.all() if len(humeurs)>0 : humeur = random.choice(humeurs) diff --git a/twitchbot/__init__.py b/twitchbot/__init__.py index c486b1a..001cb66 100644 --- a/twitchbot/__init__.py +++ b/twitchbot/__init__.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from twitchAPI.twitch import Twitch from twitchAPI.type import AuthScope, ChatEvent @@ -71,6 +72,13 @@ async def _onMessage(msg: ChatMessage): # Stocker le message dans BOT_STATUS pour l'affichage web with webapp.app_context(): from datetime import datetime + now_ts = time.time() + msg_timestamps = webapp.config["BOT_STATUS"].setdefault("twitch_msg_timestamps", []) + msg_timestamps.append(now_ts) + cutoff = now_ts - 60 + webapp.config["BOT_STATUS"]["twitch_msg_timestamps"] = [ts for ts in msg_timestamps if ts >= cutoff] + webapp.config["BOT_STATUS"]["twitch_msg_per_minute"] = len(webapp.config["BOT_STATUS"]["twitch_msg_timestamps"]) + message_data = { 'username': msg.user.name, 'text': msg.text, @@ -176,6 +184,19 @@ class TwitchBot(): self.chat.start() except Exception as e: logging.error(f'Échec de l\'authentification Twitch : {e}') + finally: + webapp.config["BOT_STATUS"]["twitch_connected"] = False + self._loop = None + try: + if hasattr(self, 'chat') and self.chat: + self.chat.stop() + except Exception: + pass + try: + if hasattr(self, 'twitch') and self.twitch: + await self.twitch.close() + except Exception: + pass else: logging.info("Twitch n'est pas configuré") @@ -287,7 +308,22 @@ class TwitchBot(): await asyncio.sleep(120) def begin(self): - asyncio.run(self._connect()) + retry_delay = 15 + while True: + try: + if not _isConfigured(): + logging.info("Twitch non configuré, nouvelle tentative dans 60s") + with webapp.app_context(): + webapp.config["BOT_STATUS"]["twitch_connected"] = False + time.sleep(60) + continue + asyncio.run(self._connect()) + logging.warning("Session Twitch terminée, reconnexion dans %ss", retry_delay) + except Exception as e: + logging.error("Déconnexion/erreur Twitch: %s", e) + with webapp.app_context(): + webapp.config["BOT_STATUS"]["twitch_connected"] = False + time.sleep(retry_delay) async def _close(self): self.chat.stop() diff --git a/twitchbot/live_alert.py b/twitchbot/live_alert.py index 830da64..b54946e 100644 --- a/twitchbot/live_alert.py +++ b/twitchbot/live_alert.py @@ -1,4 +1,6 @@ import logging +import asyncio +from datetime import datetime import discord from twitchAPI.twitch import Twitch @@ -47,6 +49,8 @@ async def checkOnlineStreamer(twitch: Twitch) : global _live_alert_first_check with webapp.app_context() : alerts : list[LiveAlert] = LiveAlert.query.all() + bot_status = webapp.config["BOT_STATUS"] + was_live = bot_status.get("twitch_is_live", False) try: streams = await _retreiveStreams(twitch, alerts) @@ -65,17 +69,37 @@ async def checkOnlineStreamer(twitch: Twitch) : # Mise à jour du BOT_STATUS pour la webapp if main_stream: - webapp.config["BOT_STATUS"]["twitch_is_live"] = True - webapp.config["BOT_STATUS"]["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0) - webapp.config["BOT_STATUS"]["twitch_stream_title"] = getattr(main_stream, 'title', '') or '' - webapp.config["BOT_STATUS"]["twitch_game_name"] = getattr(main_stream, 'game_name', '') or '' - webapp.config["BOT_STATUS"]["twitch_started_at"] = main_stream.started_at.isoformat() if getattr(main_stream, 'started_at', None) else None + bot_status["twitch_is_live"] = True + bot_status["twitch_viewer_count"] = getattr(main_stream, 'viewer_count', 0) + bot_status["twitch_stream_title"] = getattr(main_stream, 'title', '') or '' + bot_status["twitch_game_name"] = getattr(main_stream, 'game_name', '') or '' + bot_status["twitch_started_at"] = main_stream.started_at.isoformat() if getattr(main_stream, 'started_at', None) else None + bot_status["twitch_ended_at"] = None + bot_status["twitch_chat_clear_notice_sent"] = False else: - webapp.config["BOT_STATUS"]["twitch_is_live"] = False - webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0 - webapp.config["BOT_STATUS"]["twitch_stream_title"] = "" - webapp.config["BOT_STATUS"]["twitch_game_name"] = "" - webapp.config["BOT_STATUS"]["twitch_started_at"] = None + bot_status["twitch_is_live"] = False + bot_status["twitch_viewer_count"] = 0 + bot_status["twitch_stream_title"] = "" + bot_status["twitch_game_name"] = "" + bot_status["twitch_started_at"] = None + if was_live and not bot_status.get("twitch_ended_at"): + bot_status["twitch_ended_at"] = datetime.now().isoformat() + if was_live and not bot_status.get("twitch_chat_clear_notice_sent"): + messages = bot_status.setdefault("twitch_chat_messages", []) + now_iso = datetime.now().isoformat() + messages.append({ + 'username': 'System', + 'text': 'Live terminé, ce chat sera vidé dans 1h.', + 'timestamp': now_iso, + 'is_mod': False, + 'is_subscriber': False, + 'is_vip': False, + 'color': '#22c55e', + 'panel_only': True, + }) + if len(messages) > 100: + messages.pop(0) + bot_status["twitch_chat_clear_notice_sent"] = True # Premier check : synchronisation sans notification if _live_alert_first_check: @@ -113,15 +137,27 @@ async def checkOnlineStreamer(twitch: Twitch) : async def _updateBotActivity(stream: Stream | None): + if not bot.loop or bot.loop.is_closed(): + logger.warning("Loop Discord non disponible pour mise à jour de présence") + return + if stream: logger.info(f'Mise à jour de l\'activité : Regarde le live de {stream.user_name}') activity = discord.Streaming( name=f'Regarde le live de {stream.user_name}', url=f'https://www.twitch.tv/{stream.user_login}' ) - await bot.change_presence(status=discord.Status.online, activity=activity) + with webapp.app_context(): + webapp.config["BOT_STATUS"]["discord_streaming_activity"] = True + future = asyncio.run_coroutine_threadsafe( + bot.change_presence(status=discord.Status.online, activity=activity), + bot.loop + ) + future.result(timeout=10) else: logger.info('Aucun stream à regarder, retour à l\'activité normale') + with webapp.app_context(): + webapp.config["BOT_STATUS"]["discord_streaming_activity"] = False # Remettre une humeur aléatoire from database.models import Humeur import random @@ -129,10 +165,18 @@ async def _updateBotActivity(stream: Stream | None): if humeurs: humeur = random.choice(humeurs) logger.info(f'Réinitialisation du statut : {humeur.text}') - await bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text)) + future = asyncio.run_coroutine_threadsafe( + bot.change_presence(status=discord.Status.online, activity=discord.CustomActivity(humeur.text)), + bot.loop + ) + future.result(timeout=10) else: # Si pas de humeur, remettre un statut par défaut - await bot.change_presence(status=discord.Status.online, activity=None) + future = asyncio.run_coroutine_threadsafe( + bot.change_presence(status=discord.Status.online, activity=None), + bot.loop + ) + future.result(timeout=10) async def _notifyAlert(alert: LiveAlert, stream: Stream): stream_url = f'https://www.twitch.tv/{stream.user_login}' diff --git a/webapp/__init__.py b/webapp/__init__.py index 2220d08..67e43c2 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -11,6 +11,7 @@ webapp.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in webapp.config["BOT_STATUS"] = { "discord_connected": False, "discord_guild_count": 0, + "discord_streaming_activity": False, "twitch_connected": False, "twitch_channel_name": None, "twitch_is_live": False, @@ -18,6 +19,10 @@ webapp.config["BOT_STATUS"] = { "twitch_stream_title": "", "twitch_game_name": "", "twitch_started_at": None, + "twitch_ended_at": None, + "twitch_chat_clear_notice_sent": False, + "twitch_msg_per_minute": 0, + "twitch_msg_timestamps": [], # Unix timestamps des 60 dernières secondes "twitch_chat_messages": [], # Derniers messages du chat (max 100) "shoutbox_heartbeats": {}, # {"username": datetime} — présence des modos } diff --git a/webapp/templates/twitch-moderation.html b/webapp/templates/twitch-moderation.html index de7be9c..955d54f 100644 --- a/webapp/templates/twitch-moderation.html +++ b/webapp/templates/twitch-moderation.html @@ -135,116 +135,69 @@ - {% if is_live %} - - -
- -
+
+ +
-
+
+ {% if is_live %} - EN DIRECT + {% else %} + + {% endif %} + {{ 'EN DIRECT' if is_live else 'HORS LIGNE' }} • {{ viewer_count }} viewers
- Ouvrir sur Twitch + Ouvrir
-
- - -
-

- - Infos du stream -

- -
- +
Titre
-
{{ stream_title or 'N/A' }}
+
{{ stream_title or 'N/A' }}
- -
-
Catégorie
-
+
+
+
Catégorie
{{ game_name or 'N/A' }}
+
+
Viewers
+
{{ viewer_count }}
+
- -
-
Viewers
-
{{ viewer_count }}
-
-
Uptime
--:--:--
- - - Voir sur Twitch - -
-
- - -
- {% else %} - -
- -
-
- -
-
-
- - HORS LIGNE -
- - - Ouvrir sur Twitch - -
- -
- {% endif %} + +
-

Chat en direct

+

Chat Twitch (bot)

- - - Ouvrir sur Twitch - +
Msg/min: 0
@@ -300,11 +253,25 @@

Envoyé via le bot • Ouvrir le chat

-
- {% if not is_live %}
{% endif %} +
- -
+ +
+
+

Chat Twitch (compte perso)

+ Ouvrir +
+
+ +
+
+ + +
@@ -354,6 +321,7 @@
+
@@ -659,6 +627,13 @@ function escapeHtml(text) { return div.innerHTML; } +function escapeJsString(text) { + return String(text || '') + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r?\n/g, ' '); +} + function addMessageToDisplay(username, message, timestamp, badges, userColor) { var chatDisplay = document.getElementById('chatDisplay'); var messagesContainer = chatDisplay.querySelector('.space-y-2'); @@ -678,14 +653,19 @@ function addMessageToDisplay(username, message, timestamp, badges, userColor) { if (badges && badges.is_subscriber) badgeIcons += 'SUB'; var safeUser = escapeHtml(username); + var safeUserJs = escapeJsString(username); + var safeMessageJs = escapeJsString(message); var modButtons = '
' - + '' + + '' - + '' - + '' + '
'; @@ -725,6 +705,9 @@ function fetchChatMessages() { fetch('{{ url_for("get_twitch_messages") }}') .then(function(r) { return r.json(); }) .then(function(data) { + var msgPerMinEl = document.getElementById('msgPerMinValue'); + if (msgPerMinEl) msgPerMinEl.textContent = String(data.msg_per_min || 0); + if (data.clear_chat) clearChatDisplay(data.clear_reason || 'Chat vidé automatiquement.'); if (data.messages && data.messages.length > 0) { data.messages.forEach(function(msg) { addMessageToDisplay(msg.username, msg.text, msg.timestamp, { is_mod: msg.is_mod, is_vip: msg.is_vip, is_subscriber: msg.is_subscriber }, msg.color); @@ -734,6 +717,36 @@ function fetchChatMessages() { .catch(function(e) { console.error('Erreur chat:', e); }); } +function clearChatDisplay(reason) { + var chatDisplay = document.getElementById('chatDisplay'); + var messagesContainer = chatDisplay.querySelector('.space-y-2'); + if (!messagesContainer) return; + displayedMessages.clear(); + messagesContainer.innerHTML = ''; + var notice = document.createElement('div'); + notice.className = 'text-center text-sm text-gray-500 dark:text-gray-400 py-8'; + notice.textContent = reason || 'Chat vidé.'; + messagesContainer.appendChild(notice); +} + +function transferToShoutbox(username, message) { + fetch('{{ url_for("shoutbox_transfer") }}', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: username, message: message }) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + showNotification('Message transféré vers la shoutbox', 'success'); + fetchShoutbox(); + } else { + showNotification(data.error || 'Erreur de transfert', 'error'); + } + }) + .catch(function() { showNotification('Erreur réseau', 'error'); }); +} + setInterval(fetchChatMessages, 2000); fetchChatMessages(); diff --git a/webapp/twitch_moderation.py b/webapp/twitch_moderation.py index 308e4f1..a738882 100644 --- a/webapp/twitch_moderation.py +++ b/webapp/twitch_moderation.py @@ -306,8 +306,31 @@ def send_twitch_message(): @require_page("twitch_moderation") def get_twitch_messages(): """Retourne les derniers messages du chat Twitch""" - messages = list(webapp.config["BOT_STATUS"].get("twitch_chat_messages", [])) - return jsonify({"messages": messages}) + bot_status = webapp.config["BOT_STATUS"] + clear_chat = False + clear_reason = None + + ended_at_raw = bot_status.get("twitch_ended_at") + if ended_at_raw: + try: + ended_at = datetime.fromisoformat(ended_at_raw) + if datetime.now(ended_at.tzinfo) >= ended_at + timedelta(hours=1): + if bot_status.get("twitch_chat_messages"): + bot_status["twitch_chat_messages"] = [] + bot_status["twitch_msg_timestamps"] = [] + bot_status["twitch_msg_per_minute"] = 0 + clear_chat = True + clear_reason = "Chat vidé automatiquement 1h après la fin du live." + except ValueError: + pass + + messages = list(bot_status.get("twitch_chat_messages", [])) + return jsonify({ + "messages": messages, + "msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)), + "clear_chat": clear_chat, + "clear_reason": clear_reason, + }) @webapp.route("/twitch-moderation/stream-info") @@ -321,6 +344,7 @@ def twitch_stream_info(): "title": bot_status.get("twitch_stream_title", ""), "game_name": bot_status.get("twitch_game_name", ""), "started_at": bot_status.get("twitch_started_at"), + "msg_per_min": int(bot_status.get("twitch_msg_per_minute", 0)), }) @webapp.route("/twitch-moderation/logs/poll") @@ -523,6 +547,29 @@ def shoutbox_send(): return jsonify({"success": True, "id": msg.id}) +@webapp.route("/twitch-moderation/shoutbox/transfer", methods=['POST']) +@require_page("twitch_moderation") +def shoutbox_transfer(): + if not can_write_page("twitch_moderation"): + return jsonify({"success": False, "error": "Permission refusée"}), 403 + + data = request.get_json() or {} + username = (data.get('username') or '').strip().lstrip('@') + message = (data.get('message') or '').strip() + if not username or not message: + return jsonify({"success": False, "error": "Données incomplètes"}), 400 + + text = f"@{username}: {message}" + msg = ModShoutboxMessage( + author=current_user.username, + message=text[:500], + created_at=datetime.now(), + ) + db.session.add(msg) + db.session.commit() + return jsonify({"success": True, "id": msg.id}) + + @webapp.route("/twitch-moderation/shoutbox/messages") @require_page("twitch_moderation") def shoutbox_messages():