diff --git a/database/models.py b/database/models.py
index 0234412..2259340 100644
--- a/database/models.py
+++ b/database/models.py
@@ -227,3 +227,11 @@ class TwitchBannedWord(db.Model):
timeout_duration = db.Column(db.Integer, default=60) # durée du timeout en secondes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
+
+class ModShoutboxMessage(db.Model):
+ __tablename__ = 'mod_shoutbox_message'
+ id = db.Column(db.Integer, primary_key=True)
+ author = db.Column(db.String(64), nullable=False)
+ message = db.Column(db.String(500), nullable=False)
+ created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
+
diff --git a/database/schema.sql b/database/schema.sql
index 152dc93..3a3ff8b 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -198,3 +198,10 @@ CREATE TABLE IF NOT EXISTS `twitch_event_notification` (
embed_thumbnail BOOLEAN NOT NULL DEFAULT TRUE,
last_clip_id VARCHAR(128) NULL
);
+
+CREATE TABLE IF NOT EXISTS `mod_shoutbox_message` (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ `author` VARCHAR(64) NOT NULL,
+ `message` VARCHAR(500) NOT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/webapp/templates/twitch-moderation.html b/webapp/templates/twitch-moderation.html
index e795a70..956245c 100644
--- a/webapp/templates/twitch-moderation.html
+++ b/webapp/templates/twitch-moderation.html
@@ -289,6 +289,29 @@
{% if not is_live %}{% endif %}
+
+
@@ -859,5 +882,139 @@ function saveEditCommand() {
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeEditModal();
});
+
+// =============================
+// Shoutbox modérateurs (IRC)
+// =============================
+var shoutboxKnownIds = new Set();
+var shoutboxLastTimestamp = '';
+var shoutboxAutoScroll = true;
+
+var SHOUTBOX_COLORS = [
+ '#6366f1', '#8b5cf6', '#ec4899', '#14b8a6', '#f59e0b',
+ '#3b82f6', '#10b981', '#ef4444', '#06b6d4', '#84cc16',
+];
+var shoutboxColorMap = {};
+
+function shoutboxColor(name) {
+ if (!shoutboxColorMap[name]) {
+ var hash = 0;
+ for (var i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ shoutboxColorMap[name] = SHOUTBOX_COLORS[Math.abs(hash) % SHOUTBOX_COLORS.length];
+ }
+ return shoutboxColorMap[name];
+}
+
+function shoutboxTime(isoStr) {
+ var d = new Date(isoStr);
+ return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
+}
+
+function addShoutboxItem(item) {
+ if (shoutboxKnownIds.has(item.id)) return;
+ shoutboxKnownIds.add(item.id);
+
+ var display = document.getElementById('shoutboxDisplay');
+ var ph = document.getElementById('shoutboxPlaceholder');
+ if (ph) ph.remove();
+
+ var line = document.createElement('div');
+ line.className = 'py-0.5 leading-relaxed';
+
+ var time = '[' + shoutboxTime(item.created_at) + '] ';
+
+ if (item.type === 'message') {
+ var color = shoutboxColor(item.author);
+ line.innerHTML = time +
+ '<' + escapeHtml(item.author) + '> ' +
+ '' + escapeHtml(item.text) + '';
+ } else {
+ var actionUpper = (item.action || '').toUpperCase();
+ var sanctionColor = 'text-red-400';
+ if (['timeout', 'clean', 'permit'].indexOf(item.action) >= 0) sanctionColor = 'text-orange-400';
+ if (['subon', 'suboff', 'emoteon', 'emoteoff', 'follon', 'folloff'].indexOf(item.action) >= 0) sanctionColor = 'text-blue-400';
+ if (item.action === 'unban') sanctionColor = 'text-green-400';
+
+ var text = '*** ' + actionUpper;
+ if (item.moderator) text += ' par ' + item.moderator;
+ if (item.target) text += ' → ' + item.target;
+ if (item.details && item.details !== '-') text += ' (' + item.details + ')';
+ text += ' ***';
+
+ line.innerHTML = time + '' + escapeHtml(text) + '';
+ }
+
+ display.appendChild(line);
+
+ while (display.children.length > 200) display.removeChild(display.firstChild);
+ if (shoutboxAutoScroll) display.scrollTop = display.scrollHeight;
+}
+
+function fetchShoutbox() {
+ var url = '{{ url_for("shoutbox_messages") }}';
+ if (shoutboxLastTimestamp) url += '?since=' + encodeURIComponent(shoutboxLastTimestamp);
+
+ fetch(url)
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.items && data.items.length > 0) {
+ data.items.forEach(addShoutboxItem);
+ }
+ if (data.timestamp) shoutboxLastTimestamp = data.timestamp;
+ var cnt = document.getElementById('shoutboxCount');
+ if (cnt) cnt.textContent = '(' + shoutboxKnownIds.size + ')';
+ })
+ .catch(function(e) { console.error('Shoutbox error:', e); });
+}
+
+function sendShoutboxMessage(event) {
+ event.preventDefault();
+ var input = document.getElementById('shoutboxInput');
+ var message = input.value.trim();
+ if (!message) return;
+
+ var btn = event.target.querySelector('button[type="submit"]');
+ btn.disabled = true;
+
+ fetch('{{ url_for("shoutbox_send") }}', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: message })
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.success) {
+ input.value = '';
+ fetchShoutbox();
+ } else {
+ showNotification(data.error || 'Erreur shoutbox', 'error');
+ }
+ })
+ .catch(function() { showNotification('Erreur réseau', 'error'); })
+ .finally(function() { btn.disabled = false; });
+}
+
+function clearShoutbox() {
+ if (!confirm('Effacer tous les messages de la shoutbox ?')) return;
+ fetch('{{ url_for("shoutbox_clear") }}')
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.success) {
+ var display = document.getElementById('shoutboxDisplay');
+ display.innerHTML = '';
+ shoutboxKnownIds.clear();
+ shoutboxLastTimestamp = '';
+ showNotification('Shoutbox effacée', 'success');
+ }
+ })
+ .catch(function() { showNotification('Erreur réseau', 'error'); });
+}
+
+document.getElementById('shoutboxDisplay').addEventListener('scroll', function() {
+ shoutboxAutoScroll = this.scrollHeight - this.scrollTop <= this.clientHeight + 30;
+});
+
+setInterval(fetchShoutbox, 3000);
+fetchShoutbox();
{% endblock %}
diff --git a/webapp/twitch_moderation.py b/webapp/twitch_moderation.py
index e573e11..48e2c65 100644
--- a/webapp/twitch_moderation.py
+++ b/webapp/twitch_moderation.py
@@ -2,7 +2,8 @@ from flask import render_template, request, redirect, url_for, jsonify
from webapp import webapp
from webapp.auth import require_page, can_write_page
from database import db
-from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord
+from database.models import Commande, TwitchModerationLog, TwitchLinkFilter, TwitchBannedWord, ModShoutboxMessage
+from flask_login import current_user
from database.helpers import ConfigurationHelper
from datetime import datetime, timedelta
import asyncio
@@ -483,3 +484,87 @@ def execute_moderation_action():
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
+
+
+# =============================
+# Shoutbox modérateurs
+# =============================
+
+@webapp.route("/twitch-moderation/shoutbox/send", methods=['POST'])
+@require_page("twitch_moderation")
+def shoutbox_send():
+ if not can_write_page("twitch_moderation"):
+ return jsonify({"success": False, "error": "Permission refusée"}), 403
+
+ data = request.get_json()
+ message = (data.get('message') or '').strip()[:500]
+ if not message:
+ return jsonify({"success": False, "error": "Message vide"}), 400
+
+ msg = ModShoutboxMessage(
+ author=current_user.username,
+ message=message,
+ 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():
+ since_str = request.args.get('since', '')
+ since = None
+ if since_str:
+ try:
+ since = datetime.fromisoformat(since_str)
+ except ValueError:
+ pass
+
+ chat_query = ModShoutboxMessage.query
+ log_query = TwitchModerationLog.query
+ if since:
+ chat_query = chat_query.filter(ModShoutboxMessage.created_at > since)
+ log_query = log_query.filter(TwitchModerationLog.created_at > since)
+
+ chat_msgs = chat_query.order_by(ModShoutboxMessage.created_at.desc()).limit(100).all()
+ log_msgs = log_query.order_by(TwitchModerationLog.created_at.desc()).limit(100).all()
+
+ items = []
+ for m in chat_msgs:
+ items.append({
+ "type": "message",
+ "id": f"msg-{m.id}",
+ "author": m.author,
+ "text": m.message,
+ "created_at": m.created_at.isoformat() if m.created_at else '',
+ })
+ for log in log_msgs:
+ items.append({
+ "type": "sanction",
+ "id": f"log-{log.id}",
+ "action": log.action,
+ "moderator": log.moderator,
+ "target": log.target or '',
+ "details": log.details or '',
+ "created_at": log.created_at.isoformat() if log.created_at else '',
+ })
+
+ items.sort(key=lambda x: x["created_at"])
+ items = items[-100:]
+
+ return jsonify({
+ "items": items,
+ "timestamp": datetime.now().isoformat(),
+ })
+
+
+@webapp.route("/twitch-moderation/shoutbox/clear")
+@require_page("twitch_moderation")
+def shoutbox_clear():
+ if not can_write_page("twitch_moderation"):
+ return jsonify({"success": False, "error": "Permission refusée"}), 403
+ ModShoutboxMessage.query.delete()
+ db.session.commit()
+ return jsonify({"success": True})