diff --git a/database/__init__.py b/database/__init__.py
index 9d1d48e..77700d1 100644
--- a/database/__init__.py
+++ b/database/__init__.py
@@ -33,7 +33,13 @@ def _set_sqlite_pragma(dbapi_connection, connection_record):
except Exception:
pass
-def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
+def _tableExists(table_name: str, cursor: Cursor) -> bool:
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
+ return cursor.fetchone() is not None
+
+def _tableHaveColumn(table_name:str, column_name:str, cursor:Cursor) -> bool:
+ if not _tableExists(table_name, cursor):
+ return False
cursor.execute(f'PRAGMA table_info({table_name})')
columns = cursor.fetchall()
return any(col[1] == column_name for col in columns)
@@ -65,6 +71,25 @@ def _doPostImportMigration(cursor:Cursor):
logging.info("suppression de la table temporaire game_bundle_old")
_dropTable('game_bundle_old', cursor)
+ if _tableExists('youtube_notification', cursor):
+ embed_columns = [
+ ('embed_title', 'VARCHAR(256)'),
+ ('embed_description', 'VARCHAR(2000)'),
+ ('embed_color', 'VARCHAR(8) DEFAULT "FF0000"'),
+ ('embed_footer', 'VARCHAR(2048)'),
+ ('embed_author_name', 'VARCHAR(256)'),
+ ('embed_author_icon', 'VARCHAR(512)'),
+ ('embed_thumbnail', 'BOOLEAN DEFAULT 1'),
+ ('embed_image', 'BOOLEAN DEFAULT 1'),
+ ]
+ for col_name, col_type in embed_columns:
+ if not _tableHaveColumn('youtube_notification', col_name, cursor):
+ try:
+ cursor.execute(f'ALTER TABLE youtube_notification ADD COLUMN {col_name} {col_type}')
+ logging.info(f"Colonne {col_name} ajoutée à youtube_notification")
+ except Exception as e:
+ logging.warning(f"Colonne youtube_notification.{col_name}: {e}")
+
with webapp.app_context():
with open('database/schema.sql', 'r') as f:
sql = f.read()
diff --git a/database/models.py b/database/models.py
index 9f103d1..d144c7e 100644
--- a/database/models.py
+++ b/database/models.py
@@ -61,3 +61,22 @@ class AntiCheatCache(db.Model):
notes = db.Column(db.String(1024))
updated_at = db.Column(db.DateTime)
+
+class YouTubeNotification(db.Model):
+ __tablename__ = 'youtube_notification'
+ id = db.Column(db.Integer, primary_key=True)
+ enable = db.Column(db.Boolean, default=True)
+ channel_id = db.Column(db.String(128))
+ notify_channel = db.Column(db.Integer)
+ message = db.Column(db.String(2000))
+ video_type = db.Column(db.String(16), default='all')
+ last_video_id = db.Column(db.String(128))
+ embed_title = db.Column(db.String(256))
+ embed_description = db.Column(db.String(2000))
+ embed_color = db.Column(db.String(8), default='FF0000')
+ embed_footer = db.Column(db.String(2048))
+ embed_author_name = db.Column(db.String(256))
+ embed_author_icon = db.Column(db.String(512))
+ embed_thumbnail = db.Column(db.Boolean, default=True)
+ embed_image = db.Column(db.Boolean, default=True)
+
diff --git a/database/schema.sql b/database/schema.sql
index 04cef86..0af5439 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -76,3 +76,21 @@ CREATE TABLE IF NOT EXISTS `member_invites` (
`inviter_name` VARCHAR(256),
`join_date` DATETIME NOT NULL
);
+
+CREATE TABLE IF NOT EXISTS `youtube_notification` (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ `enable` BOOLEAN NOT NULL DEFAULT TRUE,
+ `channel_id` VARCHAR(128) NOT NULL,
+ `notify_channel` INTEGER NOT NULL,
+ `message` VARCHAR(2000) NOT NULL,
+ `video_type` VARCHAR(16) NOT NULL DEFAULT 'all',
+ `last_video_id` VARCHAR(128),
+ `embed_title` VARCHAR(256),
+ `embed_description` VARCHAR(2000),
+ `embed_color` VARCHAR(8) NOT NULL DEFAULT 'FF0000',
+ `embed_footer` VARCHAR(2048),
+ `embed_author_name` VARCHAR(256),
+ `embed_author_icon` VARCHAR(512),
+ `embed_thumbnail` BOOLEAN NOT NULL DEFAULT TRUE,
+ `embed_image` BOOLEAN NOT NULL DEFAULT TRUE
+);
diff --git a/discordbot/__init__.py b/discordbot/__init__.py
index 2de81dd..cffafb6 100644
--- a/discordbot/__init__.py
+++ b/discordbot/__init__.py
@@ -3,6 +3,7 @@ import discord
import logging
import random
+from webapp import webapp
from database import db
from database.helpers import ConfigurationHelper
from database.models import Configuration, Humeur, Commande
@@ -22,11 +23,14 @@ from discordbot.moderation import (
handle_say_command
)
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
+from discordbot.youtube import checkYouTubeVideos
from protondb import searhProtonDb
class DiscordBot(discord.Client):
async def on_ready(self):
logging.info(f'Connecté en tant que {self.user} (ID: {self.user.id})')
+ webapp.config["BOT_STATUS"]["discord_connected"] = True
+ webapp.config["BOT_STATUS"]["discord_guild_count"] = len(self.guilds)
for c in self.get_all_channels() :
logging.info(f'{c.id} {c.name}')
@@ -35,6 +39,10 @@ class DiscordBot(discord.Client):
self.loop.create_task(self.updateStatus())
self.loop.create_task(self.updateHumbleBundle())
+ self.loop.create_task(self.updateYouTube())
+
+ async def on_disconnect(self):
+ webapp.config["BOT_STATUS"]["discord_connected"] = False
async def updateStatus(self):
while not self.is_closed():
@@ -51,6 +59,11 @@ class DiscordBot(discord.Client):
await checkHumbleBundleAndNotify(self)
await asyncio.sleep(30*60)
+ async def updateYouTube(self):
+ while not self.is_closed():
+ await checkYouTubeVideos()
+ await asyncio.sleep(5*60)
+
def getAllTextChannel(self) -> list[TextChannel]:
channels = []
for channel in self.get_all_channels():
diff --git a/discordbot/youtube.py b/discordbot/youtube.py
new file mode 100644
index 0000000..aa78962
--- /dev/null
+++ b/discordbot/youtube.py
@@ -0,0 +1,225 @@
+import logging
+import asyncio
+import xml.etree.ElementTree as ET
+import requests
+
+from database import db
+from database.models import YouTubeNotification
+from webapp import webapp
+
+logger = logging.getLogger('youtube-notification')
+logger.setLevel(logging.INFO)
+
+
+async def checkYouTubeVideos():
+ with webapp.app_context():
+ try:
+ notifications: list[YouTubeNotification] = YouTubeNotification.query.filter_by(enable=True).all()
+
+ for notification in notifications:
+ try:
+ await _checkChannelVideos(notification)
+ except Exception as e:
+ logger.error(f"Erreur lors de la vérification de la chaîne {notification.channel_id}: {e}")
+ continue
+ except Exception as e:
+ logger.error(f"Erreur lors de la vérification YouTube: {e}")
+
+
+async def _checkChannelVideos(notification: YouTubeNotification):
+ try:
+ channel_id = notification.channel_id
+
+ rss_url = f"https://www.youtube.com/feeds/videos.xml?channel_id={channel_id}"
+
+ response = await asyncio.to_thread(requests.get, rss_url, timeout=10)
+
+ if response.status_code != 200:
+ logger.error(f"Erreur HTTP {response.status_code} lors de la récupération du RSS pour {channel_id}")
+ return
+
+ root = ET.fromstring(response.content)
+
+ ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015', 'media': 'http://search.yahoo.com/mrss/'}
+
+ entries = root.findall('atom:entry', ns)
+
+ if not entries:
+ logger.warning(f"Aucune vidéo trouvée dans le RSS pour {channel_id}")
+ return
+
+ videos = []
+ for entry in entries:
+ video_id = entry.find('yt:videoId', ns)
+ if video_id is None:
+ continue
+ video_id = video_id.text
+
+ title_elem = entry.find('atom:title', ns)
+ video_title = title_elem.text if title_elem is not None else 'Sans titre'
+
+ link_elem = entry.find('atom:link', ns)
+ video_url = link_elem.get('href') if link_elem is not None else f"https://www.youtube.com/watch?v={video_id}"
+
+ published_elem = entry.find('atom:published', ns)
+ published_at = published_elem.text if published_elem is not None else ''
+
+ author_elem = entry.find('atom:author/atom:name', ns)
+ channel_name = author_elem.text if author_elem is not None else 'Inconnu'
+
+ thumbnail = None
+ media_thumbnail = entry.find('media:group/media:thumbnail', ns)
+ if media_thumbnail is not None:
+ thumbnail = media_thumbnail.get('url')
+
+ is_short = False
+ if video_title and ('#shorts' in video_title.lower() or '#short' in video_title.lower()):
+ is_short = True
+
+ if notification.video_type == 'all':
+ videos.append((video_id, {
+ 'title': video_title,
+ 'url': video_url,
+ 'published': published_at,
+ 'channel_name': channel_name,
+ 'thumbnail': thumbnail,
+ 'is_short': is_short
+ }))
+ elif notification.video_type == 'short' and is_short:
+ videos.append((video_id, {
+ 'title': video_title,
+ 'url': video_url,
+ 'published': published_at,
+ 'channel_name': channel_name,
+ 'thumbnail': thumbnail,
+ 'is_short': is_short
+ }))
+ elif notification.video_type == 'video' and not is_short:
+ videos.append((video_id, {
+ 'title': video_title,
+ 'url': video_url,
+ 'published': published_at,
+ 'channel_name': channel_name,
+ 'thumbnail': thumbnail,
+ 'is_short': is_short
+ }))
+
+ videos.sort(key=lambda x: x[1]['published'], reverse=True)
+
+ if videos:
+ latest_video_id, latest_video = videos[0]
+
+ if not notification.last_video_id:
+ notification.last_video_id = latest_video_id
+ db.session.commit()
+ return
+
+ if latest_video_id != notification.last_video_id:
+ logger.info(f"Nouvelle vidéo détectée: {latest_video_id} pour la chaîne {notification.channel_id}")
+ await _notifyVideo(notification, latest_video, latest_video_id)
+ notification.last_video_id = latest_video_id
+ db.session.commit()
+
+ except Exception as e:
+ logger.error(f"Erreur lors de la vérification des vidéos: {e}")
+
+
+async def _notifyVideo(notification: YouTubeNotification, video_data: dict, video_id: str):
+ from discordbot import bot
+ try:
+ channel_name = video_data.get('channel_name', 'Inconnu')
+ video_title = video_data.get('title', 'Sans titre')
+ video_url = video_data.get('url', f"https://www.youtube.com/watch?v={video_id}")
+ thumbnail = video_data.get('thumbnail', '')
+ published_at = video_data.get('published', '')
+ is_short = video_data.get('is_short', False)
+
+ try:
+ message = notification.message.format(
+ channel_name=channel_name or 'Inconnu',
+ video_title=video_title or 'Sans titre',
+ video_url=video_url,
+ video_id=video_id,
+ thumbnail=thumbnail or '',
+ published_at=published_at or '',
+ is_short=is_short
+ )
+ except KeyError as e:
+ logger.error(f"Variable manquante dans le message de notification: {e}")
+ message = f"🎥 Nouvelle vidéo de {channel_name}: [{video_title}]({video_url})"
+
+ logger.info(f"Envoi de notification YouTube: {message}")
+ bot.loop.create_task(_sendMessage(notification, message, video_url, thumbnail, video_title, channel_name, video_id, published_at, is_short))
+
+ except Exception as e:
+ logger.error(f"Erreur lors de la notification: {e}")
+
+
+def _format_embed_text(text: str, channel_name: str, video_title: str, video_url: str, video_id: str, thumbnail: str, published_at: str, is_short: bool) -> str:
+ """Formate un texte d'embed avec les variables disponibles"""
+ if not text:
+ return None
+ try:
+ return text.format(
+ channel_name=channel_name or 'Inconnu',
+ video_title=video_title or 'Sans titre',
+ video_url=video_url,
+ video_id=video_id,
+ thumbnail=thumbnail or '',
+ published_at=published_at or '',
+ is_short=is_short
+ )
+ except KeyError:
+ return text
+
+
+async def _sendMessage(notification: YouTubeNotification, message: str, video_url: str, thumbnail: str, video_title: str, channel_name: str, video_id: str, published_at: str, is_short: bool):
+ from discordbot import bot
+ try:
+ discord_channel = bot.get_channel(notification.notify_channel)
+ if not discord_channel:
+ logger.error(f"Canal Discord {notification.notify_channel} introuvable")
+ return
+
+ import discord
+
+ embed_title = _format_embed_text(notification.embed_title, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_title else video_title
+ embed_description = _format_embed_text(notification.embed_description, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_description else None
+
+ try:
+ embed_color = int(notification.embed_color or 'FF0000', 16)
+ except ValueError:
+ embed_color = 0xFF0000
+
+ embed = discord.Embed(
+ title=embed_title,
+ url=video_url,
+ color=embed_color
+ )
+
+ if embed_description:
+ embed.description = embed_description
+
+ author_name = _format_embed_text(notification.embed_author_name, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short) if notification.embed_author_name else channel_name
+ author_icon = notification.embed_author_icon if notification.embed_author_icon else "https://www.youtube.com/img/desktop/yt_1200.png"
+ embed.set_author(name=author_name, icon_url=author_icon)
+
+ if notification.embed_thumbnail and thumbnail:
+ embed.set_thumbnail(url=thumbnail)
+
+ if notification.embed_image and thumbnail:
+ embed.set_image(url=thumbnail)
+
+ if notification.embed_footer:
+ footer_text = _format_embed_text(notification.embed_footer, channel_name, video_title, video_url, video_id, thumbnail, published_at, is_short)
+ if footer_text:
+ embed.set_footer(text=footer_text)
+
+ if message and message.strip():
+ await discord_channel.send(message, embed=embed)
+ else:
+ await discord_channel.send(embed=embed)
+ logger.info(f"Notification YouTube envoyée avec succès")
+
+ except Exception as e:
+ logger.error(f"Erreur lors de l'envoi du message Discord: {e}")
diff --git a/twitchbot/__init__.py b/twitchbot/__init__.py
index 529bf0d..45796f6 100644
--- a/twitchbot/__init__.py
+++ b/twitchbot/__init__.py
@@ -14,8 +14,11 @@ USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
async def _onReady(ready_event: EventData):
logging.info('Bot Twitch prêt')
+ channel = ConfigurationHelper().getValue('twitch_channel')
+ webapp.config["BOT_STATUS"]["twitch_connected"] = True
+ webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
with webapp.app_context():
- await ready_event.chat.join_room(ConfigurationHelper().getValue('twitch_channel'))
+ await ready_event.chat.join_room(channel)
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
diff --git a/webapp/__init__.py b/webapp/__init__.py
index 28f6c3c..0ff86c7 100644
--- a/webapp/__init__.py
+++ b/webapp/__init__.py
@@ -2,4 +2,12 @@ from flask import Flask
webapp = Flask(__name__)
-from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation
+# État des bots (mis à jour par les bots, lu par le panneau)
+webapp.config["BOT_STATUS"] = {
+ "discord_connected": False,
+ "discord_guild_count": 0,
+ "twitch_connected": False,
+ "twitch_channel_name": None,
+}
+
+from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube
diff --git a/webapp/index.py b/webapp/index.py
index 10f735b..ffe42b2 100644
--- a/webapp/index.py
+++ b/webapp/index.py
@@ -1,6 +1,16 @@
from flask import render_template
from webapp import webapp
+from database.models import ModerationEvent
@webapp.route("/")
def index():
- return render_template("index.html")
+ status = webapp.config["BOT_STATUS"]
+ sanctions_count = ModerationEvent.query.count()
+ return render_template(
+ "index.html",
+ discord_connected=status["discord_connected"],
+ discord_guild_count=status["discord_guild_count"],
+ sanctions_count=sanctions_count,
+ twitch_connected=status["twitch_connected"],
+ twitch_channel_name=status["twitch_channel_name"],
+ )
diff --git a/webapp/moderation.py b/webapp/moderation.py
index 5571487..e0d92f4 100644
--- a/webapp/moderation.py
+++ b/webapp/moderation.py
@@ -3,16 +3,58 @@ from webapp import webapp
from database import db
from database.models import ModerationEvent
+def _top_sanctioned():
+ return (
+ db.session.query(
+ ModerationEvent.discord_id,
+ db.func.max(ModerationEvent.username).label("username"),
+ db.func.count(ModerationEvent.id).label("count"),
+ )
+ .group_by(ModerationEvent.discord_id)
+ .order_by(db.func.count(ModerationEvent.id).desc())
+ .limit(3)
+ .all()
+ )
+
+def _top_moderators():
+ return (
+ db.session.query(
+ ModerationEvent.staff_id,
+ db.func.max(ModerationEvent.staff_name).label("staff_name"),
+ db.func.count(ModerationEvent.id).label("count"),
+ )
+ .group_by(ModerationEvent.staff_id)
+ .order_by(db.func.count(ModerationEvent.id).desc())
+ .limit(3)
+ .all()
+ )
+
@webapp.route("/moderation")
def moderation():
events = ModerationEvent.query.order_by(ModerationEvent.created_at.desc()).all()
- return render_template("moderation.html", events=events, event=None)
+ top_sanctioned = _top_sanctioned()
+ top_moderators = _top_moderators()
+ return render_template(
+ "moderation.html",
+ events=events,
+ event=None,
+ top_sanctioned=top_sanctioned,
+ top_moderators=top_moderators,
+ )
@webapp.route("/moderation/edit/
Notifications Twitch
-Serveurs connectés
+{{ discord_guild_count }}
+Sanctions enregistrées
+{{ sanctions_count }}
+Commandes personnalisées
-Statuts Discord rotatifs
-Canal connecté
+{% if twitch_channel_name %}{{ twitch_channel_name }}{% else %}—{% endif %}
Historique et actions
-Sanctions
+—
+À venir
Compatibilité Linux
-Intégrations du bot Twitch à venir.
Paramètres du bot
-- Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté. + Mamie Henriette est un bot open source pour Discord et Twitch, développé par la communauté. Cette interface vous permet de configurer et gérer toutes les fonctionnalités.
Utilisateurs les plus sanctionnés
+Staff ayant effectué le plus d'actions
++ Liste des chaînes YouTube surveillées pour les notifications de nouvelles vidéos. + + Le bot vérifie toutes les 5 minutes les nouvelles vidéos des chaînes en dessous. + Quand une nouvelle vidéo est détectée, le bot enverra une notification sur Discord. +
+ +{% if not notification %} +| Chaîne YouTube | +Canal Discord | +Type | +Message | +# | +
|---|---|---|---|---|
| {{notification.channel_id}} | +{{notification.notify_channel_name}} | ++ {% if notification.video_type == 'all' %} + Toutes + {% elif notification.video_type == 'video' %} + Vidéos uniquement + {% elif notification.video_type == 'short' %} + Shorts uniquement + {% endif %} + | +{{notification.message}} | ++ {{ '✅' if notification.enable else '❌' }} + ✐ + 🗑 + | +
+ Variables disponibles pour l'embed : +
{channel_name} : nom de la chaîne YouTube{video_title} : titre de la vidéo{video_url} : lien vers la vidéo{video_id} : ID de la vidéo{thumbnail} : URL de la miniature{published_at} : date de publication{is_short} : True si c'est un short, False sinon