Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<<<<<<< HEAD
__pycache__
src/bot/cogs/
src/bot/.idea/
src/urpy/.idea/
__pycache__
src/bot/cogs/

src/bot/.idea/

src/urpy/.idea/
=======
test.yml
compose-build.yml
Expand All @@ -18,3 +18,6 @@ env/*
#invert to keep default .env (keep the default.*)
!env/default*
>>>>>>> parent of 268b102... test

# Données runtime de l'API planning (events.xml, webhooks.json)
api-data/
14 changes: 14 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# API de gestion du planning (remplace le CGI Web_Planning + l'écriture directe sur
# events.xml partagé par le bot, incompatibles avec des conteneurs séparés).
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt

COPY main.py main.py

EXPOSE 3000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000"]
186 changes: 186 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import json
import os
from datetime import datetime
from pathlib import Path
from typing import List

import httpx
from fastapi import FastAPI, HTTPException
from lxml import etree as et
from pydantic import BaseModel

app = FastAPI()

DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
EVENTS_XML_PATH = DATA_DIR / "events.xml"
WEBHOOKS_JSON_PATH = DATA_DIR / "webhooks.json"

# Union des Rolistes © 2020 by "Association Union des Rôlistes & co" is licensed under
# Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA)

# Repris tel quel de Web_Planning/src/www/cgi/modele_fiche_planning.txt (partie avant le
# "[" : la partie après contient des notes de réactions jamais utilisées par le CGI d'origine).
ANNOUNCEMENT_TEMPLATE = """
**Type ** {type}
:calendar: ** Date ** Le {date}
:earth_americas: ** Fuseau horaire ** {horaire}
:clapper: **Titre** {title}
:timer: ** Durée moyenne du scénario ** {length}
:person_standing_tone1: ** Nombre de joueurs ** {players}
:crown: **MJ** {pseudoMJ}
:d10: ** Système** {system}
:baby::skin-tone-1: **PJ Mineur ** {minors_allowed}
:star2: ** Plateformes ** {platforms}
:grey_question: ** Détails**
{details}

** Participe ** ✅ / ** Ne participe pas ❌ **
"""

# Ordre et valeurs alignés sur Web_Planning/src/www/cgi/const.py (MinorsAllowed), qui est
# ce que le formulaire réel envoie (jdr_pj radio : 0/1/2) - à ne pas confondre avec l'enum à
# 4 valeurs de cog_planning/const.py, utilisée ailleurs par le bot pour un usage différent.
MINORS_ALLOWED = {0: "oui", 1: "non préférable", 2: "non"}

# Ordre des balises XML repris de Bot_Base/src/urpy/xml.py (Calendar.TAGS), pour rester
# compatible avec le format lu par le rendu PHP du calendrier.
XML_TAGS_ORDER = [
"titre", "capacite", "minimum", "inscrits", "date", "heure", "duree",
"type", "mj", "systeme", "pjMineur", "plateformes", "details", "lien",
]

_webhooks: dict = {}


def _load_webhooks() -> None:
if WEBHOOKS_JSON_PATH.exists():
_webhooks.update({int(k): v for k, v in json.loads(WEBHOOKS_JSON_PATH.read_text()).items()})


def _save_webhooks() -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
WEBHOOKS_JSON_PATH.write_text(json.dumps(_webhooks))


_load_webhooks()


class WebhookRegistration(BaseModel):
user_id: int
webhook_url: str
guild_id: int
channel_id: int


class EventCreate(BaseModel):
user_id: int
pseudo: str
jdr_type: str
jdr_title: str
jdr_date: str # format "dd/mm/YYYY HH:MM", produit par le sélecteur tail.DateTime du formulaire
jdr_horaire: str # fuseau horaire choisi (ex. "GMT +1"), pas une heure
jdr_length: str
jdr_system: str
jdr_pj: int # 0/1/2, voir MINORS_ALLOWED
platform: List[str] = []
jdr_details: str
min_joueurs: int
max_joueurs: int


def _load_calendar() -> et._ElementTree:
if not EVENTS_XML_PATH.exists():
DATA_DIR.mkdir(parents=True, exist_ok=True)
et.ElementTree(et.Element("parties", last_id="0")).write(str(EVENTS_XML_PATH), pretty_print=True)
return et.parse(str(EVENTS_XML_PATH), et.XMLParser(remove_blank_text=True))


@app.post("/webhooks")
def register_webhook(reg: WebhookRegistration):
"""Enregistre le webhook du salon d'annonce pour un utilisateur (appelé par $jdr)."""
_webhooks[reg.user_id] = reg.dict()
_save_webhooks()
return {"ok": True}


@app.post("/events")
async def create_event(event: EventCreate):
"""Envoie l'annonce Discord via webhook et persiste l'événement dans events.xml."""
webhook = _webhooks.get(event.user_id)
if webhook is None:
raise HTTPException(
status_code=404,
detail="Aucun webhook enregistré pour cet utilisateur, relancez $jdr sur Discord.",
)

try:
dt = datetime.strptime(event.jdr_date, "%d/%m/%Y %H:%M")
except ValueError:
raise HTTPException(status_code=400, detail="Format de date invalide, attendu dd/mm/YYYY HH:MM.")

players = (
str(event.max_joueurs)
if event.max_joueurs == event.min_joueurs
else f"{event.max_joueurs} (min {event.min_joueurs})"
)
minors_allowed = MINORS_ALLOWED.get(event.jdr_pj, "?")
mj = f"<@{event.user_id}> [{event.pseudo}]"
platforms = " ".join(event.platform)

description = ANNOUNCEMENT_TEMPLATE.format(
type=event.jdr_type,
date=event.jdr_date,
horaire=event.jdr_horaire,
title=event.jdr_title,
length=event.jdr_length,
players=players,
pseudoMJ=mj,
system=event.jdr_system,
minors_allowed=minors_allowed,
platforms=platforms,
details=event.jdr_details,
)

async with httpx.AsyncClient() as client:
resp = await client.post(
f"{webhook['webhook_url']}?wait=true",
json={"embeds": [{"type": "rich", "description": description}]},
)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Échec de l'envoi Discord : {resp.status_code} {resp.text}")
message = resp.json()

tree = _load_calendar()
root = tree.getroot()
new_id = int(root.get("last_id", "0")) + 1
root.set("last_id", str(new_id))

lien = f"https://discord.com/channels/{webhook['guild_id']}/{webhook['channel_id']}/{message['id']}"
values = {
"titre": event.jdr_title,
"capacite": str(event.max_joueurs),
"minimum": str(event.min_joueurs),
"inscrits": "0",
"date": dt.strftime("%Y-%m-%d"),
"heure": dt.strftime("%Hh%M"),
"duree": event.jdr_length,
"type": event.jdr_type,
"mj": mj,
"systeme": event.jdr_system,
"pjMineur": minors_allowed,
"plateformes": platforms,
"details": event.jdr_details,
"lien": lien,
}

partie = et.SubElement(root, "partie", id=str(new_id))
for tag in XML_TAGS_ORDER:
et.SubElement(partie, tag).text = values[tag]
tree.write(str(EVENTS_XML_PATH), pretty_print=True)

return {"id": new_id, "lien": lien}


@app.get("/health")
def health():
return {"ok": True}
4 changes: 4 additions & 0 deletions api/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn==0.34.0
httpx==0.28.1
lxml==6.1.0
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,20 @@ services:
# Secret jamais committé ni copié dans l'image : monté au runtime,
# exactement comme config.json pour le bot rolelist.
- ./.bot_token:/usr/local/src/URbot/.bot_token:ro
environment:
# Base URL de l'API planning, jointe via le réseau Docker interne du stack.
- PLANNING_API_BASE_URL=http://planning-api:3000

planning-api:
build:
context: ./api
dockerfile: Dockerfile
container_name: planning-api
restart: unless-stopped
ports:
# Publié sur l'hôte car le CGI de Web_Planning tourne nativement sous Apache
# sur le VPS (pas dans un conteneur), il appelle donc http://localhost:3000.
- "3000:3000"
volumes:
# events.xml + webhooks.json : données réelles, persistées hors du conteneur.
- ./api-data:/data
Loading