From 6ef0b6856e801a1cb1e0440d691dc83d036b5f2d Mon Sep 17 00:00:00 2001 From: Mow910 Date: Wed, 18 Mar 2026 20:31:40 +0100 Subject: [PATCH] Enhance Twitch and Discord bot functionality by updating BOT_STATUS management. Added tracking for Twitch message timestamps and messages per minute, improved connection handling, and refined live status updates. Updated webapp to reflect new status attributes and modified Twitch moderation template for better chat message handling. --- discordbot/__init__.py | 4 + twitchbot/__init__.py | 38 +++++- twitchbot/live_alert.py | 70 ++++++++-- webapp/__init__.py | 5 + webapp/templates/twitch-moderation.html | 169 +++++++++++++----------- webapp/twitch_moderation.py | 51 ++++++- 6 files changed, 243 insertions(+), 94 deletions(-) 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():