From 540d23a3cf519576a29057d915bc38beed5ec2bc Mon Sep 17 00:00:00 2001 From: Mow910 Date: Sat, 7 Mar 2026 11:01:58 +0100 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20la=20gestion=20des=20publications?= =?UTF-8?q?=20Patreon=20dans=20la=20base=20de=20donn=C3=A9es.=20Cr=C3=A9at?= =?UTF-8?q?ion=20de=20la=20table=20`patreon=5Fpost`=20avec=20les=20colonne?= =?UTF-8?q?s=20n=C3=A9cessaires=20et=20mise=20=C3=A0=20jour=20des=20migrat?= =?UTF-8?q?ions=20pour=20int=C3=A9grer=20ces=20nouvelles=20colonnes.=20Int?= =?UTF-8?q?=C3=A9gration=20de=20la=20v=C3=A9rification=20des=20publication?= =?UTF-8?q?s=20Patreon=20dans=20le=20bot=20Discord=20et=20ajout=20de=20lie?= =?UTF-8?q?ns=20vers=20Patreon=20dans=20l'interface=20utilisateur.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/__init__.py | 18 +++ database/models.py | 10 ++ database/schema.sql | 9 ++ discordbot/__init__.py | 7 + discordbot/patreon.py | 282 +++++++++++++++++++++++++++++++++ webapp/__init__.py | 2 +- webapp/patreon.py | 103 ++++++++++++ webapp/templates/patreon.html | 206 ++++++++++++++++++++++++ webapp/templates/template.html | 28 ++-- 9 files changed, 654 insertions(+), 11 deletions(-) create mode 100644 discordbot/patreon.py create mode 100644 webapp/patreon.py create mode 100644 webapp/templates/patreon.html diff --git a/database/__init__.py b/database/__init__.py index b35ef13..6ff0c6d 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -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), diff --git a/database/models.py b/database/models.py index 2259340..75435a8 100644 --- a/database/models.py +++ b/database/models.py @@ -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) diff --git a/database/schema.sql b/database/schema.sql index 3a3ff8b..a9379fb 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/discordbot/__init__.py b/discordbot/__init__.py index f3382fe..65ee04c 100644 --- a/discordbot/__init__.py +++ b/discordbot/__init__.py @@ -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(): diff --git a/discordbot/patreon.py b/discordbot/patreon.py new file mode 100644 index 0000000..fe43b11 --- /dev/null +++ b/discordbot/patreon.py @@ -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'', '\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']+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 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)) diff --git a/webapp/__init__.py b/webapp/__init__.py index b8b6029..2220d08 100644 --- a/webapp/__init__.py +++ b/webapp/__init__.py @@ -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 diff --git a/webapp/patreon.py b/webapp/patreon.py new file mode 100644 index 0000000..04cba91 --- /dev/null +++ b/webapp/patreon.py @@ -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})) diff --git a/webapp/templates/patreon.html b/webapp/templates/patreon.html new file mode 100644 index 0000000..591e9a2 --- /dev/null +++ b/webapp/templates/patreon.html @@ -0,0 +1,206 @@ +{% extends "template.html" %} + +{% block content %} +
+

Patreon — Notifications de posts

+ {% if request.args.get('msg') %} + {% set msg_type = request.args.get('type') %} +
+ {{ request.args.get('msg') }} +
+ {% endif %} +
+

+ 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. +

+
+
+ +
+
+ +

Configuration Patreon

+
+ +
+
+ +
+ +
+ + +

Le nom tel qu'il apparaît dans l'URL : patreon.com/nom_du_createur

+
+ +
+ + +
+ +
+

Mentions (optionnel)

+

Choisissez qui mentionner au début du message (avant l'embed).

+
+ + +
+ {% if roles %} +
+

Rôles à mentionner

+ {% if roles|length > 1 %} +
+ {% for guild_data in roles %} + + {% endfor %} +
+ {% endif %} + {% for guild_data in roles %} +
+ {% for role in guild_data.roles %} + + {% endfor %} +
+ {% endfor %} +
+ {% endif %} +
+ + +
+ +
+

Aperçu de l'embed Discord

+

Exemple du message envoyé dans le canal lors d'un nouveau post Patreon.

+
+
+
+ + Nom du créateur +
+ Titre du post Patreon +

Ceci est un aperçu de la description du post Patreon. Le contenu HTML est automatiquement nettoyé et tronqué pour l'embed Discord...

+
+ +
+

MamieHenriette • Patreon

+
+
+
+
+ +{% if posts %} +
+
+
+

Historique des posts Patreon

+

{{ posts|length }} post{{ 's' if posts|length > 1 else '' }} enregistré{{ 's' if posts|length > 1 else '' }}

+
+
+
+ {% for post in posts %} +
+
+
+

+ {% if post.link %} + {{ post.title or 'Sans titre' }} + {% else %} + {{ post.title or 'Sans titre' }} + {% endif %} +

+ {% if post.notified %} + + + Notifié + + {% else %} + + + Non notifié + + {% endif %} +
+ {% if post.description %} +

{{ post.description|striptags|truncate(150) }}

+ {% endif %} + {% if post.published_formatted %} +

{{ post.published_formatted }}

+ {% endif %} +
+
+ + +
+
+
+
+ {% endfor %} +
+
+
+
+{% else %} +
+

Aucun post Patreon enregistré. Les posts apparaîtront ici après la première vérification du flux RSS.

+
+{% endif %} + + +{% endblock %} diff --git a/webapp/templates/template.html b/webapp/templates/template.html index e171b20..bc65760 100644 --- a/webapp/templates/template.html +++ b/webapp/templates/template.html @@ -106,11 +106,15 @@ ProtonDB - - 🎁 - FreeLoot - -
+ + 🎁 + FreeLoot + + + + Patreon + +
Commandes @@ -249,11 +253,15 @@ ProtonDB - - 🎁 - FreeLoot - - + + 🎁 + FreeLoot + + + + Patreon + + Configurations