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:
+179
-7
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user