feat: Add initial Mobilizon => Discord sync #1

Merged
doomhammer merged 2 commits from add-initial-approach into main 2026-04-20 18:42:05 +00:00
12 changed files with 545 additions and 49 deletions
Showing only changes of commit 3fe23fdc38 - Show all commits

6
.envrc
View file

@ -1,5 +1,11 @@
#!/usr/bin/env bash
if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then

Check if actually needed.
Add explanation comment.

Check if actually needed. Add explanation comment.
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

This was done on purpose - check only in pre-commit hook, force dev to know what formatting has happened.

This was done on purpose - check only in pre-commit hook, force dev to know what formatting has happened.

Then we should have a command that runs all the linters and formatters as we seem to have checks that enforce certain rules but don't have a command to apply those rules.

Then we should have a command that runs all the linters and formatters as we seem to have checks that enforce certain rules but don't have a command to apply those rules.

#19

https://forge.hs3.pl/leming/kronos/issues/19
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"]

What is the reason for change?

What is the reason for change?

uv made me do it

uv made me do it
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"]
@ -47,7 +72,7 @@ exclude = [
]
line-length = 100
indent-width = 4
indent-width = 2

What is the reason for change?

What is the reason for change?
  1. I prefer smaller files
  2. For some reason ruff seemed to like the line length when formatting but not when linting. Using 2 spaces for indent made it shut its yap. I know, ugly. And disgusting. But I spent too much time looking for a solution...
1. I prefer smaller files 2. For some reason ruff seemed to like the line length when formatting but not when linting. Using 2 spaces for indent made it shut its yap. I know, ugly. And disgusting. But I spent too much time looking for a solution...
target-version = "py313"
[tool.ruff.lint]

8
shell.nix Normal file
View file

@ -0,0 +1,8 @@
{

Not an issue, but let me know what happens here.

Not an issue, but let me know what happens here.

I'll add the comments to the file

I'll add the comments to the file
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 = [
doomhammer marked this conversation as resolved

What's with the french?

What's with the french?

It was stolen from the French: https://github.com/cleming/newsletter-kalepin

It was stolen from the French: https://github.com/cleming/newsletter-kalepin
"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>