- Introduced `cmd_pdb` command in TwitchBot for ProtonDB integration. - Registered new command for Twitch and updated chat command list. - Added configuration options for enabling ProtonDB on Twitch in the webapp. - Updated ProtonDB template to include new settings and permissions for Twitch users.
354 lines
14 KiB
Python
354 lines
14 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
from twitchAPI.twitch import Twitch
|
|
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,
|
|
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')
|
|
twitchBot._loop = asyncio.get_running_loop()
|
|
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
|
|
now_ts = time.time()
|
|
msg_timestamps = webapp.config["BOT_STATUS"].setdefault("twitch_msg_timestamps", [])
|
|
msg_timestamps.append(now_ts)
|
|
cutoff = now_ts - 60
|
|
webapp.config["BOT_STATUS"]["twitch_msg_timestamps"] = [ts for ts in msg_timestamps if ts >= cutoff]
|
|
webapp.config["BOT_STATUS"]["twitch_msg_per_minute"] = len(webapp.config["BOT_STATUS"]["twitch_msg_timestamps"])
|
|
|
|
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
|
|
response = _replace_command_variables(commande.response, msg)
|
|
await msg.reply(response)
|
|
|
|
|
|
def _replace_command_variables(text: str, msg: ChatMessage) -> str:
|
|
"""Remplace les variables de template dans la réponse d'une commande."""
|
|
from datetime import datetime
|
|
|
|
result = text
|
|
result = result.replace('{user}', msg.user.name)
|
|
result = result.replace('{username}', msg.user.name)
|
|
result = result.replace('{channel}', msg.room.name)
|
|
|
|
bot_status = webapp.config.get("BOT_STATUS", {})
|
|
result = result.replace('{title}', bot_status.get("twitch_stream_title", ""))
|
|
result = result.replace('{game}', bot_status.get("twitch_game_name", ""))
|
|
result = result.replace('{viewers}', str(bot_status.get("twitch_viewer_count", 0)))
|
|
|
|
uptime_str = "hors ligne"
|
|
started_at_str = bot_status.get("twitch_started_at")
|
|
if started_at_str and bot_status.get("twitch_is_live", False):
|
|
try:
|
|
started_at = datetime.fromisoformat(started_at_str)
|
|
delta = datetime.now(started_at.tzinfo) - started_at
|
|
total_seconds = int(delta.total_seconds())
|
|
hours, remainder = divmod(max(0, total_seconds), 3600)
|
|
minutes, _ = divmod(remainder, 60)
|
|
if hours > 0:
|
|
uptime_str = f"{hours}h {minutes:02d}min"
|
|
else:
|
|
uptime_str = f"{minutes}min"
|
|
except (ValueError, TypeError):
|
|
pass
|
|
result = result.replace('{uptime}', uptime_str)
|
|
|
|
return result
|
|
|
|
|
|
async def _helloCommand(msg: ChatMessage):
|
|
await msg.reply(f'Bonjour {msg.user.name}')
|
|
|
|
|
|
def _isConfigured():
|
|
helper = ConfigurationHelper()
|
|
return (helper.getValue('twitch_client_id') is not None and
|
|
helper.getValue('twitch_client_secret') is not None and
|
|
helper.getValue('twitch_access_token') is not None and
|
|
helper.getValue('twitch_refresh_token') is not None and
|
|
helper.getValue('twitch_channel') is not None)
|
|
|
|
|
|
class TwitchBot():
|
|
_eventsub = None
|
|
_loop = None
|
|
|
|
async def _connect(self):
|
|
with webapp.app_context():
|
|
if _isConfigured():
|
|
try:
|
|
helper = ConfigurationHelper()
|
|
self.twitch = await Twitch(helper.getValue('twitch_client_id'), helper.getValue('twitch_client_secret'))
|
|
await self.twitch.set_user_authentication(helper.getValue('twitch_access_token'), USER_SCOPE, helper.getValue('twitch_refresh_token'))
|
|
self.chat = await Chat(self.twitch)
|
|
# Laisser des tentatives de reconnexion internes plus longues avant reboot complet du client
|
|
self.chat.reconnect_delay_steps = [0, 1, 2, 4, 8, 16, 32, 64, 128, 128, 128]
|
|
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()
|
|
disconnected_since = None
|
|
while True:
|
|
connected = self.chat.is_connected()
|
|
if connected:
|
|
disconnected_since = None
|
|
else:
|
|
if disconnected_since is None:
|
|
disconnected_since = time.time()
|
|
# Si la lib n'arrive pas à se reconnecter en interne pendant un moment, on relance la session complète.
|
|
elif time.time() - disconnected_since >= 90:
|
|
logging.warning("Chat Twitch déconnecté depuis plus de 90s, redémarrage de la session")
|
|
break
|
|
await asyncio.sleep(2)
|
|
except Exception as e:
|
|
logging.error(f'Échec de l\'authentification Twitch : {e}')
|
|
finally:
|
|
webapp.config["BOT_STATUS"]["twitch_connected"] = False
|
|
self._loop = None
|
|
try:
|
|
if hasattr(self, 'chat') and self.chat:
|
|
self.chat.stop()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if hasattr(self, 'twitch') and self.twitch:
|
|
await self.twitch.close()
|
|
except Exception:
|
|
pass
|
|
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)
|
|
|
|
from twitchbot import protondb as protondb_twitch
|
|
async def cmd_pdb(msg): await protondb_twitch.protondb_command(msg)
|
|
|
|
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)
|
|
self.chat.register_command('pdb', cmd_pdb)
|
|
self.chat.register_command('protondb', cmd_pdb)
|
|
|
|
async def _checkOnlineStreamers(self):
|
|
while True:
|
|
try:
|
|
await checkOnlineStreamer(self.twitch)
|
|
except Exception as e:
|
|
logging.error(f'Erreur check streamers online : {e}')
|
|
await asyncio.sleep(5 * 60)
|
|
|
|
async def _runAnnouncements(self):
|
|
with webapp.app_context():
|
|
channel = ConfigurationHelper().getValue('twitch_channel')
|
|
while True:
|
|
try:
|
|
await checkAndSendAnnouncements(self.chat, channel, self.twitch)
|
|
except Exception as e:
|
|
logging.error(f'Erreur envoi annonces : {e}')
|
|
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):
|
|
retry_delay = 15
|
|
while True:
|
|
try:
|
|
if not _isConfigured():
|
|
logging.info("Twitch non configuré, nouvelle tentative dans 60s")
|
|
with webapp.app_context():
|
|
webapp.config["BOT_STATUS"]["twitch_connected"] = False
|
|
time.sleep(60)
|
|
continue
|
|
asyncio.run(self._connect())
|
|
logging.warning("Session Twitch perdue, reconnexion complète dans %ss", retry_delay)
|
|
except Exception as e:
|
|
logging.error("Déconnexion/erreur Twitch: %s", e)
|
|
with webapp.app_context():
|
|
webapp.config["BOT_STATUS"]["twitch_connected"] = False
|
|
time.sleep(retry_delay)
|
|
|
|
async def _close(self):
|
|
self.chat.stop()
|
|
await self.twitch.close()
|
|
|
|
|
|
twitchBot = TwitchBot()
|