283 lines
7.7 KiB
Python
283 lines
7.7 KiB
Python
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))
|