Files
Mow910andCursor b274dcaa51 Ajout de fonctionnalités majeures Twitch, Discord et interface web
Nouvelles fonctionnalités :
- Système de modération Twitch complet (bans, timeouts, avertissements)
- Filtre de liens intelligent pour Twitch avec whitelist/blacklist
- Notifications d'événements Twitch (follows, subs, raids, etc.)
- Système Freeloot Discord avec flux RSS dédié
- Salons automatiques Discord (création/suppression dynamique)
- Authentification utilisateur pour l'interface web (login/register)
- Gestion des utilisateurs et permissions
- Interface de modération Twitch dans la webapp
- Interface de gestion des événements Twitch
- Configuration des paramètres utilisateur
- Migration BDD pour les mots bannis

Améliorations de l'interface :
- Refonte complète des templates (configurations, commandes, humeurs, etc.)
- Nouvelle page de settings utilisateur
- Page de gestion des utilisateurs (admin)
- Page d'erreur 403 personnalisée
- Amélioration de la navigation et du design global
- Intégration de nouvelles sections dans le menu principal

Modifications techniques :
- Ajout de nouveaux modèles en base de données
- Extension des helpers database
- Mise à jour des dépendances (requirements.txt)
- Amélioration de la gestion des annonces Twitch
- Refactorisation du code pour meilleure maintenabilité

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 22:37:22 +01:00

265 lines
8.6 KiB
Python

