feat: Add initial Mobilizon => Discord sync (#1)
Closes #2 , #3 Co-authored-by: Piotr Gaczkowski <DoomHammerNG@gmail.com> Reviewed-on: #1 Reviewed-by: leming leming <leming@hs3.pl>
This commit is contained in:
parent
246ef4c829
commit
2ec156422b
14 changed files with 742 additions and 57 deletions
0
src/kronos/__init__.py
Normal file
0
src/kronos/__init__.py
Normal file
86
src/kronos/discordbot.py
Normal file
86
src/kronos/discordbot.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import pickle
|
||||
import os.path
|
||||
import urllib.request
|
||||
|
||||
import discord
|
||||
|
||||
|
||||
class KronosBot(discord.Client):
|
||||
def attach_events(self, events):
|
||||
print(f"Adding {len(events)} events")
|
||||
self.events = events
|
||||
|
||||
async def on_ready(self):
|
||||
event_mapping_file = "event_mapping.pickle"
|
||||
event_mapping = dict()
|
||||
|
||||
try:
|
||||
if os.path.isfile(event_mapping_file):
|
||||
with open(event_mapping_file, "rb") as f:
|
||||
event_mapping = pickle.load(f)
|
||||
except Exception:
|
||||
print("There are problems opening the pickle jar")
|
||||
|
||||
try:
|
||||
print(f"Logged on as {self.user}!")
|
||||
guilds_total = len(self.guilds)
|
||||
print(f"I work for {guilds_total} guilds")
|
||||
guilds_processed = 0
|
||||
for guild in self.guilds:
|
||||
guild_id = str(guild)
|
||||
print(f"Processing {guild.name} as {guild.me.name}")
|
||||
guilds_processed += 1
|
||||
for event in self.events:
|
||||
description = event["link"] + "\n\n" + event["description"]
|
||||
|
||||
picture_url = "https://hs3.pl/images/zasoby/null.jpg"
|
||||
if event["picture_url"] is not None:
|
||||
picture_url = event["picture_url"]
|
||||
pic = urllib.request.urlopen(picture_url).read()
|
||||
|
||||
print(f"Processing event {event}")
|
||||
if event["link"] in event_mapping and guild_id in event_mapping[event["link"]]:
|
||||
print("Updating the event, this one already processed")
|
||||
discord_event = guild.get_scheduled_event(event_mapping[event["link"]][guild_id])
|
||||
if discord_event is not None:
|
||||
await discord_event.edit(
|
||||
name=event["title"],
|
||||
description=description,
|
||||
start_time=event["start_time"],
|
||||
end_time=event["end_time"],
|
||||
location=event["location"],
|
||||
image=pic,
|
||||
)
|
||||
else:
|
||||
discord_event = await guild.create_scheduled_event(
|
||||
name=event["title"],
|
||||
description=description,
|
||||
start_time=event["start_time"],
|
||||
end_time=event["end_time"],
|
||||
location=event["location"],
|
||||
image=pic,
|
||||
entity_type=discord.EntityType.external,
|
||||
privacy_level=discord.PrivacyLevel.guild_only,
|
||||
)
|
||||
if event["link"] not in event_mapping:
|
||||
event_mapping[event["link"]] = dict()
|
||||
event_mapping[event["link"]][guild_id] = discord_event.id
|
||||
print(f"Mobilizon event {event['link']} mapped to Discord event {discord_event.id}")
|
||||
|
||||
print(f"Dumping event mapping to a pickle: {event_mapping}")
|
||||
with open(event_mapping_file, "wb") as f:
|
||||
pickle.dump(event_mapping, f)
|
||||
except Exception:
|
||||
print("There are problems processing the events, mate")
|
||||
|
||||
print("So long, and thanks for all the fish!")
|
||||
await self.close()
|
||||
|
||||
async def on_message(self, message):
|
||||
print(f"Message from {message.author}: {message.content}")
|
||||
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = False
|
||||
|
||||
client = KronosBot(intents=intents)
|
||||
253
src/kronos/main.py
Executable file
253
src/kronos/main.py
Executable file
|
|
@ -0,0 +1,253 @@
|
|||
import datetime
|
||||
import os
|
||||
import sys
|
||||
from zoneinfo import ZoneInfo
|
||||
from argparse import ArgumentParser
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from dotenv import load_dotenv
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from premailer import transform
|
||||
|
||||
from .discordbot import KronosBot, intents
|
||||
|
||||
|
||||
MOBILIZON_API_URL = "https://events.hs3.pl/api"
|
||||
QUERY_LIMIT = 100
|
||||
TIME_WINDOW_DAYS = 365
|
||||
TRUNCATE_AFTER_CHARACTERS = 300
|
||||
TEMPLATE_FILENAME = "templates/newsletter_template.html"
|
||||
OUTPUT_FILENAME = "newsletter_events.html"
|
||||
|
||||
|
||||
def sanitize_html(raw_html: str) -> str:
|
||||
wrapper = f"<div>{raw_html}</div>"
|
||||
soup = BeautifulSoup(wrapper, "html5lib")
|
||||
div = soup.find("div")
|
||||
cleaned = "".join(str(child) for child in div.contents)
|
||||
return cleaned
|
||||
|
||||
|
||||
def get_time_window(days: int = 365):
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
ends_utc = now_utc + datetime.timedelta(days=days)
|
||||
|
||||
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
return begins_iso, ends_iso
|
||||
|
||||
|
||||
def build_graphql_query():
|
||||
return """
|
||||
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
|
||||
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
|
||||
total
|
||||
elements {
|
||||
__typename
|
||||
... on Event {
|
||||
id
|
||||
title
|
||||
description
|
||||
beginsOn
|
||||
picture {
|
||||
url
|
||||
}
|
||||
url
|
||||
physicalAddress {
|
||||
description
|
||||
locality
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
|
||||
query = build_graphql_query()
|
||||
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
|
||||
payload = {"query": query, "variables": variables}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "errors" in data:
|
||||
raise RuntimeError(f"GraphQL error: {data['errors']}")
|
||||
|
||||
events = []
|
||||
for elem in data["data"]["searchEvents"]["elements"]:
|
||||
if elem.get("__typename") == "Event":
|
||||
begins_iso = elem.get("beginsOn")
|
||||
if begins_iso:
|
||||
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
|
||||
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
|
||||
if dt_begins <= dt < dt_ends:
|
||||
events.append(elem)
|
||||
return events
|
||||
|
||||
|
||||
def prepare_events_for_template(raw_events: list) -> list:
|
||||
jours_semaine = [
|
||||
"poniedziałek",
|
||||
"wtorek",
|
||||
"środa",
|
||||
"czwartek",
|
||||
"piątek",
|
||||
"sobota",
|
||||
"niedziela",
|
||||
]
|
||||
mois_annee = [
|
||||
"",
|
||||
"stycznia",
|
||||
"lutego",
|
||||
"marca",
|
||||
"kwietnia",
|
||||
"maja",
|
||||
"czerwca",
|
||||
"lipca",
|
||||
"sierpnia",
|
||||
"września",
|
||||
"października",
|
||||
"listopada",
|
||||
"grudnia",
|
||||
]
|
||||
|
||||
prepared = []
|
||||
for ev in raw_events:
|
||||
title = ev.get("title", "Untitled")
|
||||
|
||||
raw_desc = ev.get("description") or ""
|
||||
cleaned_html = sanitize_html(raw_desc)
|
||||
|
||||
text_only = BeautifulSoup(cleaned_html, "html.parser").get_text()
|
||||
if len(text_only) > TRUNCATE_AFTER_CHARACTERS:
|
||||
truncated = text_only[:300].rstrip() + " …"
|
||||
else:
|
||||
truncated = text_only
|
||||
|
||||
begins_iso = ev.get("beginsOn")
|
||||
picture = ev.get("picture")
|
||||
picture_url = picture.get("url") if picture else None
|
||||
link = ev.get("url") or ""
|
||||
|
||||
if picture_url and "?" in picture_url:
|
||||
picture_url = picture_url.split("?", 1)[0]
|
||||
|
||||
phys = ev.get("physicalAddress")
|
||||
if phys:
|
||||
part1 = phys.get("description") or ""
|
||||
part2 = phys.get("locality") or ""
|
||||
location = ", ".join(x for x in (part1, part2) if x.strip())
|
||||
else:
|
||||
location = ""
|
||||
|
||||
dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||
dt_paris = dt_utc.astimezone(ZoneInfo("Europe/Paris"))
|
||||
jour_nom = jours_semaine[dt_paris.weekday()]
|
||||
jour_num = dt_paris.day
|
||||
mois_nom = mois_annee[dt_paris.month]
|
||||
annee = dt_paris.year
|
||||
heure = dt_paris.hour
|
||||
minute = dt_paris.minute
|
||||
minute_str = f"{minute:02d}"
|
||||
full_date = f"{jour_nom}, {jour_num} {mois_nom} {annee} @ {heure}:{minute_str}"
|
||||
|
||||
prepared.append(
|
||||
{
|
||||
"title": title,
|
||||
"description": truncated,
|
||||
"full_date": full_date,
|
||||
"picture_url": picture_url,
|
||||
"location": location,
|
||||
"link": link,
|
||||
"start_time": dt_utc,
|
||||
"end_time": dt_utc + datetime.timedelta(hours=1),
|
||||
}
|
||||
)
|
||||
|
||||
return prepared
|
||||
|
||||
|
||||
def render_newsletter(events: list, template_dir: str, template_name: str) -> str:
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(searchpath=template_dir),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template(template_name)
|
||||
|
||||
now_paris = datetime.datetime.now(ZoneInfo("Europe/Paris")).strftime("%Y-%m-%d %H:%M")
|
||||
return template.render(events=events, date_now=now_paris)
|
||||
|
||||
|
||||
def inline_css(input_html_path, output_html_path):
|
||||
with open(input_html_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
inlined_html = transform(html)
|
||||
with open(output_html_path, "w", encoding="utf-8") as f:
|
||||
f.write(inlined_html)
|
||||
|
||||
|
||||
def _main():
|
||||
load_dotenv()
|
||||
|
||||
parser = ArgumentParser(prog="kronos", description="Share events between services.")
|
||||
parser.add_argument("--name")
|
||||
_args = parser.parse_args()
|
||||
|
||||
try:
|
||||
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
|
||||
print(
|
||||
f"Fetching events between {begins_on} and {ends_on}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
raw_events = list(
|
||||
filter(
|
||||
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
|
||||
fetch_events(begins_on, ends_on),
|
||||
)
|
||||
)
|
||||
if not raw_events:
|
||||
print("No events found in the requested period.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
for ev in raw_events:
|
||||
print(f"{ev.get('title', 'Untitled')} — {ev.get('beginsOn', 'Unknown date')}")
|
||||
|
||||
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
|
||||
|
||||
events = prepare_events_for_template(raw_events)
|
||||
|
||||
os.makedirs("dist", exist_ok=True)
|
||||
output_path = os.path.join(os.getcwd(), "dist", OUTPUT_FILENAME)
|
||||
html_output = render_newsletter(
|
||||
events=events, template_dir=os.getcwd(), template_name=TEMPLATE_FILENAME
|
||||
)
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html_output)
|
||||
print(f"File '{output_path}' generated successfully.", file=sys.stderr)
|
||||
|
||||
inlined_output_path = os.path.join(os.getcwd(), "dist", "newsletter_events_inlined.html")
|
||||
inline_css(output_path, inlined_output_path)
|
||||
print(
|
||||
f"File '{inlined_output_path}' (inline CSS) generated.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
discord_token = os.getenv("DISCORD_TOKEN")
|
||||
client = KronosBot(intents=intents)
|
||||
client.attach_events(events)
|
||||
client.run(discord_token)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue