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>
265 lines
8.6 KiB
Python
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")
|