Annonce twitch

This commit is contained in:
2026-02-01 13:38:48 +01:00
parent 48531690fd
commit d941fdcf9c
8 changed files with 366 additions and 35 deletions
+8 -4
View File
@@ -27,11 +27,15 @@ class LiveAlert(db.Model):
notify_channel = db.Column(db.Integer)
message = db.Column(db.String(2000))
class Message(db.Model):
class TwitchAnnouncement(db.Model):
__tablename__ = 'twitch_announcement'
id = db.Column(db.Integer, primary_key=True)
enable = db.Column(db.Boolean, default=False)
text = db.Column(db.String(256))
periodicity = db.Column(db.Integer)
enable = db.Column(db.Boolean, default=True)
name = db.Column(db.String(64))
text = db.Column(db.String(500))
periodicity = db.Column(db.Integer, default=10)
min_chat_messages = db.Column(db.Integer, default=0)
last_sent = db.Column(db.DateTime, nullable=True)
class Commande(db.Model):
id = db.Column(db.Integer, primary_key=True)
+7 -4
View File
@@ -31,11 +31,14 @@ CREATE TABLE IF NOT EXISTS live_alert (
`message` VARCHAR(2000) NOT NULL
);
CREATE TABLE IF NOT EXISTS `message` (
CREATE TABLE IF NOT EXISTS `twitch_announcement` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
`enable` BOOLEAN NOT NULL DEFAULT FALSE,
`text` VARCHAR(256) NULL,
periodicity INTEGER NULL
`enable` BOOLEAN NOT NULL DEFAULT TRUE,
`name` VARCHAR(64) NOT NULL,
`text` VARCHAR(500) NOT NULL,
`periodicity` INTEGER NOT NULL DEFAULT 10,
`min_chat_messages` INTEGER NOT NULL DEFAULT 0,
`last_sent` DATETIME NULL
);
CREATE TABLE IF NOT EXISTS `commande` (
+37 -23
View File
@@ -1,4 +1,3 @@
import asyncio
import logging
@@ -8,10 +7,12 @@ from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
from database.helpers import ConfigurationHelper
from twitchbot.live_alert import checkOnlineStreamer
from twitchbot.announcements import checkAndSendAnnouncements, incrementMessageCount
from webapp import webapp
USER_SCOPE = [AuthScope.CHAT_READ, AuthScope.CHAT_EDIT]
async def _onReady(ready_event: EventData):
logging.info('Bot Twitch prêt')
channel = ConfigurationHelper().getValue('twitch_channel')
@@ -20,56 +21,69 @@ async def _onReady(ready_event: EventData):
with webapp.app_context():
await ready_event.chat.join_room(channel)
asyncio.get_event_loop().create_task(twitchBot._checkOnlineStreamers())
asyncio.get_event_loop().create_task(twitchBot._runAnnouncements())
async def _onMessage(msg: ChatMessage):
logging.info(f'Dans {msg.room.name}, {msg.user.name} a dit : {msg.text}')
incrementMessageCount()
# commande qui répond "bonjour" à "!hello"
async def _helloCommand(msg: ChatMessage):
await msg.reply(f'Bonjour {msg.user.name}')
def _isConfigured() -> bool:
helper = ConfigurationHelper()
return helper.getValue('twitch_client_id') != None and helper.getValue('twitch_client_secret') != None and helper.getValue('twitch_access_token') != None and helper.getValue('twitch_refresh_token') != None and helper.getValue('twitch_channel') != None
class TwitchBot() :
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():
async def _connect(self):
with webapp.app_context():
if _isConfigured() :
try :
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)
self.chat.register_event(ChatEvent.READY, _onReady)
self.chat.register_event(ChatEvent.MESSAGE, _onMessage)
# chat.register_event(ChatEvent.SUB, on_sub)
self.chat.register_command('hello', _helloCommand)
self.chat.start()
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch. Vérifiez vos identifiants et redémarrez après correction : {e}')
else:
except Exception as e:
logging.error(f'Échec de l\'authentification Twitch : {e}')
else:
logging.info("Twitch n'est pas configuré")
async def _checkOnlineStreamers(self):
# pas bon faudrait faire un truc mieux
while True :
async def _checkOnlineStreamers(self):
while True:
try:
await checkOnlineStreamer(self.twitch)
except Exception as e:
logging.error(f'Erreur lors lors du check des streamers online : {e}')
# toutes les 5 minutes
await asyncio.sleep(5*60)
logging.error(f'Erreur check streamers online : {e}')
await asyncio.sleep(5 * 60)
def begin(self):
async def _runAnnouncements(self):
channel = ConfigurationHelper().getValue('twitch_channel')
while True:
try:
await checkAndSendAnnouncements(self.chat, channel)
except Exception as e:
logging.error(f'Erreur envoi annonces : {e}')
await asyncio.sleep(60)
def begin(self):
asyncio.run(self._connect())
# je ne sais pas encore comment appeler ça
async def _close(self):
self.chat.stop()
await self.twitch.close()
twitchBot = TwitchBot()
twitchBot = TwitchBot()
+51
View File
@@ -0,0 +1,51 @@
import logging
from datetime import datetime, timedelta
from twitchAPI.chat import Chat
from database import db
from database.models import TwitchAnnouncement
from webapp import webapp
logger = logging.getLogger('twitch-announcements')
logger.setLevel(logging.INFO)
async def checkAndSendAnnouncements(chat: Chat, channel: str):
"""
Vérifie et envoie les annonces dont la périodicité est écoulée.
Appelé périodiquement par le bot Twitch.
"""
with webapp.app_context():
announcements: list[TwitchAnnouncement] = TwitchAnnouncement.query.filter_by(enable=True).all()
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}')
def _shouldSend(announcement: TwitchAnnouncement, now: datetime) -> bool:
"""
Vérifie si une annonce doit être envoyée basée sur sa périodicité.
"""
if announcement.last_sent is None:
return True
time_since_last = now - announcement.last_sent
periodicity_delta = timedelta(minutes=announcement.periodicity)
return time_since_last >= periodicity_delta
async def _sendAnnouncement(chat: Chat, channel: str, announcement: TwitchAnnouncement):
"""
Envoie une annonce dans le chat Twitch.
"""
await chat.send_message(channel, announcement.text)
+1 -1
View File
@@ -10,4 +10,4 @@ webapp.config["BOT_STATUS"] = {
"twitch_channel_name": None,
}
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube
from webapp import commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements
+66
View File
@@ -0,0 +1,66 @@
from flask import render_template, request, redirect, url_for
from webapp import webapp
from database import db
from database.models import TwitchAnnouncement
@webapp.route("/announcements")
def openAnnouncements():
announcements = TwitchAnnouncement.query.all()
return render_template("announcements.html", announcements=announcements)
@webapp.route("/announcements/add", methods=['POST'])
def addAnnouncement():
announcement = TwitchAnnouncement(
enable=True,
name=request.form.get('name'),
text=request.form.get('text'),
periodicity=int(request.form.get('periodicity', 10)),
min_chat_messages=int(request.form.get('min_chat_messages', 0))
)
db.session.add(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/toggle/<int:id>")
def toggleAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.enable = not announcement.enable
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/edit/<int:id>")
def openEditAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
return render_template("announcements.html", announcement=announcement)
@webapp.route("/announcements/edit/<int:id>", methods=['POST'])
def submitEditAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.name = request.form.get('name')
announcement.text = request.form.get('text')
announcement.periodicity = int(request.form.get('periodicity', 10))
announcement.min_chat_messages = int(request.form.get('min_chat_messages', 0))
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/del/<int:id>")
def delAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
db.session.delete(announcement)
db.session.commit()
return redirect(url_for("openAnnouncements"))
@webapp.route("/announcements/reset/<int:id>")
def resetAnnouncement(id):
announcement = TwitchAnnouncement.query.get_or_404(id)
announcement.last_sent = None
db.session.commit()
return redirect(url_for("openAnnouncements"))
+188
View File
@@ -0,0 +1,188 @@
{% extends "template.html" %}
{% block content %}
<div class="space-y-8">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Annonces Twitch</h1>
<p class="text-gray-600 dark:text-gray-400">Messages automatiques périodiques dans le chat</p>
</div>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
Configurez des messages envoyés automatiquement dans le chat Twitch.
L'annonce est envoyée uniquement si le temps est écoulé ET si le nombre minimum de messages a été atteint.
</p>
</div>
{% if not announcement %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Annonces configurées</h2>
</div>
{% if announcements %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Nom</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Temps</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Min. messages</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Dernier envoi</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for ann in announcements %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<span class="font-medium text-gray-900 dark:text-white">{{ ann.name }}</span>
</td>
<td class="px-6 py-4">
<span class="text-gray-600 dark:text-gray-300 text-sm max-w-xs truncate block">{{ ann.text[:60] }}{% if ann.text|length > 60 %}...{% endif %}</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{{ ann.periodicity }} min
</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{{ ann.min_chat_messages }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{% if ann.last_sent %}
{{ ann.last_sent.strftime('%d/%m %H:%M') }}
{% else %}
<span class="italic">Jamais</span>
{% endif %}
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('toggleAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="{{ 'Désactiver' if ann.enable else 'Activer' }}">
{% if ann.enable %}
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% else %}
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% endif %}
</a>
<a href="{{ url_for('resetAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Remettre le compteur à zéro">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</a>
<a href="{{ url_for('openEditAnnouncement', id=ann.id) }}"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Modifier">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</a>
<a href="{{ url_for('delAnnouncement', id=ann.id) }}"
onclick="return confirm('Êtes-vous sûr de vouloir supprimer cette annonce ?')"
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
title="Supprimer">
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">Aucune annonce</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Commencez par créer votre première annonce automatique.</p>
</div>
{% endif %}
</div>
{% endif %}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">
{{ 'Modifier l\'annonce' if announcement else 'Ajouter une annonce' }}
</h2>
<form action="{{ url_for('submitEditAnnouncement', id=announcement.id) if announcement else url_for('addAnnouncement') }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nom de l'annonce
</label>
<input type="text" name="name" id="name" required maxlength="64"
value="{{ announcement.name if announcement else '' }}"
placeholder="Ex: Règles du chat"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
</div>
<div>
<label for="periodicity" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Temps entre les annonces (minutes)
</label>
<input type="number" name="periodicity" id="periodicity" required min="1" max="1440"
value="{{ announcement.periodicity if announcement else 10 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">1 min à 1440 min (24h)</p>
</div>
<div>
<label for="min_chat_messages" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Messages minimum entre annonces
</label>
<input type="number" name="min_chat_messages" id="min_chat_messages" required min="0" max="1000"
value="{{ announcement.min_chat_messages if announcement else 0 }}"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">0 = pas de minimum</p>
</div>
</div>
<div>
<label for="text" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Message
</label>
<textarea name="text" id="text" required maxlength="500" rows="4"
placeholder="Le message qui sera envoyé dans le chat..."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors resize-none">{{ announcement.text if announcement else '' }}</textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Maximum 500 caractères</p>
</div>
<div class="flex items-center gap-4">
<button type="submit"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-purple-500 focus:ring-offset-2">
{{ 'Enregistrer' if announcement else 'Ajouter' }}
</button>
{% if announcement %}
<a href="{{ url_for('openAnnouncements') }}"
class="px-6 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium rounded-lg transition-colors">
Annuler
</a>
{% endif %}
</div>
</form>
</div>
</div>
{% endblock %}
+8 -3
View File
@@ -128,9 +128,10 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</a>
<span class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-400 dark:text-gray-500 italic">
Bot Twitch — à venir
</span>
<a href="/announcements" class="flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces
</a>
</div>
</div>
</div>
@@ -165,6 +166,10 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
Alerte live
</a>
<a href="/announcements" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"></path></svg>
Annonces Twitch
</a>
<a href="/youtube" class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
YouTube