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>
This commit is contained in:
2026-02-12 22:37:22 +01:00
co-authored by Cursor
parent d941fdcf9c
commit b274dcaa51
52 changed files with 7700 additions and 665 deletions
+179 -7
View File
@@ -6,27 +6,107 @@ from twitchAPI.type import AuthScope, ChatEvent
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
from database.helpers import ConfigurationHelper
from database.models import Commande
def _user_has_twitch_permission(msg: ChatMessage, required: str) -> bool:
"""Vérifie si l'utilisateur a le niveau de permission requis (viewer, sub, vip, moderator)."""
if not required or required == 'viewer':
return True
is_broadcaster = msg.user.name.lower() == msg.room.name.lower()
is_mod = msg.user.mod or is_broadcaster
if required == 'moderator':
return is_mod
if required == 'vip':
return msg.user.vip or is_mod
if required == 'sub':
return msg.user.subscriber or msg.user.vip or is_mod
return True
from twitchbot.live_alert import checkOnlineStreamer
from twitchbot.announcements import checkAndSendAnnouncements, incrementMessageCount
from twitchbot import moderation
from twitchbot import link_filter
from twitchbot import event_notifications
from webapp import webapp
USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
USER_SCOPE = [
AuthScope.CHAT_READ,
AuthScope.CHAT_EDIT,
AuthScope.MODERATOR_MANAGE_BANNED_USERS,
AuthScope.MODERATOR_MANAGE_CHAT_MESSAGES,
AuthScope.MODERATOR_MANAGE_CHAT_SETTINGS,
AuthScope.MODERATOR_MANAGE_SHIELD_MODE,
AuthScope.CHANNEL_MANAGE_BROADCAST,
AuthScope.MODERATOR_READ_FOLLOWERS,
AuthScope.CHANNEL_READ_SUBSCRIPTIONS, # EventSub channel.subscribe (notifs sub)
]
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():
channel = ConfigurationHelper().getValue('twitch_channel')
webapp.config["BOT_STATUS"]["twitch_connected"] = True
webapp.config["BOT_STATUS"]["twitch_channel_name"] = channel
await ready_event.chat.join_room(channel)
# EventSub (follow, sub, raid) : besoin du broadcaster_id
try:
from twitchAPI.helper import first
user = await first(twitchBot.twitch.get_users(logins=[channel]))
if user:
twitchBot._eventsub = event_notifications.create_eventsub(twitchBot.twitch, asyncio.get_event_loop())
asyncio.create_task(event_notifications.register_eventsub_handlers(twitchBot._eventsub, user.id, ready_event.chat, channel))
except Exception as e:
logging.warning('EventSub non démarré: %s', e)
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
asyncio.get_event_loop().create_task(twitchBot._runAnnouncements())
asyncio.get_event_loop().create_task(twitchBot._checkClips())
async def _onMessage(msg: ChatMessage):
logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}')
incrementMessageCount()
# Stocker le message dans BOT_STATUS pour l'affichage web
with webapp.app_context():
from datetime import datetime
message_data = {
'username': msg.user.name,
'text': msg.text,
'timestamp': datetime.now().isoformat(),
'is_mod': msg.user.mod,
'is_subscriber': msg.user.subscriber,
'is_vip': msg.user.vip,
'color': getattr(msg.user, 'color', None) or '#9146FF'
}
messages = webapp.config["BOT_STATUS"]["twitch_chat_messages"]
messages.append(message_data)
# Limiter à 100 messages
if len(messages) > 100:
messages.pop(0)
if not await link_filter.check_message_for_links(msg, twitchBot.twitch):
return
if not await moderation.check_message_for_banned_words(msg, twitchBot.twitch):
return
await _handleCustomCommand(msg)
async def _handleCustomCommand(msg: ChatMessage):
if not msg.text.startswith('!'):
return
trigger = msg.text.split()[0].lower()
with webapp.app_context():
# Vérifier si les commandes Twitch sont activées globalement
if not ConfigurationHelper().getValue('twitch_commands_enable'):
return
commande = Commande.query.filter_by(trigger=trigger, twitch_enable=True).first()
if commande:
permission = commande.twitch_permission or 'viewer'
if not _user_has_twitch_permission(msg, permission):
return # Pas de réponse = l'utilisateur n'a pas la permission
response = commande.response.replace('{user}', msg.user.name)
await msg.reply(response)
async def _helloCommand(msg: ChatMessage):
@@ -43,6 +123,7 @@ def _isConfigured():
class TwitchBot():
_eventsub = None
async def _connect(self):
with webapp.app_context():
@@ -55,12 +136,55 @@ class TwitchBot():
self.chat.register_event(ChatEvent.READY, _onReady)
self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
self.chat.register_command('hello', _helloCommand)
self._register_moderation_commands()
self.chat.start()
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch : {e}')
else:
logging.info("Twitch n'est pas configuré")
def _register_moderation_commands(self):
# Créer des wrappers async pour chaque commande
async def cmd_timeout(msg): await moderation.timeout_command(msg, self.twitch)
async def cmd_ban(msg): await moderation.ban_command(msg, self.twitch)
async def cmd_unban(msg): await moderation.unban_command(msg, self.twitch)
async def cmd_clean(msg): await moderation.clean_command(msg, self.twitch)
async def cmd_shieldmode(msg): await moderation.shieldmode_command(msg, self.twitch)
async def cmd_settitle(msg): await moderation.settitle_command(msg, self.twitch)
async def cmd_setgame(msg): await moderation.setgame_command(msg, self.twitch)
async def cmd_subon(msg): await moderation.subon_command(msg, self.twitch)
async def cmd_suboff(msg): await moderation.suboff_command(msg, self.twitch)
async def cmd_follon(msg): await moderation.follon_command(msg, self.twitch)
async def cmd_folloff(msg): await moderation.folloff_command(msg, self.twitch)
async def cmd_emoteon(msg): await moderation.emoteon_command(msg, self.twitch)
async def cmd_emoteoff(msg): await moderation.emoteoff_command(msg, self.twitch)
async def cmd_ann(msg): await moderation.ann_command(msg, self.twitch)
async def cmd_no_game(msg): await moderation.no_game_command(msg, self.twitch)
async def cmd_multitwitch(msg): await moderation.multitwitch_command(msg, self.twitch)
async def cmd_permit(msg): await link_filter.permit_command(msg, self.twitch)
self.chat.register_command('kick', cmd_timeout)
self.chat.register_command('to', cmd_timeout)
self.chat.register_command('timeout', cmd_timeout)
self.chat.register_command('tm', cmd_timeout)
self.chat.register_command('ban', cmd_ban)
self.chat.register_command('unban', cmd_unban)
self.chat.register_command('clean', cmd_clean)
self.chat.register_command('shieldmode', cmd_shieldmode)
self.chat.register_command('settitle', cmd_settitle)
self.chat.register_command('setgame', cmd_setgame)
self.chat.register_command('setcateg', cmd_setgame)
self.chat.register_command('subon', cmd_subon)
self.chat.register_command('suboff', cmd_suboff)
self.chat.register_command('follon', cmd_follon)
self.chat.register_command('folloff', cmd_folloff)
self.chat.register_command('emoteon', cmd_emoteon)
self.chat.register_command('emoteoff', cmd_emoteoff)
self.chat.register_command('ann', cmd_ann)
self.chat.register_command('no_game', cmd_no_game)
self.chat.register_command('multitwitch', cmd_multitwitch)
self.chat.register_command('permit', cmd_permit)
async def _checkOnlineStreamers(self):
while True:
try:
@@ -70,13 +194,61 @@ class TwitchBot():
await asyncio.sleep(5 * 60)
async def _runAnnouncements(self):
channel = ConfigurationHelper().getValue('twitch_channel')
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
while True:
try:
await checkAndSendAnnouncements(self.chat, channel)
await checkAndSendAnnouncements(self.chat, channel, self.twitch)
except Exception as e:
logging.error(f'Erreur envoi annonces : {e}')
await asyncio.sleep(60)
await asyncio.sleep(2 * 60) # Vérification toutes les 2 min pour limiter le spam
async def _checkClips(self):
"""Vérifie le clip le plus récent (polling) et notifie si nouveau."""
with webapp.app_context():
channel = ConfigurationHelper().getValue('twitch_channel')
if not channel:
return
from twitchAPI.helper import first
try:
user = await first(self.twitch.get_users(logins=[channel]))
if not user:
return
except Exception:
return
while True:
try:
with webapp.app_context():
from database.models import TwitchEventNotification
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if not cfg:
await asyncio.sleep(120)
continue
clip = await first(self.twitch.get_clips(broadcaster_id=user.id, first=1))
if not clip:
await asyncio.sleep(120)
continue
if cfg.last_clip_id is None:
with webapp.app_context():
from database import db
cfg = TwitchEventNotification.query.filter_by(event_type='clip', enable=True).first()
if cfg:
cfg.last_clip_id = clip.id
db.session.commit()
elif clip.id != cfg.last_clip_id:
with webapp.app_context():
await event_notifications.notify_clip(
self.chat,
channel,
user=getattr(clip, 'creator_name', None) or getattr(clip, 'user_name', '') or clip.id,
title=getattr(clip, 'title', '') or 'Clip',
url=getattr(clip, 'url', '') or f'https://clips.twitch.tv/{clip.id}',
thumbnail_url=getattr(clip, 'thumbnail_url', '') or '',
clip_id=clip.id,
)
except Exception as e:
logging.error('Erreur check clips: %s', e)
await asyncio.sleep(120)
def begin(self):
asyncio.run(self._connect())
+99 -13
View File
@@ -2,6 +2,7 @@ import logging
from datetime import datetime, timedelta
from twitchAPI.chat import Chat
from twitchAPI.twitch import Twitch
from database import db
from database.models import TwitchAnnouncement
@@ -10,25 +11,110 @@ from webapp import webapp
logger = logging.getLogger('twitch-announcements')
logger.setLevel(logging.INFO)
# Délai minimum entre deux annonces (quelle qu'elles soient) pour éviter le spam
MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES = 5
async def checkAndSendAnnouncements(chat: Chat, channel: str):
_message_count: int = 0
_last_announcement_index: int = -1 # Pour la rotation round-robin
def incrementMessageCount():
global _message_count
_message_count += 1
def _getAndResetMessageCount() -> int:
global _message_count
count = _message_count
_message_count = 0
return count
async def _is_channel_live(twitch: Twitch, channel: str) -> bool:
"""Vérifie si la chaîne Twitch est actuellement en live."""
try:
async for _ in twitch.get_streams(user_login=[channel]):
return True
except Exception as e:
logger.warning(f'Impossible de vérifier le statut live de {channel}: {e}')
return False
async def checkAndSendAnnouncements(chat: Chat, channel: str, twitch: Twitch):
"""
Vérifie et envoie les annonces dont la périodicité est écoulée.
Envoie une annonce en rotation parmi celles dont la périodicité est écoulée,
uniquement si la chaîne est en live et qu'il y a assez d'activité dans le chat.
Appelé périodiquement par le bot Twitch.
"""
global _last_announcement_index
with webapp.app_context():
announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).all()
if not await _is_channel_live(twitch, channel):
return
announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).order_by(TwitchAnnouncement.id).all()
if not announcements:
return
now = datetime.now()
for announcement in announcements:
if _shouldSend(announcement, now):
try:
await _sendAnnouncement(chat, channel, announcement)
announcement.last_sent = now
db.session.commit()
logger.info(f'Annonce envoyée : {announcement.name}')
except Exception as e:
logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}')
# Ne pas envoyer si une annonce (quelle qu'elle soit) a été envoyée récemment
last_any = max((a.last_sent for a in announcements if a.last_sent), default=None)
if last_any and (now - last_any) < timedelta(minutes=MIN_DELAY_BETWEEN_ANNOUNCEMENTS_MINUTES):
return
# Filtrer les annonces dont la périodicité est écoulée
due = [a for a in announcements if _shouldSend(a, now)]
if not due:
return
# Rotation round-robin : chercher la prochaine annonce après la dernière envoyée
announcement = _selectNextAnnouncement(due, announcements)
if not announcement:
return
# Vérifier le nombre minimum de messages dans le chat
message_count = _getAndResetMessageCount()
if message_count < announcement.min_chat_messages:
logger.debug(f'Annonce "{announcement.name}" ignorée : seulement {message_count} messages (minimum requis : {announcement.min_chat_messages})')
return
try:
await _sendAnnouncement(chat, channel, announcement)
announcement.last_sent = now
_last_announcement_index = announcements.index(announcement)
db.session.commit()
logger.info(f'Annonce envoyée : {announcement.name} (après {message_count} messages)')
except Exception as e:
logger.error(f'Erreur lors de l\'envoi de l\'annonce "{announcement.name}": {e}')
def _selectNextAnnouncement(due: list[TwitchAnnouncement], all_announcements: list[TwitchAnnouncement]) -> TwitchAnnouncement | None:
"""
Sélectionne la prochaine annonce selon un système de rotation round-robin.
Cherche la première annonce éligible après la dernière envoyée.
"""
global _last_announcement_index
if not due:
return None
# Si c'est la première annonce ou si l'index est invalide, prendre la première de la liste
if _last_announcement_index == -1 or _last_announcement_index >= len(all_announcements):
return due[0]
# Chercher la prochaine annonce éligible après la dernière envoyée
start_index = _last_announcement_index + 1
# Parcourir depuis l'index suivant jusqu'à la fin, puis revenir au début
for i in range(len(all_announcements)):
idx = (start_index + i) % len(all_announcements)
announcement = all_announcements[idx]
if announcement in due:
return announcement
# Fallback : retourner la première annonce éligible
return due[0]
def _shouldSend(announcement: TwitchAnnouncement, now: datetime) -> bool:
+264
View File
@@ -0,0 +1,264 @@
# 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")
+166
View File
@@ -0,0 +1,166 @@
import re
import logging
from datetime import datetime, timedelta
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchLinkFilter, TwitchAllowedDomain, TwitchPermit, TwitchAllowedUser
from twitchbot.moderation import _log_action, _get_broadcaster_id, _get_moderator_id, _get_user_id, _is_moderator
from webapp import webapp
logger = logging.getLogger('twitch-link-filter')
logger.setLevel(logging.INFO)
URL_REGEX = re.compile(r'https?://[^\s]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:/[^\s]*)?')
def _get_filter_config():
"""Retourne un dictionnaire avec la config du filtre de liens"""
with webapp.app_context():
config = TwitchLinkFilter.query.first()
if not config:
config = TwitchLinkFilter(enabled=False)
db.session.add(config)
db.session.commit()
# Retourner un dict pour éviter DetachedInstanceError
return {
'enabled': config.enabled,
'allow_subscribers': config.allow_subscribers,
'allow_vips': config.allow_vips,
'allow_moderators': config.allow_moderators,
'timeout_duration': config.timeout_duration,
'warning_message': config.warning_message
}
def _get_allowed_domains():
with webapp.app_context():
return [d.domain.lower() for d in TwitchAllowedDomain.query.all()]
def _is_user_whitelisted(username: str) -> bool:
with webapp.app_context():
return TwitchAllowedUser.query.filter_by(username=username.lower()).first() is not None
def _has_valid_permit(username: str) -> bool:
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=username.lower()).first()
if permit and permit.expires_at > datetime.now():
return True
if permit and permit.expires_at <= datetime.now():
db.session.delete(permit)
db.session.commit()
return False
def _extract_domain(url: str) -> str:
url = url.lower()
url = re.sub(r'^https?://', '', url)
url = re.sub(r'^www\.', '', url)
return url.split('/')[0]
def _is_domain_allowed(url: str, allowed_domains: list) -> bool:
domain = _extract_domain(url)
for allowed in allowed_domains:
if domain == allowed or domain.endswith('.' + allowed):
return True
return False
async def check_message_for_links(msg: ChatMessage, twitch: Twitch) -> bool:
config = _get_filter_config()
if not config['enabled']:
return True
if config['allow_moderators'] and (msg.user.mod or msg.user.name.lower() == msg.room.name.lower()):
return True
if config['allow_vips'] and msg.user.vip:
return True
if config['allow_subscribers'] and msg.user.subscriber:
return True
if _is_user_whitelisted(msg.user.name):
return True
urls = URL_REGEX.findall(msg.text)
if not urls:
return True
if _has_valid_permit(msg.user.name):
with webapp.app_context():
permit = TwitchPermit.query.filter_by(username=msg.user.name.lower()).first()
if permit:
db.session.delete(permit)
db.session.commit()
return True
allowed_domains = _get_allowed_domains()
for url in urls:
if not _is_domain_allowed(url, allowed_domains):
await _handle_unauthorized_link(msg, twitch, config, url)
return False
return True
async def _handle_unauthorized_link(msg: ChatMessage, twitch: Twitch, config: dict, url: str):
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
if user_id and config['timeout_duration'] > 0:
try:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Lien non autorise", duration=config['timeout_duration'])
except Exception as e:
logger.error(f"Erreur timeout link filter: {e}")
try:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message: {e}")
if config['warning_message']:
await msg.reply(config['warning_message'])
_log_action("link_blocked", "AutoMod", msg.user.name, _extract_domain(url))
logger.info(f"Lien bloque de {msg.user.name}: {url}")
async def permit_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !permit <viewer> [minutes]")
return
username = args[0].lstrip('@').lower()
duration = 60
if len(args) >= 2:
try:
duration = int(args[1]) * 60
except ValueError:
duration = 60
expires_at = datetime.now() + timedelta(seconds=duration)
with webapp.app_context():
existing = TwitchPermit.query.filter_by(username=username).first()
if existing:
existing.expires_at = expires_at
else:
permit = TwitchPermit(username=username, expires_at=expires_at)
db.session.add(permit)
db.session.commit()
_log_action("permit", msg.user.name, username, f"{duration}s")
await msg.reply(f"@{username} peut poster un lien pendant {duration // 60} minute(s)")
logger.info(f"Permit accorde a {username} par {msg.user.name}")
+155 -12
View File
@@ -1,4 +1,5 @@
import logging
import discord
from twitchAPI.twitch import Twitch
from twitchAPI.object.api import Stream
@@ -11,11 +12,76 @@ from webapp import webapp
logger = logging.getLogger('live-alert')
logger.setLevel(logging.INFO)
_live_alert_first_check = True
def _stream_thumbnail_url(stream: Stream) -> str:
"""URL de la miniature du stream (preview Twitch)."""
url = getattr(stream, 'thumbnail_url', None) or ''
if '{width}' in url or '{height}' in url:
url = url.replace('{width}', '320').replace('{height}', '180')
if not url:
url = f"https://static-cdn.jtvnw.net/previews-ttv/live_user_{stream.user_login}-320x180.jpg"
return url
def _format_embed_text(text: str, stream: Stream, stream_url: str, thumbnail: str) -> str:
"""Formate un texte d'embed avec les variables stream."""
if not text:
return ''
try:
return text.format(
user_login=stream.user_login or '',
user_name=stream.user_name or '',
game_name=getattr(stream, 'game_name', None) or '',
title=stream.title or '',
language=getattr(stream, 'language', None) or '',
stream_url=stream_url,
thumbnail=thumbnail,
)
except KeyError:
return text
async def checkOnlineStreamer(twitch: Twitch) :
global _live_alert_first_check
with webapp.app_context() :
alerts : list[LiveAlert] = LiveAlert.query.all()
streams = await _retreiveStreams(twitch, alerts)
watch_stream = None
# Récupération du statut du live principal (channel configuré)
from database.helpers import ConfigurationHelper
main_channel = ConfigurationHelper().getValue('twitch_channel')
main_stream = None
if main_channel:
main_stream = next((s for s in streams if s.user_login.lower() == main_channel.lower()), None)
# 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)
else:
webapp.config["BOT_STATUS"]["twitch_is_live"] = False
webapp.config["BOT_STATUS"]["twitch_viewer_count"] = 0
# Premier check : synchronisation sans notification
if _live_alert_first_check:
logger.info('Live Alert: première vérification, synchronisation sans notification')
for alert in alerts:
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream:
alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else:
alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit()
_live_alert_first_check = False
return
# Vérifications normales ensuite
for alert in alerts :
stream = next((s for s in streams if s.user_login == alert.login), None)
if stream :
@@ -24,26 +90,103 @@ async def checkOnlineStreamer(twitch: Twitch) :
logger.info(f'N\'etait pas en ligne auparavant : {alert.login}')
await _notifyAlert(alert, stream)
alert.online = True
if alert.watch_activity and alert.enable:
watch_stream = stream
else :
logger.info(f'Streamer hors ligne : {alert.login}')
alert.online = False
await _updateBotActivity(watch_stream)
db.session.commit()
async def _notifyAlert(alert : LiveAlert, stream : Stream):
message : str = alert.message.format(stream)
logger.info(f'Message de notification : {message}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message))
async def _sendMessage(channel : int, message : str) :
logger.info(f'Envoi de notification : {message}')
await bot.get_channel(channel).send(message)
logger.info(f'Notification envoyé')
async def _updateBotActivity(stream: Stream | None):
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)
else:
logger.info('Aucun stream à regarder, retour à l\'activité normale')
# Remettre une humeur aléatoire
from database.models import Humeur
import random
humeurs = Humeur.query.all()
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))
else:
# Si pas de humeur, remettre un statut par défaut
await bot.change_presence(status=discord.Status.online, activity=None)
async def _retreiveStreams(twitch: Twitch, alerts : list[LiveAlert]) -> list[Stream] :
streams : list[Stream] = []
async def _notifyAlert(alert: LiveAlert, stream: Stream):
stream_url = f'https://www.twitch.tv/{stream.user_login}'
thumbnail = _stream_thumbnail_url(stream)
# Message texte optionnel (avant l'embed)
message_text = None
if alert.message and alert.message.strip():
try:
message_text = alert.message.format(stream)
except KeyError:
message_text = alert.message
# Construction de l'embed Discord
try:
embed_color = int(alert.embed_color or '9146FF', 16)
except ValueError:
embed_color = 0x9146FF
embed_title = _format_embed_text(alert.embed_title, stream, stream_url, thumbnail) if alert.embed_title else (stream.title or f"{stream.user_name} est en live")
embed_description = _format_embed_text(alert.embed_description, stream, stream_url, thumbnail) if alert.embed_description else None
embed = discord.Embed(
title=embed_title,
url=stream_url,
color=embed_color
)
if embed_description:
embed.description = embed_description
author_name = _format_embed_text(alert.embed_author_name, stream, stream_url, thumbnail) if alert.embed_author_name else stream.user_name
user_id = getattr(stream, 'user_id', None)
author_icon = alert.embed_author_icon or (f"https://static-cdn.jtvnw.net/jtv_user_pictures/{user_id}-profile_image-70x70.png" if user_id else "https://static-cdn.jtvnw.net/ttv-favicon/favicon-32x32.png")
embed.set_author(name=author_name, icon_url=author_icon)
if alert.embed_thumbnail and thumbnail:
embed.set_thumbnail(url=thumbnail)
if alert.embed_image and thumbnail:
embed.set_image(url=thumbnail)
if alert.embed_footer:
footer_text = _format_embed_text(alert.embed_footer, stream, stream_url, thumbnail)
if footer_text:
embed.set_footer(text=footer_text)
logger.info(f'Envoi de notification live (embed) : {stream.user_login}')
bot.loop.create_task(_sendMessage(alert.notify_channel, message_text, embed))
async def _sendMessage(channel_id: int, message: str | None, embed: discord.Embed):
try:
discord_channel = bot.get_channel(channel_id)
if not discord_channel:
logger.error(f"Canal Discord {channel_id} introuvable")
return
if message and message.strip():
await discord_channel.send(content=message, embed=embed)
else:
await discord_channel.send(embed=embed)
logger.info('Notification live envoyée')
except Exception as e:
logger.error(f"Erreur lors de l'envoi de la notification live : {e}")
async def _retreiveStreams(twitch: Twitch, alerts: list[LiveAlert]) -> list[Stream]:
streams: list[Stream] = []
logger.info(f'Recherche de streams pour : {alerts}')
async for stream in twitch.get_streams(user_login = [alert.login for alert in alerts]):
async for stream in twitch.get_streams(user_login=[alert.login for alert in alerts]):
streams.append(stream)
logger.info(f'Ces streams sont en ligne : {streams}')
return streams
+446
View File
@@ -0,0 +1,446 @@
import re
import logging
from datetime import datetime
from twitchAPI.twitch import Twitch
from twitchAPI.chat import ChatMessage
from database import db
from database.models import TwitchAnnouncement, TwitchModerationLog, TwitchBannedWord
from webapp import webapp
logger = logging.getLogger('twitch-moderation')
logger.setLevel(logging.INFO)
last_multitwitch: str = None
games_disabled: bool = False
def _log_action(action: str, moderator: str, target: str = None, details: str = None):
with webapp.app_context():
log = TwitchModerationLog(
action=action,
moderator=moderator,
target=target,
details=details,
created_at=datetime.now()
)
db.session.add(log)
db.session.commit()
def _is_moderator(msg: ChatMessage) -> bool:
return msg.user.mod or msg.user.name.lower() == msg.room.name.lower()
async def _get_broadcaster_id(twitch: Twitch, channel: str) -> str:
async for user in twitch.get_users(logins=[channel]):
return user.id
return None
async def _get_user_id(twitch: Twitch, username: str) -> str:
async for user in twitch.get_users(logins=[username]):
return user.id
return None
async def _get_moderator_id(twitch: Twitch) -> str:
async for user in twitch.get_users():
return user.id
return None
async def timeout_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !timeout <viewer> [minutes] [raison]")
return
viewer = args[0].lstrip('@')
duration = 180 # 3 minutes par défaut
reason = "Timeout"
# Si args[1] est un nombre, c'est la durée, sinon c'est la raison
if len(args) >= 2:
try:
duration = int(args[1]) * 60
# Tout ce qui suit est la raison
if len(args) >= 3:
reason = ' '.join(args[2:])
except ValueError:
# args[1] n'est pas un nombre, donc tout depuis args[1] est la raison
reason = ' '.join(args[1:])
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason=reason, duration=duration)
# Log avec durée et raison
log_details = f"{duration}s - {reason}"
_log_action("timeout", msg.user.name, viewer, log_details)
logger.info(f'{viewer} timeout pour {duration}s par {msg.user.name} - Raison: {reason}')
async def ban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !ban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.ban_user(broadcaster_id, moderator_id, user_id, reason="Ban")
_log_action("ban", msg.user.name, viewer)
logger.info(f'{viewer} banni par {msg.user.name}')
async def unban_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !unban <viewer1> [viewer2] ...")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
for viewer in args[:5]:
viewer = viewer.lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.unban_user(broadcaster_id, moderator_id, user_id)
_log_action("unban", msg.user.name, viewer)
logger.info(f'{viewer} débanni par {msg.user.name}')
async def clean_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
if len(args) >= 1:
viewer = args[0].lstrip('@')
user_id = await _get_user_id(twitch, viewer)
if user_id:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, user_id=user_id)
_log_action("clean", msg.user.name, viewer)
logger.info(f'Messages de {viewer} supprimés par {msg.user.name}')
else:
await twitch.delete_chat_messages(broadcaster_id, moderator_id)
_log_action("clean", msg.user.name, None, "Chat complet")
logger.info(f'Chat nettoyé par {msg.user.name}')
async def shieldmode_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !shieldmode <on/off>")
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
is_active = args[0].lower() == "on"
await twitch.update_shield_mode_status(broadcaster_id, moderator_id, is_active)
_log_action("shieldmode", msg.user.name, None, "on" if is_active else "off")
logger.info(f'Shield mode {"activé" if is_active else "désactivé"} par {msg.user.name}')
async def settitle_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !settitle <titre>")
return
title = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
await twitch.modify_channel_information(broadcaster_id, title=title)
_log_action("settitle", msg.user.name, None, title)
logger.info(f'Titre changé en "{title}" par {msg.user.name}')
async def setgame_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
parts = msg.text.split(maxsplit=1)
if len(parts) < 2:
await msg.reply("Usage: !setgame <jeu>")
return
game_name = parts[1]
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
game_id = None
async for game in twitch.get_games(names=[game_name]):
game_id = game.id
break
if game_id:
await twitch.modify_channel_information(broadcaster_id, game_id=game_id)
_log_action("setgame", msg.user.name, None, game_name)
logger.info(f'Jeu changé en "{game_name}" par {msg.user.name}')
else:
await msg.reply(f"Jeu '{game_name}' introuvable")
async def subon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=True)
_log_action("subon", msg.user.name)
logger.info(f'Mode abonnés activé par {msg.user.name}')
async def suboff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, subscriber_mode=False)
_log_action("suboff", msg.user.name)
logger.info(f'Mode abonnés désactivé par {msg.user.name}')
async def follon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
duration = 10
if len(args) >= 1:
try:
duration = int(args[0])
except ValueError:
duration = 10
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=True, follower_mode_duration=duration)
_log_action("follon", msg.user.name, None, f"{duration}min")
logger.info(f'Mode followers ({duration}min) activé par {msg.user.name}')
async def folloff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, follower_mode=False)
_log_action("folloff", msg.user.name)
logger.info(f'Mode followers désactivé par {msg.user.name}')
async def emoteon_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=True)
_log_action("emoteon", msg.user.name)
logger.info(f'Mode emote activé par {msg.user.name}')
async def emoteoff_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
await twitch.update_chat_settings(broadcaster_id, moderator_id, emote_mode=False)
_log_action("emoteoff", msg.user.name)
logger.info(f'Mode emote désactivé par {msg.user.name}')
async def multitwitch_command(msg: ChatMessage, twitch: Twitch):
global last_multitwitch
args = msg.text.split()[1:]
if len(args) == 0:
if last_multitwitch:
await msg.reply(last_multitwitch)
return
if not _is_moderator(msg):
return
if args[0].lower() == "reset":
last_multitwitch = None
logger.info(f'MultiTwitch reset par {msg.user.name}')
return
if args[0].lower() == "auto":
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
async for channel in twitch.get_channel_information(broadcaster_id):
title = channel.title
mentions = re.findall(r'@(\w+)', title)
if mentions:
channels = [msg.room.name] + mentions
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
return
return
channels = []
for arg in args:
if arg == "@":
channels.append(msg.room.name)
else:
channels.append(arg.lstrip('@'))
last_multitwitch = f"https://multitwitch.live/{'/'.join(channels)}"
await msg.reply(last_multitwitch)
logger.info(f'MultiTwitch créé par {msg.user.name}: {last_multitwitch}')
async def ann_command(msg: ChatMessage, twitch: Twitch):
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 2:
await msg.reply("Usage: !ann <alias> <on/off/toggle>")
return
alias = args[0]
action = args[1].lower()
with webapp.app_context():
announcement = TwitchAnnouncement.query.filter_by(name=alias).first()
if not announcement:
await msg.reply(f"Annonce '{alias}' introuvable")
return
if action == "on":
announcement.enable = True
elif action == "off":
announcement.enable = False
elif action == "toggle":
announcement.enable = not announcement.enable
else:
await msg.reply("Action invalide: on/off/toggle")
return
db.session.commit()
status = "activée" if announcement.enable else "désactivée"
logger.info(f'Annonce {alias} {status} par {msg.user.name}')
await msg.reply(f"Annonce '{alias}' {status}")
async def no_game_command(msg: ChatMessage, twitch: Twitch):
global games_disabled
if not _is_moderator(msg):
return
args = msg.text.split()[1:]
if len(args) < 1:
await msg.reply("Usage: !no_game <on/off>")
return
action = args[0].lower()
if action == "on":
games_disabled = True
logger.info(f'Jeux désactivés par {msg.user.name}')
await msg.reply("Jeux désactivés")
elif action == "off":
games_disabled = False
logger.info(f'Jeux activés par {msg.user.name}')
await msg.reply("Jeux activés")
def are_games_disabled() -> bool:
return games_disabled
async def check_message_for_banned_words(msg: ChatMessage, twitch: Twitch) -> bool:
"""
Vérifie si le message contient des mots interdits.
Retourne True si le message est valide, False s'il doit être bloqué.
"""
# Modérateurs et broadcaster exemptés
if msg.user.mod or msg.user.name.lower() == msg.room.name.lower():
return True
with webapp.app_context():
banned_words = TwitchBannedWord.query.filter_by(enabled=True).all()
if not banned_words:
return True
message_lower = msg.text.lower()
for banned_word_entry in banned_words:
word = banned_word_entry.word.lower()
# Recherche du mot dans le message (mot entier ou partie de mot)
if word in message_lower:
# Bloquer le message
broadcaster_id = await _get_broadcaster_id(twitch, msg.room.name)
moderator_id = await _get_moderator_id(twitch)
user_id = await _get_user_id(twitch, msg.user.name)
# Timeout de l'utilisateur
if user_id and banned_word_entry.timeout_duration > 0:
try:
await twitch.ban_user(
broadcaster_id,
moderator_id,
user_id,
reason=f"Mot interdit: {banned_word_entry.word}",
duration=banned_word_entry.timeout_duration
)
except Exception as e:
logger.error(f"Erreur timeout mot interdit: {e}")
# Suppression du message
try:
await twitch.delete_chat_messages(broadcaster_id, moderator_id, message_id=msg.id)
except Exception as e:
logger.error(f"Erreur suppression message mot interdit: {e}")
# Log
_log_action("banned_word", "AutoMod", msg.user.name, f"Mot: {banned_word_entry.word}")
logger.info(f"Mot interdit détecté de {msg.user.name}: {banned_word_entry.word}")
return False
return True