# Notifications d'événements Twitch (sub, follow, raid, clip) : chat + Discord
import asyncio
import logging
from typing import Any
import discord
from twitchAPI.chat import Chat
from twitchAPI.eventsub.websocket import EventSubWebsocket
from twitchAPI.object.eventsub import (
ChannelFollowEvent,
ChannelRaidEvent,
ChannelSubscribeEvent,
)
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchEventNotification
from webapp import webapp
logger = logging.getLogger("twitch-events")
def _format_message(template: str, **kwargs: Any) -> str:
if not template:
return ""
for k, v in (kwargs or {}).items():
template = template.replace("{" + k + "}", str(v or ""))
return template
async def _send_twitch(chat: Chat, channel: str, text: str) -> None:
if not text or not chat:
return
try:
await chat.send_message(channel, text[:500])
except Exception as e:
logger.error("Envoi chat Twitch événement: %s", e)
def _schedule_discord_send(channel_id: int, content: str | None, embed: discord.Embed | None) -> None:
"""Planifie l'envoi sur le canal Discord (sans bloquer le loop Twitch)."""
from discordbot import bot
try:
ch = bot.get_channel(channel_id)
if not ch:
logger.warning("Canal Discord %s introuvable", channel_id)
return
payload = content if content else embed
if not payload:
return
asyncio.run_coroutine_threadsafe(
ch.send(content=content, embed=embed) if (content and embed) else ch.send(content=content or None, embed=embed if not content else None),
bot.loop,
)
except Exception as e:
logger.error("Envoi Discord événement: %s", e)
async def _handle_follow(data: ChannelFollowEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="follow", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour le follow !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau follow", user=user_login, user_name=user),
description=cfg.embed_description or f"{user} suit maintenant la chaîne.",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_subscribe(data: ChannelSubscribeEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="sub", enable=True).first()
if not cfg:
return
ev = data.event
user = getattr(ev, "user_name", None) or getattr(ev, "user_login", "")
user_login = getattr(ev, "user_login", user)
msg = _format_message(
cfg.message_twitch or "Merci {user} pour l'abonnement !",
user=user_login,
user_name=user,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(cfg.message_discord or "", user=user_login, user_name=user)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouvel abonnement", user=user_login, user_name=user),
description=cfg.embed_description or f"Merci à {user} pour l'abonnement !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def _handle_raid(data: ChannelRaidEvent, chat: Chat, channel: str) -> None:
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="raid", enable=True).first()
if not cfg:
return
ev = data.event
from_broadcaster = getattr(ev, "from_broadcaster_user_name", None) or getattr(ev, "from_broadcaster_user_login", "")
viewers = getattr(ev, "viewers", 0)
msg = _format_message(
cfg.message_twitch or "Bienvenue aux {viewers} viewers de {from_broadcaster_name} !",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
if cfg.notify_twitch_chat and msg:
await _send_twitch(chat, channel, msg)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(
cfg.embed_title or "Raid reçu",
from_broadcaster_name=from_broadcaster,
viewers=viewers,
),
description=cfg.embed_description or f"{from_broadcaster} a raid avec {viewers} viewers !",
color=embed_color,
)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
async def notify_clip(
chat: Chat | None,
channel: str,
*,
user: str,
title: str,
url: str,
thumbnail_url: str,
clip_id: str,
) -> None:
"""Appelé quand un nouveau clip est détecté (polling)."""
with webapp.app_context():
cfg = TwitchEventNotification.query.filter_by(event_type="clip", enable=True).first()
if not cfg:
return
msg_twitch = _format_message(
cfg.message_twitch or "Nouveau clip par {user} : {title} {url}",
user=user,
title=title,
url=url,
)
if cfg.notify_twitch_chat and chat and msg_twitch:
await _send_twitch(chat, channel, msg_twitch)
if cfg.notify_discord and cfg.discord_channel_id:
content = _format_message(
cfg.message_discord or "",
user=user,
title=title,
url=url,
thumbnail_url=thumbnail_url or "",
)
try:
embed_color = int(cfg.embed_color or "9146FF", 16)
except ValueError:
embed_color = 0x9146FF
embed = discord.Embed(
title=_format_message(cfg.embed_title or "Nouveau clip", user=user, title=title),
url=url,
description=cfg.embed_description or title,
color=embed_color,
)
if cfg.embed_thumbnail and thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
_schedule_discord_send(cfg.discord_channel_id, content.strip() or None, embed)
cfg.last_clip_id = clip_id
db.session.commit()
def create_eventsub(twitch: Twitch, callback_loop: asyncio.AbstractEventLoop) -> EventSubWebsocket:
"""Crée et démarre le client EventSub. Les callbacks seront exécutés sur `callback_loop`."""
eventsub = EventSubWebsocket(twitch, callback_loop=callback_loop)
eventsub.start()
return eventsub
async def register_eventsub_handlers(
eventsub: EventSubWebsocket,
broadcaster_id: str,
chat: Chat,
channel: str,
) -> None:
"""Enregistre follow, sub, raid sur l'EventSub. À appeler dans les 10 s après start()."""
# Définir les callbacks comme des wrappers explicites
async def on_follow(data: ChannelFollowEvent) -> None:
try:
await _handle_follow(data, chat, channel)
except Exception as e:
logger.error("Erreur handler follow: %s", e)
async def on_subscribe(data: ChannelSubscribeEvent) -> None:
try:
await _handle_subscribe(data, chat, channel)
except Exception as e:
logger.error("Erreur handler subscribe: %s", e)
async def on_raid(data: ChannelRaidEvent) -> None:
try:
await _handle_raid(data, chat, channel)
except Exception as e:
logger.error("Erreur handler raid: %s", e)
# Chaque souscription est tentée séparément : si le token n'a pas channel:read:subscriptions,
# seule "sub" échouera ; follow et raid restent actifs.
subscriptions_ok = 0
try:
await eventsub.listen_channel_follow_v2(broadcaster_id, broadcaster_id, on_follow)
logger.info("EventSub: follow enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub follow: %s", e)
try:
await eventsub.listen_channel_subscribe(broadcaster_id, on_subscribe)
logger.info("EventSub: subscribe enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.warning("EventSub subscribe (nécessite scope channel:read:subscriptions): %s", e)
try:
await eventsub.listen_channel_raid(to_broadcaster_user_id=broadcaster_id, callback=on_raid)
logger.info("EventSub: raid enregistré ✓")
subscriptions_ok += 1
except Exception as e:
logger.error("EventSub raid: %s", e)
if subscriptions_ok == 0:
logger.error("EventSub: AUCUNE souscription n'a réussi ! Le WebSocket va se fermer.")
else:
logger.info(f"EventSub: {subscriptions_ok}/3 souscriptions actives")