feat: Add initial Mobilizon => Discord sync

This commit is contained in:
Piotr Gaczkowski 2026-04-01 11:36:01 +02:00
parent 246ef4c829
commit 3fe23fdc38
12 changed files with 545 additions and 49 deletions

6
.envrc
View file

@ -1,5 +1,11 @@
#!/usr/bin/env bash
if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w="
fi
watch_file nix/*.nix
use nix
export DIRENV_WARN_TIMEOUT=20s
eval "$(devenv direnvrc)"

3
.gitignore vendored
View file

@ -4,3 +4,6 @@ devenv.lock
uv.lock
__pycache__/
*.egg-info/
.env*
*.pickle
src/kronos/*events*.html

View file

@ -25,7 +25,7 @@ repos:
- id: ruff-format
name: Ruff format
description: Format.
entry: ruff format --check --config pyproject.toml
entry: ruff format --config pyproject.toml
language: system
types: [python]

View file

@ -21,7 +21,7 @@ pre-commit install
- Install as editable package.
```bash
pip install -e .
uv pip install -e .
```
## Run

View file

@ -1,3 +1,4 @@
{ pkgs, ... }:
{
name = "kronos";
@ -11,4 +12,26 @@
sync.allExtras = true;
};
};
packages = [
pkgs.uv
];
enterShell = ''
echo
echo "🪝 Installing pre-commit"
echo
pre-commit install
echo
echo " Installing kronos"
echo
uv pip install -e .
echo
echo "🙌 Welcome to kronos' devenv!"
echo 'To run kronos, type `kronos`'
echo
echo
'';
}

View file

@ -1,11 +0,0 @@
from argparse import ArgumentParser
def _main():
parser = ArgumentParser(prog="kronos", description="Share events between services.")
parser.add_argument("--name")
_args = parser.parse_args()
if __name__ == "__main__":
_main()

View file

@ -4,11 +4,36 @@ version = "0.1.0"
description = "Share events between services."
authors = [{ name = "Hackerspace Trójmiasto" }]
readme = "README.md"
license = { file = "LICENSE" }
license-files = ["LICENSE"]
requires-python = ">=3.13"
dependencies = []
dependencies = [
"beautifulsoup4==4.13.4",
"brevo-python==1.1.2",
"bs4==0.0.2",
"cachetools==6.1.0",
"certifi==2025.4.26",
"charset-normalizer==3.4.2",
"cssselect==1.3.0",
"cssutils==2.11.1",
"discord.py==2.7.1",
"html5lib==1.1",
"idna==3.10",
"Jinja2==3.1.6",
"lxml==6.0.0",
"MarkupSafe==3.0.2",
"more-itertools==10.7.0",
"premailer==3.10.0",
"python-dateutil==2.9.0.post0",
"python-dotenv==1.1.1",
"requests==2.32.4",
"six==1.17.0",
"soupsieve==2.7",
"typing_extensions==4.14.0",
"urllib3==2.5.0",
"webencodings==0.5.1",
]
[dependency-groups]
dev = ["pre-commit", "pre-commit-hooks", "pytest", "ruff"]
@ -18,47 +43,47 @@ kronos = "kronos.main:_main"
[tool.ruff]
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
line-length = 100
indent-width = 4
indent-width = 2
target-version = "py313"
[tool.ruff.lint]
select = [
# pycodestyle
"E",
"W",
# pyflakes
"F",
# pylint
"PL",
# pycodestyle
"E",
"W",
# pyflakes
"F",
# pylint
"PL",
]
ignore = []
fixable = ["ALL"]

8
shell.nix Normal file
View file

@ -0,0 +1,8 @@
{
pkgs ? import <nixpkgs> { },
}:
pkgs.mkShell {
buildInputs = with pkgs; [
devenv
];
}

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)
script_dir = os.path.dirname(os.path.abspath(__file__))
html_output = render_newsletter(
events=events, template_dir=script_dir, template_name=TEMPLATE_FILENAME
)
output_path = os.path.join(script_dir, OUTPUT_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(script_dir, "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()

View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="plr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Najbliższe wydarzenia w Hackerspace Trójmiasto (HS3)</title>
<style>
/* Dark mode */
@media (prefers-color-scheme: dark) {
.dark-mode-button {
background: #ffffff !important;
color: #000000 !important;
border: 1px solid #cccccc !important;
}
}
@media only screen and (max-width: 600px) {
.event-table {
display: block !important;
width: 100% !important;
}
.event-table tbody,
.event-table tr,
.event-table td {
display: block !important;
width: 100% !important;
box-sizing: border-box !important;
}
.event-image {
text-align: center !important;
padding: 20px 20px 10px 20px !important;
}
.event-content {
padding: 10px 10px 20px 10px !important;
width: 100% !important;
}
.event-image img {
width: 100% !important;
height: auto !important;
max-width: 300px !important;
}
/* Simplification mobile */
.event-content * {
max-width: none !important;
width: auto !important;
display: inline-block !important;
white-space: normal !important;
}
.event-content h2 {
display: block !important;
width: 100% !important;
}
.event-content a {
display: inline-block !important;
width: auto !important;
max-width: 200px !important;
}
.event-content div[style*="background:#ff7105"] {
white-space: nowrap !important;
font-size: 12px !important;
padding: 4px 8px !important;
}
}
</style>
</head>
<body style="background-color:#f0f2f5; font-family:Helvetica,Arial,sans-serif; color:#333; padding:20px;">
<div style="max-width:800px; margin:0 auto; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 4px 16px #eee;">
<div style="text-align:center; padding:40px 20px 20px 20px; background:#4B64F2;">
<img src="https://img.notionusercontent.com/s3/prod-files-secure%2F00deef52-50e0-4f54-b2ee-5c2fbc2c4c6d%2F2ad5c423-a798-4a76-9a07-9be972f119cb%2FHS_LOGO_COLOR_BLACK.png/size/w=1440?exp=1773155386&sig=QdEp5ee-WNVt8umc9b3zwnO4lrUpbwRKYym5aG0zyL4&id=89cbb915-e2d5-4d18-85d7-2c8cf5bae1b5&table=block" alt="Hackerspace Trójmiasto" style="width:230px; display:block; margin:0 auto 24px auto;">
<h1 style="color:#fff; font-size:24px; font-weight:500; margin:0;">NAJBLIŻSZE WYDARZENIA W HACKERSPACE TRÓJMIASTO (HS3)</h1>
</div>
<div style="padding:32px 10px 10px 10px; overflow-x:hidden;">
{% for event in events %}
<table class="event-table" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:32px; background:#fff; border-radius:8px; border:1px solid #eee; table-layout:fixed;">
<tr>
{% if event.picture_url %}
<td class="event-image" width="220" valign="top" style="padding:20px;">
<img src="{{ event.picture_url }}" alt="Photo de l'événement" style="display:block; max-width:200px; height:auto;">
</td>
{% endif %}
<td class="event-content" valign="top" style="padding:20px; overflow:hidden;">
<h2 style="font-size:20px; color:#2a2a2a; margin:0 0 8px 0; line-height:1.2; max-width:100%; white-space:normal;">{{ event.title }}</h2>
<div style="background:#ff7105; color:#fff; padding:6px 12px; border-radius:20px; font-size:14px; margin-bottom:12px; margin-top:4px; display:inline-block; font-weight:500; white-space:nowrap;">
{{ event.full_date }}
</div>
{% if event.location %}
<div style="font-size:15px; color:#000; font-weight:500; margin-bottom:6px; max-width:100%; white-space:normal;">Gdzie : {{ event.location }}</div>
{% endif %}
<div style="font-size:15px; color:#444; margin-bottom:12px; max-width:100%; white-space:normal;">{{ event.description }}</div>
{% if event.link %}
<a href="{{ event.link }}" class="dark-mode-button" style="display:inline-block; background:#4B64F2; color:#fff; text-decoration:none; padding:10px 20px; border-radius:4px; font-weight:500;">Sprawdź na HS3 Events</a>
{% endif %}
</td>
</tr>
</table>
{% endfor %}
</div>
<div style="padding:20px; text-align:center; font-size:14px; color:#777; background:#fafbfd;">
<p>Otrzymujesz ten newsletter ponieważ jesteś super cool 1337 osobą subskrybującą info HS3.</p>
</div>
</div>
</body>
</html>