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:
Piotr Gaczkowski 2026-04-20 18:42:04 +00:00
parent 246ef4c829
commit 2ec156422b
14 changed files with 742 additions and 57 deletions

0
src/kronos/__init__.py Normal file
View file

86
src/kronos/discordbot.py Normal file
View 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
View 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()