Ajout de la gestion des publications Patreon dans la base de données. Création de la table patreon_post avec les colonnes nécessaires et mise à jour des migrations pour intégrer ces nouvelles colonnes. Intégration de la vérification des publications Patreon dans le bot Discord et ajout de liens vers Patreon dans l'interface utilisateur.
This commit is contained in:
@@ -166,6 +166,23 @@ def _doAddColumnMigrations(cursor: Cursor):
|
||||
except Exception as e:
|
||||
logging.warning(f"Seed twitch_event_notification {ev}: {e}")
|
||||
|
||||
# Colonnes supplémentaires pour patreon_post (historique + statut notification)
|
||||
if _tableExists('patreon_post', cursor):
|
||||
patreon_columns = [
|
||||
('title', 'VARCHAR(512)'),
|
||||
('link', 'VARCHAR(1024)'),
|
||||
('description', 'TEXT'),
|
||||
('published_at', 'VARCHAR(64)'),
|
||||
('notified', 'BOOLEAN NOT NULL DEFAULT 0'),
|
||||
]
|
||||
for col_name, col_type in patreon_columns:
|
||||
if not _tableHaveColumn('patreon_post', col_name, cursor):
|
||||
try:
|
||||
cursor.execute(f'ALTER TABLE patreon_post ADD COLUMN {col_name} {col_type}')
|
||||
logging.info(f"Colonne {col_name} ajoutée à patreon_post")
|
||||
except Exception as e:
|
||||
logging.warning(f"Colonne patreon_post.{col_name}: {e}")
|
||||
|
||||
# Table webapp_user (auth)
|
||||
if not _tableExists('webapp_user', cursor):
|
||||
try:
|
||||
@@ -251,6 +268,7 @@ def _doSeedAuth(cursor: Cursor):
|
||||
("youtube", 1, 2),
|
||||
("protondb", 1, 2),
|
||||
("freeloot", 1, 2),
|
||||
("patreon", 1, 2),
|
||||
("moderation", 1, 2),
|
||||
("users", 5, 5),
|
||||
("settings", 5, 5),
|
||||
|
||||
@@ -228,6 +228,16 @@ class TwitchBannedWord(db.Model):
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PatreonPost(db.Model):
|
||||
__tablename__ = 'patreon_post'
|
||||
guid = db.Column(db.String(512), primary_key=True)
|
||||
title = db.Column(db.String(512))
|
||||
link = db.Column(db.String(1024))
|
||||
description = db.Column(db.Text)
|
||||
published_at = db.Column(db.String(64))
|
||||
notified = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
class ModShoutboxMessage(db.Model):
|
||||
__tablename__ = 'mod_shoutbox_message'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
@@ -199,6 +199,15 @@ CREATE TABLE IF NOT EXISTS `twitch_event_notification` (
|
||||
last_clip_id VARCHAR(128) NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `patreon_post` (
|
||||
guid VARCHAR(512) PRIMARY KEY,
|
||||
title VARCHAR(512),
|
||||
link VARCHAR(1024),
|
||||
description TEXT,
|
||||
published_at VARCHAR(64),
|
||||
notified BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `mod_shoutbox_message` (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`author` VARCHAR(64) NOT NULL,
|
||||
|
||||
@@ -26,6 +26,7 @@ from discordbot.moderation import (
|
||||
transfer_message_context_menu
|
||||
)
|
||||
from discordbot.welcome import sendWelcomeMessage, sendLeaveMessage, updateInviteCache
|
||||
from discordbot.patreon import checkPatreonPosts
|
||||
from discordbot.youtube import checkYouTubeVideos
|
||||
from discordbot.auto_rooms import on_voice_state_update_auto_rooms, on_raw_reaction_add_auto_rooms, on_message_auto_rooms, cleanup_orphaned_auto_rooms
|
||||
from protondb import searhProtonDb
|
||||
@@ -76,6 +77,7 @@ class DiscordBot(discord.Client):
|
||||
self.loop.create_task(self.updateHumbleBundle())
|
||||
self.loop.create_task(self.updateYouTube())
|
||||
self.loop.create_task(self.updateFreeLoot())
|
||||
self.loop.create_task(self.updatePatreon())
|
||||
|
||||
async def on_disconnect(self):
|
||||
webapp.config["BOT_STATUS"]["discord_connected"] = False
|
||||
@@ -105,6 +107,11 @@ class DiscordBot(discord.Client):
|
||||
await checkFreeLootAndNotify(self)
|
||||
await asyncio.sleep(30*60)
|
||||
|
||||
async def updatePatreon(self):
|
||||
while not self.is_closed():
|
||||
await checkPatreonPosts(self)
|
||||
await asyncio.sleep(10*60)
|
||||
|
||||
def getAllTextChannel(self) -> list[TextChannel]:
|
||||
channels = []
|
||||
for channel in self.get_all_channels():
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import requests
|
||||
from discord import Client
|
||||
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import PatreonPost
|
||||
from webapp import webapp
|
||||
|
||||
logger = logging.getLogger('patreon-notification')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
_patreon_first_check = True
|
||||
|
||||
|
||||
def _get_mention_content() -> str:
|
||||
raw = ConfigurationHelper().getValue("patreon_mention")
|
||||
if not raw or not str(raw).strip():
|
||||
return ""
|
||||
parts = []
|
||||
for s in str(raw).strip().split(","):
|
||||
s = s.strip()
|
||||
if s == "everyone":
|
||||
parts.append("@everyone")
|
||||
elif s == "here":
|
||||
parts.append("@here")
|
||||
elif s.isdigit():
|
||||
parts.append(f"<@&{s}>")
|
||||
return " ".join(parts) if parts else ""
|
||||
|
||||
|
||||
def _strip_html(html: str, max_len: int = 300) -> str:
|
||||
"""Extrait le texte brut depuis du HTML et tronque."""
|
||||
if not html:
|
||||
return ""
|
||||
text = re.sub(r'<br\s*/?>', '\n', html)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r' ', ' ', text)
|
||||
text = re.sub(r'&', '&', text)
|
||||
text = re.sub(r'<', '<', text)
|
||||
text = re.sub(r'>', '>', text)
|
||||
text = re.sub(r'&#\d+;', '', text)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text).strip()
|
||||
if len(text) > max_len:
|
||||
text = text[:max_len].rsplit(' ', 1)[0] + '...'
|
||||
return text
|
||||
|
||||
|
||||
def _extract_image(html: str) -> str | None:
|
||||
"""Extrait la première URL d'image depuis le contenu HTML."""
|
||||
if not html:
|
||||
return None
|
||||
match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
return None
|
||||
|
||||
|
||||
def _parse_item(item, creator_name: str) -> dict | None:
|
||||
"""Parse un <item> RSS et retourne un dict avec les métadonnées."""
|
||||
guid_elem = item.find('guid')
|
||||
if guid_elem is None or not guid_elem.text:
|
||||
return None
|
||||
title_elem = item.find('title')
|
||||
link_elem = item.find('link')
|
||||
desc_elem = item.find('description')
|
||||
pub_elem = item.find('pubDate')
|
||||
return {
|
||||
'guid': guid_elem.text.strip(),
|
||||
'title': title_elem.text if title_elem is not None else 'Nouveau post',
|
||||
'link': link_elem.text if link_elem is not None else '',
|
||||
'description': desc_elem.text if desc_elem is not None else '',
|
||||
'published_at': pub_elem.text if pub_elem is not None else '',
|
||||
'creator': creator_name,
|
||||
}
|
||||
|
||||
|
||||
def _fetch_rss() -> tuple[list[dict], str] | None:
|
||||
"""Fetch le RSS Patreon et retourne (posts, creator_name) ou None."""
|
||||
helper = ConfigurationHelper()
|
||||
creator = helper.getValue("patreon_creator")
|
||||
if not creator or not str(creator).strip():
|
||||
return None
|
||||
|
||||
rss_url = f"https://www.patreon.com/rss/{str(creator).strip()}"
|
||||
|
||||
try:
|
||||
response = requests.get(rss_url, timeout=15)
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: erreur réseau lors de la récupération du RSS: {e}")
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Patreon: HTTP {response.status_code} pour {rss_url}")
|
||||
return None
|
||||
|
||||
try:
|
||||
root = ET.fromstring(response.content)
|
||||
except ET.ParseError as e:
|
||||
logger.error(f"Patreon: erreur de parsing XML: {e}")
|
||||
return None
|
||||
|
||||
creator_name = creator
|
||||
channel_elem = root.find('.//channel/title')
|
||||
if channel_elem is not None and channel_elem.text:
|
||||
creator_name = channel_elem.text
|
||||
|
||||
items = root.findall('.//item')
|
||||
posts = []
|
||||
for item in items:
|
||||
parsed = _parse_item(item, creator_name)
|
||||
if parsed:
|
||||
posts.append(parsed)
|
||||
|
||||
return (posts, creator_name)
|
||||
|
||||
|
||||
def _build_embed(post: dict):
|
||||
import discord
|
||||
|
||||
title = post.get('title') or 'Nouveau post Patreon'
|
||||
link = post.get('link') or ''
|
||||
description = _strip_html(post.get('description') or '', max_len=350)
|
||||
creator = post.get('creator') or 'Patreon'
|
||||
image_url = _extract_image(post.get('description') or '')
|
||||
|
||||
helper = ConfigurationHelper()
|
||||
try:
|
||||
color = int(helper.getValue('patreon_embed_color') or 'F96854', 16)
|
||||
except (ValueError, TypeError):
|
||||
color = 0xF96854
|
||||
|
||||
embed = discord.Embed(
|
||||
title=title,
|
||||
url=link if link.startswith("http") else None,
|
||||
color=color,
|
||||
)
|
||||
|
||||
if description:
|
||||
embed.description = description
|
||||
|
||||
embed.set_author(
|
||||
name=creator,
|
||||
icon_url="https://c5.patreon.com/external/favicon/favicon-32x32.png",
|
||||
)
|
||||
|
||||
if image_url:
|
||||
embed.set_image(url=image_url)
|
||||
|
||||
embed.set_footer(text="MamieHenriette \u2022 Patreon")
|
||||
|
||||
return embed
|
||||
|
||||
|
||||
async def checkPatreonPosts(bot: Client):
|
||||
global _patreon_first_check
|
||||
with webapp.app_context():
|
||||
helper = ConfigurationHelper()
|
||||
if not helper.getValue("patreon_enable"):
|
||||
return
|
||||
|
||||
channel_id = helper.getIntValue("patreon_channel_id")
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
logger.warning("Patreon: canal Discord introuvable")
|
||||
return
|
||||
|
||||
result = await asyncio.to_thread(_fetch_rss)
|
||||
if not result:
|
||||
return
|
||||
|
||||
posts, creator_name = result
|
||||
|
||||
if not posts:
|
||||
logger.info("Patreon: aucun post trouvé dans le flux RSS")
|
||||
return
|
||||
|
||||
if _patreon_first_check:
|
||||
logger.info("Patreon: première vérification, synchronisation sans notification")
|
||||
for post_data in posts:
|
||||
guid = post_data['guid']
|
||||
if not PatreonPost.query.get(guid):
|
||||
try:
|
||||
db.session.add(PatreonPost(
|
||||
guid=guid,
|
||||
title=post_data['title'],
|
||||
link=post_data['link'],
|
||||
description=post_data['description'],
|
||||
published_at=post_data['published_at'],
|
||||
notified=False,
|
||||
))
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: erreur de synchronisation pour {guid}: {e}")
|
||||
db.session.rollback()
|
||||
_patreon_first_check = False
|
||||
return
|
||||
|
||||
for post_data in posts:
|
||||
guid = post_data['guid']
|
||||
|
||||
if PatreonPost.query.get(guid):
|
||||
continue
|
||||
|
||||
try:
|
||||
embed = _build_embed(post_data)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
db.session.add(PatreonPost(
|
||||
guid=guid,
|
||||
title=post_data['title'],
|
||||
link=post_data['link'],
|
||||
description=post_data['description'],
|
||||
published_at=post_data['published_at'],
|
||||
notified=True,
|
||||
))
|
||||
db.session.commit()
|
||||
logger.info(f"Patreon: notification envoyée pour '{post_data['title']}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: envoi Discord échoué pour {guid}: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
|
||||
async def _send_post_to_discord_async(bot: Client, guid: str) -> tuple[bool, str]:
|
||||
"""Envoie un post Patreon sur Discord (appel manuel). Retourne (succès, message)."""
|
||||
helper = ConfigurationHelper()
|
||||
channel_id = helper.getIntValue("patreon_channel_id")
|
||||
if not channel_id:
|
||||
return (False, "Aucun canal Discord configuré pour Patreon.")
|
||||
channel = bot.get_channel(channel_id)
|
||||
if not channel:
|
||||
return (False, "Canal Discord introuvable.")
|
||||
|
||||
post_db = PatreonPost.query.get(guid)
|
||||
if not post_db:
|
||||
return (False, "Post introuvable en base de données.")
|
||||
|
||||
creator = helper.getValue("patreon_creator") or "Patreon"
|
||||
# Tenter de récupérer le nom du créateur depuis le RSS
|
||||
result = _fetch_rss()
|
||||
creator_name = result[1] if result else creator
|
||||
|
||||
post_data = {
|
||||
'title': post_db.title or 'Nouveau post',
|
||||
'link': post_db.link or '',
|
||||
'description': post_db.description or '',
|
||||
'creator': creator_name,
|
||||
}
|
||||
|
||||
try:
|
||||
embed = _build_embed(post_data)
|
||||
content = _get_mention_content()
|
||||
await channel.send(content=content or None, embed=embed)
|
||||
post_db.notified = True
|
||||
db.session.commit()
|
||||
return (True, "Notification envoyée sur Discord.")
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: envoi manuel échoué pour {guid}: {e}")
|
||||
db.session.rollback()
|
||||
return (False, str(e))
|
||||
|
||||
|
||||
def send_post_to_discord_sync(bot: Client, guid: str) -> tuple[bool, str]:
|
||||
"""Appel synchrone pour envoyer un post sur Discord (depuis la webapp)."""
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
_send_post_to_discord_async(bot, guid),
|
||||
bot.loop,
|
||||
)
|
||||
return future.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.error(f"Patreon: send_post_to_discord_sync: {e}")
|
||||
return (False, str(e))
|
||||
+1
-1
@@ -36,7 +36,7 @@ def load_user(user_id):
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot
|
||||
from webapp import auth, commandes, configurations, index, humeurs, protondb, live_alert, twitch_auth, moderation, youtube, announcements, twitch_moderation, link_filter, twitch_events, users, settings, freeloot, patreon
|
||||
|
||||
from flask import request, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from flask import render_template, request, redirect, url_for
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from webapp import webapp
|
||||
from webapp.auth import require_page, can_write_page
|
||||
from database import db
|
||||
from database.helpers import ConfigurationHelper
|
||||
from database.models import PatreonPost
|
||||
from discordbot import bot
|
||||
from discordbot.patreon import send_post_to_discord_sync
|
||||
|
||||
|
||||
def _parse_mention_config(raw: str | None) -> tuple[bool, bool, list[str]]:
|
||||
everyone, here, role_ids = False, False, []
|
||||
if not raw or not str(raw).strip():
|
||||
return (everyone, here, role_ids)
|
||||
for part in str(raw).strip().split(","):
|
||||
part = part.strip()
|
||||
if part == "everyone":
|
||||
everyone = True
|
||||
elif part == "here":
|
||||
here = True
|
||||
elif part.isdigit():
|
||||
role_ids.append(part)
|
||||
return (everyone, here, role_ids)
|
||||
|
||||
|
||||
def _format_pub_date(raw: str | None) -> str:
|
||||
if not raw or not str(raw).strip():
|
||||
return ""
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
dt = parsedate_to_datetime(raw)
|
||||
return dt.strftime("%d/%m/%Y %H:%M")
|
||||
except Exception:
|
||||
return raw[:16] if len(raw or "") >= 16 else (raw or "")
|
||||
|
||||
|
||||
@webapp.route("/patreon")
|
||||
@require_page("patreon")
|
||||
def openPatreon():
|
||||
helper = ConfigurationHelper()
|
||||
channels = bot.getAllTextChannel()
|
||||
roles = bot.getAllRoles()
|
||||
raw_mention = helper.getValue("patreon_mention")
|
||||
mention_everyone, mention_here, mention_role_ids = _parse_mention_config(raw_mention)
|
||||
|
||||
posts = PatreonPost.query.order_by(PatreonPost.published_at.desc()).all()
|
||||
for p in posts:
|
||||
p.published_formatted = _format_pub_date(p.published_at)
|
||||
|
||||
return render_template(
|
||||
"patreon.html",
|
||||
configuration=helper,
|
||||
channels=channels,
|
||||
roles=roles,
|
||||
mention_everyone=mention_everyone,
|
||||
mention_here=mention_here,
|
||||
mention_role_ids=mention_role_ids,
|
||||
posts=posts,
|
||||
)
|
||||
|
||||
|
||||
@webapp.route("/patreon/update", methods=["POST"])
|
||||
@require_page("patreon")
|
||||
def updatePatreon():
|
||||
if not can_write_page("patreon"):
|
||||
return render_template("403.html"), 403
|
||||
helper = ConfigurationHelper()
|
||||
enable = request.form.get("patreon_enable") in ("on", "1", "true", "yes")
|
||||
creator = (request.form.get("patreon_creator") or "").strip()
|
||||
channel_id = request.form.get("patreon_channel_id")
|
||||
|
||||
mention_parts = []
|
||||
if request.form.get("patreon_mention_everyone"):
|
||||
mention_parts.append("everyone")
|
||||
if request.form.get("patreon_mention_here"):
|
||||
mention_parts.append("here")
|
||||
mention_parts.extend(request.form.getlist("patreon_mention_roles"))
|
||||
|
||||
helper.createOrUpdate("patreon_enable", "true" if enable else "false")
|
||||
helper.createOrUpdate("patreon_creator", creator)
|
||||
if channel_id:
|
||||
try:
|
||||
helper.createOrUpdate("patreon_channel_id", str(int(channel_id)))
|
||||
except ValueError:
|
||||
pass
|
||||
helper.createOrUpdate("patreon_mention", ",".join(mention_parts))
|
||||
db.session.commit()
|
||||
return redirect(url_for("openPatreon") + "?msg=Configuration enregistrée.&type=success")
|
||||
|
||||
|
||||
@webapp.route("/patreon/send", methods=["POST"])
|
||||
@require_page("patreon")
|
||||
def sendPatreonToDiscord():
|
||||
if not can_write_page("patreon"):
|
||||
return render_template("403.html"), 403
|
||||
guid = (request.form.get("guid") or "").strip()
|
||||
if not guid:
|
||||
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": "Post manquant.", "type": "error"}))
|
||||
ok, message = send_post_to_discord_sync(bot, guid)
|
||||
msg_type = "success" if ok else "error"
|
||||
return redirect(url_for("openPatreon") + "?" + urlencode({"msg": message, "type": msg_type}))
|
||||
@@ -0,0 +1,206 @@
|
||||
{% extends "template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">Patreon — Notifications de posts</h1>
|
||||
{% if request.args.get('msg') %}
|
||||
{% set msg_type = request.args.get('type') %}
|
||||
<div class="mb-4 p-4 rounded-lg {% if msg_type == 'success' %}bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300{% elif msg_type == 'error' %}bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300{% else %}bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300{% endif %}">
|
||||
{{ request.args.get('msg') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
Notifications des nouveaux posts Patreon via le flux RSS public. Renseignez le nom du créateur Patreon
|
||||
et choisissez le canal Discord de destination. Le bot vérifie le flux environ toutes les 10 minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<svg class="w-8 h-8 text-orange-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Configuration Patreon</h2>
|
||||
</div>
|
||||
|
||||
<form action="{{ url_for('updatePatreon') }}" method="POST" class="space-y-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="patreon_enable" {% if configuration.getValue('patreon_enable') %}checked{% endif %}
|
||||
class="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Activer les notifications Patreon</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="patreon_creator" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Nom du créateur Patreon</label>
|
||||
<input type="text" name="patreon_creator" id="patreon_creator"
|
||||
value="{{ configuration.getValue('patreon_creator') or '' }}"
|
||||
placeholder="ex: nom_du_createur"
|
||||
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-orange-500 focus:border-transparent transition-all">
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Le nom tel qu'il apparaît dans l'URL : patreon.com/<strong>nom_du_createur</strong></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="patreon_channel_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Canal Discord pour les notifications</label>
|
||||
<select name="patreon_channel_id" id="patreon_channel_id"
|
||||
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-orange-500 focus:border-transparent transition-all">
|
||||
<option value="">— Choisir un canal —</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.id }}" {% if configuration.getIntValue('patreon_channel_id') == channel.id %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 space-y-4">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">Mentions (optionnel)</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Choisissez qui mentionner au début du message (avant l'embed).</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="patreon_mention_everyone" {% if mention_everyone %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@everyone</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="patreon_mention_here" {% if mention_here %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">@here</span>
|
||||
</label>
|
||||
</div>
|
||||
{% if roles %}
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rôles à mentionner</p>
|
||||
{% if roles|length > 1 %}
|
||||
<div class="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-600 mb-3">
|
||||
{% for guild_data in roles %}
|
||||
<button type="button" class="patreon-role-tab px-4 py-2 text-sm font-medium rounded-t-lg transition-colors {% if loop.first %}bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600{% endif %}"
|
||||
data-tab="patreon-roles-{{ guild_data.guild_id }}" {% if loop.first %}data-default{% endif %}>
|
||||
{{ guild_data.guild_name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for guild_data in roles %}
|
||||
<div id="patreon-roles-{{ guild_data.guild_id }}" class="patreon-role-panel {% if not loop.first %}hidden{% endif %} max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-600 rounded-lg p-3 space-y-2">
|
||||
{% for role in guild_data.roles %}
|
||||
<label class="flex items-center gap-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 p-1 rounded">
|
||||
<input type="checkbox" name="patreon_mention_roles" value="{{ role.id }}"
|
||||
{% if role.id|string in mention_role_ids %}checked{% endif %}
|
||||
class="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-orange-600 focus:ring-orange-500 dark:bg-gray-700">
|
||||
{% if role.color is defined and role.color is not none and role.color.value != 0 %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color:#{{ '%06x'|format(role.color.value) }}"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0 bg-gray-400"></span>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ role.name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="px-6 py-2.5 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
Enregistrer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 mb-3">Aperçu de l'embed Discord</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Exemple du message envoyé dans le canal lors d'un nouveau post Patreon.</p>
|
||||
<div class="inline-block rounded-r-lg overflow-hidden border border-gray-300 dark:border-gray-600 bg-[#2f3136] max-w-lg shadow-lg" style="border-left: 4px solid #F96854;">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<img src="https://c5.patreon.com/external/favicon/favicon-32x32.png" alt="" class="w-6 h-6 rounded-full">
|
||||
<span class="text-[#dcddde] text-sm font-medium">Nom du créateur</span>
|
||||
</div>
|
||||
<a href="#" class="text-[#00a8fc] hover:underline font-semibold text-base block mb-2">Titre du post Patreon</a>
|
||||
<p class="text-[#dcddde] text-sm leading-relaxed mb-3">Ceci est un aperçu de la description du post Patreon. Le contenu HTML est automatiquement nettoyé et tronqué pour l'embed Discord...</p>
|
||||
<div class="rounded overflow-hidden bg-[#202225] aspect-video flex items-center justify-center my-2">
|
||||
<svg class="w-12 h-12 text-gray-500" fill="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
</div>
|
||||
<p class="text-xs text-[#72767d] pt-1">MamieHenriette • Patreon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if posts %}
|
||||
<div class="mt-8">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg 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-xl font-semibold text-gray-900 dark:text-white">Historique des posts Patreon</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ posts|length }} post{{ 's' if posts|length > 1 else '' }} enregistré{{ 's' if posts|length > 1 else '' }}</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for post in posts %}
|
||||
<article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-md transition-shadow flex flex-col">
|
||||
<div class="p-4 flex flex-col flex-1">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white line-clamp-2 flex-1" title="{{ post.title or 'Sans titre' }}">
|
||||
{% if post.link %}
|
||||
<a href="{{ post.link }}" target="_blank" rel="noopener noreferrer" class="hover:text-orange-600 dark:hover:text-orange-400 transition-colors">{{ post.title or 'Sans titre' }}</a>
|
||||
{% else %}
|
||||
{{ post.title or 'Sans titre' }}
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if post.notified %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 flex-shrink-0">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
Notifié
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01"></path></svg>
|
||||
Non notifié
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if post.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-3">{{ post.description|striptags|truncate(150) }}</p>
|
||||
{% endif %}
|
||||
{% if post.published_formatted %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-3">{{ post.published_formatted }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-auto">
|
||||
<form action="{{ url_for('sendPatreonToDiscord') }}" method="POST" class="w-full">
|
||||
<input type="hidden" name="guid" value="{{ post.guid }}">
|
||||
<button type="submit" class="w-full inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
|
||||
{% if post.notified %}Re-notifier{% else %}Envoyer sur Discord{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-8 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Aucun post Patreon enregistré. Les posts apparaîtront ici après la première vérification du flux RSS.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.patreon-role-tab').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
var tabId = this.getAttribute('data-tab');
|
||||
document.querySelectorAll('.patreon-role-panel').forEach(p => p.classList.add('hidden'));
|
||||
document.querySelectorAll('.patreon-role-tab').forEach(b => {
|
||||
b.classList.remove('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
b.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
document.getElementById(tabId).classList.remove('hidden');
|
||||
this.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
|
||||
this.classList.add('bg-gray-200', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white');
|
||||
});
|
||||
});
|
||||
document.querySelector('.patreon-role-tab[data-default]')?.click();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -106,11 +106,15 @@
|
||||
<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 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<a href="{{ url_for('openPatreon') }}" 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="currentColor" viewBox="0 0 24 24"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
Patreon
|
||||
</a>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
|
||||
<a href="/commandes" 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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
Commandes
|
||||
@@ -249,11 +253,15 @@
|
||||
<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 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"></path></svg>
|
||||
ProtonDB
|
||||
</a>
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<a href="/configurations" 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">
|
||||
<a href="{{ url_for('openFreeLoot') }}" 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">
|
||||
<span class="text-lg">🎁</span>
|
||||
FreeLoot
|
||||
</a>
|
||||
<a href="{{ url_for('openPatreon') }}" 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="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-2.56 0-4.81-1.34-6.09-3.36L2 20.6h3.12l2.6-2.63 1.38 2.63h3.36l-2.75-5.23c.27-.39.51-.8.71-1.24l4.82 6.47h3.76l-6.47-8.67c.19-.76.29-1.55.29-2.37 0-1.32-.28-2.58-.78-3.71h3.63L14.82 2.41zM14.82 4.92c2.57 0 4.67 2.09 4.67 4.7 0 2.57-2.1 4.67-4.67 4.67-2.58 0-4.68-2.1-4.68-4.67 0-2.61 2.1-4.7 4.68-4.7z"/></svg>
|
||||
Patreon
|
||||
</a>
|
||||
<a href="/configurations" 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||
Configurations
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user