From 3fe23fdc3825544881e4acc6fad0340f8cf460df Mon Sep 17 00:00:00 2001 From: Piotr Gaczkowski Date: Wed, 1 Apr 2026 11:36:01 +0200 Subject: [PATCH 1/3] feat: Add initial Mobilizon => Discord sync --- .envrc | 6 + .gitignore | 3 + .pre-commit-config.yaml | 2 +- README.md | 2 +- devenv.nix | 23 ++ kronos/main.py | 11 - pyproject.toml | 97 ++++--- shell.nix | 8 + {kronos => src/kronos}/__init__.py | 0 src/kronos/discordbot.py | 86 ++++++ src/kronos/main.py | 253 ++++++++++++++++++ src/kronos/templates/newsletter_template.html | 103 +++++++ 12 files changed, 545 insertions(+), 49 deletions(-) delete mode 100755 kronos/main.py create mode 100644 shell.nix rename {kronos => src/kronos}/__init__.py (100%) create mode 100644 src/kronos/discordbot.py create mode 100755 src/kronos/main.py create mode 100644 src/kronos/templates/newsletter_template.html diff --git a/.envrc b/.envrc index cc5c18b..0d4fe40 100644 --- a/.envrc +++ b/.envrc @@ -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)" diff --git a/.gitignore b/.gitignore index ba0403c..d29838a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ devenv.lock uv.lock __pycache__/ *.egg-info/ +.env* +*.pickle +src/kronos/*events*.html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e552a88..982fb0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/README.md b/README.md index 3dcafbc..5c01ed4 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ pre-commit install - Install as editable package. ```bash -pip install -e . +uv pip install -e . ``` ## Run diff --git a/devenv.nix b/devenv.nix index a3ed509..8d8e23d 100644 --- a/devenv.nix +++ b/devenv.nix @@ -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 + ''; } diff --git a/kronos/main.py b/kronos/main.py deleted file mode 100755 index 8aae611..0000000 --- a/kronos/main.py +++ /dev/null @@ -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() diff --git a/pyproject.toml b/pyproject.toml index c55d976..185fa2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2f26af8 --- /dev/null +++ b/shell.nix @@ -0,0 +1,8 @@ +{ + pkgs ? import { }, +}: +pkgs.mkShell { + buildInputs = with pkgs; [ + devenv + ]; +} diff --git a/kronos/__init__.py b/src/kronos/__init__.py similarity index 100% rename from kronos/__init__.py rename to src/kronos/__init__.py diff --git a/src/kronos/discordbot.py b/src/kronos/discordbot.py new file mode 100644 index 0000000..71ff78e --- /dev/null +++ b/src/kronos/discordbot.py @@ -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) diff --git a/src/kronos/main.py b/src/kronos/main.py new file mode 100755 index 0000000..c747734 --- /dev/null +++ b/src/kronos/main.py @@ -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"
{raw_html}
" + 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() diff --git a/src/kronos/templates/newsletter_template.html b/src/kronos/templates/newsletter_template.html new file mode 100644 index 0000000..a0ebbbc --- /dev/null +++ b/src/kronos/templates/newsletter_template.html @@ -0,0 +1,103 @@ + + + + + + Najbliższe wydarzenia w Hackerspace Trójmiasto (HS3) + + + +
+
+ Hackerspace Trójmiasto +

NAJBLIŻSZE WYDARZENIA W HACKERSPACE TRÓJMIASTO (HS3)

+
+
+ {% for event in events %} + + + {% if event.picture_url %} + + {% endif %} + + +
+ Photo de l'événement + +

{{ event.title }}

+
+ {{ event.full_date }} +
+ {% if event.location %} +
Gdzie : {{ event.location }}
+ {% endif %} +
{{ event.description }}
+ {% if event.link %} + Sprawdź na HS3 Events + {% endif %} +
+ {% endfor %} +
+
+

Otrzymujesz ten newsletter ponieważ jesteś super cool 1337 osobą subskrybującą info HS3.

+
+
+ + From e0eb0b4617113b0d5fb1b1fdff685dfd21333986 Mon Sep 17 00:00:00 2001 From: Piotr Gaczkowski Date: Mon, 13 Apr 2026 10:53:07 +0200 Subject: [PATCH 2/3] chore: Implement code review suggestions --- .envrc | 4 +- .gitignore | 3 +- README.md | 6 + devenv.nix | 21 ++- npins/default.nix | 146 ++++++++++++++++++ npins/sources.json | 18 +++ shell.nix | 11 +- src/kronos/main.py | 8 +- .../newsletter_template.html | 0 9 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 npins/default.nix create mode 100644 npins/sources.json rename {src/kronos/templates => templates}/newsletter_template.html (100%) diff --git a/.envrc b/.envrc index 0d4fe40..97abe0c 100644 --- a/.envrc +++ b/.envrc @@ -1,12 +1,14 @@ #!/usr/bin/env bash +# Install nix_direnv for better caching and handling of nix configuration 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 +# shell.nix allows using devenv on nix systems that don't have devenv installed use nix -export DIRENV_WARN_TIMEOUT=20s +export DIRENV_WARN_TIMEOUT=30s eval "$(devenv direnvrc)" diff --git a/.gitignore b/.gitignore index d29838a..84365d3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__/ *.egg-info/ .env* *.pickle -src/kronos/*events*.html +dist/ +result diff --git a/README.md b/README.md index 5c01ed4..05ca4e0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Share events between services. Nix is required. +- Add cachix binary cache (this will speed up building): + +```bash +cachix use hs3city +``` + - Set up environment. ```bash diff --git a/devenv.nix b/devenv.nix index 8d8e23d..c46ffe5 100644 --- a/devenv.nix +++ b/devenv.nix @@ -2,14 +2,21 @@ { name = "kronos"; - languages.python = { - enable = true; - venv.enable = true; - version = "3.13.0"; - uv = { + cachix = { + pull = [ "hs3city" ]; + push = "hs3city"; + }; + + languages = { + python = { enable = true; - sync.enable = true; - sync.allExtras = true; + venv.enable = true; + version = "3.13.0"; + uv = { + enable = true; + sync.enable = true; + sync.allExtras = true; + }; }; }; diff --git a/npins/default.nix b/npins/default.nix new file mode 100644 index 0000000..6592476 --- /dev/null +++ b/npins/default.nix @@ -0,0 +1,146 @@ +/* + This file is provided under the MIT licence: + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +# Generated by npins. Do not modify; will be overwritten regularly +let + data = builtins.fromJSON (builtins.readFile ./sources.json); + version = data.version; + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295 + range = + first: last: if first > last then [ ] else builtins.genList (n: first + n) (last - first + 1); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257 + stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1)); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269 + stringAsChars = f: s: concatStrings (map f (stringToCharacters s)); + concatMapStrings = f: list: concatStrings (map f list); + concatStrings = builtins.concatStringsSep ""; + + # If the environment variable NPINS_OVERRIDE_${name} is set, then use + # the path directly as opposed to the fetched source. + # (Taken from Niv for compatibility) + mayOverride = + name: path: + let + envVarName = "NPINS_OVERRIDE_${saneName}"; + saneName = stringAsChars (c: if (builtins.match "[a-zA-Z0-9]" c) == null then "_" else c) name; + ersatz = builtins.getEnv envVarName; + in + if ersatz == "" then + path + else + # this turns the string into an actual Nix path (for both absolute and + # relative paths) + builtins.trace "Overriding path of \"${name}\" with \"${ersatz}\" due to set \"${envVarName}\"" ( + if builtins.substring 0 1 ersatz == "/" then + /. + ersatz + else + /. + builtins.getEnv "PWD" + "/${ersatz}" + ); + + mkSource = + name: spec: + assert spec ? type; + let + path = + if spec.type == "Git" then + mkGitSource spec + else if spec.type == "GitRelease" then + mkGitSource spec + else if spec.type == "PyPi" then + mkPyPiSource spec + else if spec.type == "Channel" then + mkChannelSource spec + else if spec.type == "Tarball" then + mkTarballSource spec + else + builtins.throw "Unknown source type ${spec.type}"; + in + spec // { outPath = mayOverride name path; }; + + mkGitSource = + { + repository, + revision, + url ? null, + submodules, + hash, + branch ? null, + ... + }: + assert repository ? type; + # At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository + # In the latter case, there we will always be an url to the tarball + if url != null && !submodules then + builtins.fetchTarball { + inherit url; + sha256 = hash; # FIXME: check nix version & use SRI hashes + } + else + let + url = + if repository.type == "Git" then + repository.url + else if repository.type == "GitHub" then + "https://github.com/${repository.owner}/${repository.repo}.git" + else if repository.type == "GitLab" then + "${repository.server}/${repository.repo_path}.git" + else + throw "Unrecognized repository type ${repository.type}"; + urlToName = + url: rev: + let + matched = builtins.match "^.*/([^/]*)(\\.git)?$" url; + + short = builtins.substring 0 7 rev; + + appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else ""; + in + "${if matched == null then "source" else builtins.head matched}${appendShort}"; + name = urlToName url revision; + in + builtins.fetchGit { + rev = revision; + inherit name; + # hash = hash; + inherit url submodules; + }; + + mkPyPiSource = + { url, hash, ... }: + builtins.fetchurl { + inherit url; + sha256 = hash; + }; + + mkChannelSource = + { url, hash, ... }: + builtins.fetchTarball { + inherit url; + sha256 = hash; + }; + + mkTarballSource = + { + url, + locked_url ? url, + hash, + ... + }: + builtins.fetchTarball { + url = locked_url; + sha256 = hash; + }; +in +if version == 5 then + builtins.mapAttrs mkSource data.pins +else + throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`" diff --git a/npins/sources.json b/npins/sources.json new file mode 100644 index 0000000..6ee51c7 --- /dev/null +++ b/npins/sources.json @@ -0,0 +1,18 @@ +{ + "pins": { + "nixpkgs": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "nixos", + "repo": "nixpkgs" + }, + "branch": "nixos-25.11", + "submodules": false, + "revision": "54170c54449ea4d6725efd30d719c5e505f1c10e", + "url": "https://github.com/nixos/nixpkgs/archive/54170c54449ea4d6725efd30d719c5e505f1c10e.tar.gz", + "hash": "0ayx6v3wwvqpdjkbfnbgyp7rwnsypgn6willidza61x9ilmxkqdp" + } + }, + "version": 5 +} diff --git a/shell.nix b/shell.nix index 2f26af8..528b29b 100644 --- a/shell.nix +++ b/shell.nix @@ -1,6 +1,15 @@ +# Use npins to pin nixpkgs dependencies +# This makes it easier to cache build results { - pkgs ? import { }, + sources ? import ./npins, + system ? builtins.currentSystem, + pkgs ? import sources.nixpkgs { + inherit system; + config = { }; + overlays = [ ]; + }, }: +# Create a nix shell with devenv (so `use devenv` can work in direnv/.envrc) pkgs.mkShell { buildInputs = with pkgs; [ devenv diff --git a/src/kronos/main.py b/src/kronos/main.py index c747734..25e9219 100755 --- a/src/kronos/main.py +++ b/src/kronos/main.py @@ -222,17 +222,17 @@ def _main(): events = prepare_events_for_template(raw_events) - script_dir = os.path.dirname(os.path.abspath(__file__)) + os.makedirs("dist", exist_ok=True) + output_path = os.path.join(os.getcwd(), "dist", OUTPUT_FILENAME) html_output = render_newsletter( - events=events, template_dir=script_dir, template_name=TEMPLATE_FILENAME + events=events, template_dir=os.getcwd(), 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") + 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.", diff --git a/src/kronos/templates/newsletter_template.html b/templates/newsletter_template.html similarity index 100% rename from src/kronos/templates/newsletter_template.html rename to templates/newsletter_template.html From 2ec156422bf9eb3634ee9f02554f4d34869ff9a8 Mon Sep 17 00:00:00 2001 From: Piotr Gaczkowski Date: Mon, 20 Apr 2026 18:42:04 +0000 Subject: [PATCH 3/3] feat: Add initial Mobilizon => Discord sync (#1) Closes #2 , #3 Co-authored-by: Piotr Gaczkowski Reviewed-on: https://forge.hs3.pl/leming/kronos/pulls/1 Reviewed-by: leming leming --- .envrc | 10 +- .gitignore | 4 + .pre-commit-config.yaml | 2 +- README.md | 8 +- devenv.nix | 44 ++++- kronos/main.py | 11 -- npins/default.nix | 146 +++++++++++++++++ npins/sources.json | 18 ++ pyproject.toml | 97 +++++++---- shell.nix | 17 ++ {kronos => src/kronos}/__init__.py | 0 src/kronos/discordbot.py | 86 ++++++++++ src/kronos/main.py | 253 +++++++++++++++++++++++++++++ templates/newsletter_template.html | 103 ++++++++++++ 14 files changed, 742 insertions(+), 57 deletions(-) delete mode 100755 kronos/main.py create mode 100644 npins/default.nix create mode 100644 npins/sources.json create mode 100644 shell.nix rename {kronos => src/kronos}/__init__.py (100%) create mode 100644 src/kronos/discordbot.py create mode 100755 src/kronos/main.py create mode 100644 templates/newsletter_template.html diff --git a/.envrc b/.envrc index cc5c18b..97abe0c 100644 --- a/.envrc +++ b/.envrc @@ -1,6 +1,14 @@ #!/usr/bin/env bash -export DIRENV_WARN_TIMEOUT=20s +# Install nix_direnv for better caching and handling of nix configuration +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 +# shell.nix allows using devenv on nix systems that don't have devenv installed +use nix + +export DIRENV_WARN_TIMEOUT=30s eval "$(devenv direnvrc)" diff --git a/.gitignore b/.gitignore index ba0403c..84365d3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ devenv.lock uv.lock __pycache__/ *.egg-info/ +.env* +*.pickle +dist/ +result diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e552a88..982fb0b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/README.md b/README.md index 3dcafbc..05ca4e0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Share events between services. Nix is required. +- Add cachix binary cache (this will speed up building): + +```bash +cachix use hs3city +``` + - Set up environment. ```bash @@ -21,7 +27,7 @@ pre-commit install - Install as editable package. ```bash -pip install -e . +uv pip install -e . ``` ## Run diff --git a/devenv.nix b/devenv.nix index a3ed509..c46ffe5 100644 --- a/devenv.nix +++ b/devenv.nix @@ -1,14 +1,44 @@ +{ pkgs, ... }: { name = "kronos"; - languages.python = { - enable = true; - venv.enable = true; - version = "3.13.0"; - uv = { + cachix = { + pull = [ "hs3city" ]; + push = "hs3city"; + }; + + languages = { + python = { enable = true; - sync.enable = true; - sync.allExtras = true; + venv.enable = true; + version = "3.13.0"; + uv = { + enable = true; + sync.enable = true; + 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 + ''; } diff --git a/kronos/main.py b/kronos/main.py deleted file mode 100755 index 8aae611..0000000 --- a/kronos/main.py +++ /dev/null @@ -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() diff --git a/npins/default.nix b/npins/default.nix new file mode 100644 index 0000000..6592476 --- /dev/null +++ b/npins/default.nix @@ -0,0 +1,146 @@ +/* + This file is provided under the MIT licence: + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ +# Generated by npins. Do not modify; will be overwritten regularly +let + data = builtins.fromJSON (builtins.readFile ./sources.json); + version = data.version; + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295 + range = + first: last: if first > last then [ ] else builtins.genList (n: first + n) (last - first + 1); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257 + stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1)); + + # https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269 + stringAsChars = f: s: concatStrings (map f (stringToCharacters s)); + concatMapStrings = f: list: concatStrings (map f list); + concatStrings = builtins.concatStringsSep ""; + + # If the environment variable NPINS_OVERRIDE_${name} is set, then use + # the path directly as opposed to the fetched source. + # (Taken from Niv for compatibility) + mayOverride = + name: path: + let + envVarName = "NPINS_OVERRIDE_${saneName}"; + saneName = stringAsChars (c: if (builtins.match "[a-zA-Z0-9]" c) == null then "_" else c) name; + ersatz = builtins.getEnv envVarName; + in + if ersatz == "" then + path + else + # this turns the string into an actual Nix path (for both absolute and + # relative paths) + builtins.trace "Overriding path of \"${name}\" with \"${ersatz}\" due to set \"${envVarName}\"" ( + if builtins.substring 0 1 ersatz == "/" then + /. + ersatz + else + /. + builtins.getEnv "PWD" + "/${ersatz}" + ); + + mkSource = + name: spec: + assert spec ? type; + let + path = + if spec.type == "Git" then + mkGitSource spec + else if spec.type == "GitRelease" then + mkGitSource spec + else if spec.type == "PyPi" then + mkPyPiSource spec + else if spec.type == "Channel" then + mkChannelSource spec + else if spec.type == "Tarball" then + mkTarballSource spec + else + builtins.throw "Unknown source type ${spec.type}"; + in + spec // { outPath = mayOverride name path; }; + + mkGitSource = + { + repository, + revision, + url ? null, + submodules, + hash, + branch ? null, + ... + }: + assert repository ? type; + # At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository + # In the latter case, there we will always be an url to the tarball + if url != null && !submodules then + builtins.fetchTarball { + inherit url; + sha256 = hash; # FIXME: check nix version & use SRI hashes + } + else + let + url = + if repository.type == "Git" then + repository.url + else if repository.type == "GitHub" then + "https://github.com/${repository.owner}/${repository.repo}.git" + else if repository.type == "GitLab" then + "${repository.server}/${repository.repo_path}.git" + else + throw "Unrecognized repository type ${repository.type}"; + urlToName = + url: rev: + let + matched = builtins.match "^.*/([^/]*)(\\.git)?$" url; + + short = builtins.substring 0 7 rev; + + appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else ""; + in + "${if matched == null then "source" else builtins.head matched}${appendShort}"; + name = urlToName url revision; + in + builtins.fetchGit { + rev = revision; + inherit name; + # hash = hash; + inherit url submodules; + }; + + mkPyPiSource = + { url, hash, ... }: + builtins.fetchurl { + inherit url; + sha256 = hash; + }; + + mkChannelSource = + { url, hash, ... }: + builtins.fetchTarball { + inherit url; + sha256 = hash; + }; + + mkTarballSource = + { + url, + locked_url ? url, + hash, + ... + }: + builtins.fetchTarball { + url = locked_url; + sha256 = hash; + }; +in +if version == 5 then + builtins.mapAttrs mkSource data.pins +else + throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`" diff --git a/npins/sources.json b/npins/sources.json new file mode 100644 index 0000000..6ee51c7 --- /dev/null +++ b/npins/sources.json @@ -0,0 +1,18 @@ +{ + "pins": { + "nixpkgs": { + "type": "Git", + "repository": { + "type": "GitHub", + "owner": "nixos", + "repo": "nixpkgs" + }, + "branch": "nixos-25.11", + "submodules": false, + "revision": "54170c54449ea4d6725efd30d719c5e505f1c10e", + "url": "https://github.com/nixos/nixpkgs/archive/54170c54449ea4d6725efd30d719c5e505f1c10e.tar.gz", + "hash": "0ayx6v3wwvqpdjkbfnbgyp7rwnsypgn6willidza61x9ilmxkqdp" + } + }, + "version": 5 +} diff --git a/pyproject.toml b/pyproject.toml index c55d976..185fa2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..528b29b --- /dev/null +++ b/shell.nix @@ -0,0 +1,17 @@ +# Use npins to pin nixpkgs dependencies +# This makes it easier to cache build results +{ + sources ? import ./npins, + system ? builtins.currentSystem, + pkgs ? import sources.nixpkgs { + inherit system; + config = { }; + overlays = [ ]; + }, +}: +# Create a nix shell with devenv (so `use devenv` can work in direnv/.envrc) +pkgs.mkShell { + buildInputs = with pkgs; [ + devenv + ]; +} diff --git a/kronos/__init__.py b/src/kronos/__init__.py similarity index 100% rename from kronos/__init__.py rename to src/kronos/__init__.py diff --git a/src/kronos/discordbot.py b/src/kronos/discordbot.py new file mode 100644 index 0000000..71ff78e --- /dev/null +++ b/src/kronos/discordbot.py @@ -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) diff --git a/src/kronos/main.py b/src/kronos/main.py new file mode 100755 index 0000000..25e9219 --- /dev/null +++ b/src/kronos/main.py @@ -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"
{raw_html}
" + 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() diff --git a/templates/newsletter_template.html b/templates/newsletter_template.html new file mode 100644 index 0000000..a0ebbbc --- /dev/null +++ b/templates/newsletter_template.html @@ -0,0 +1,103 @@ + + + + + + Najbliższe wydarzenia w Hackerspace Trójmiasto (HS3) + + + +
+
+ Hackerspace Trójmiasto +

NAJBLIŻSZE WYDARZENIA W HACKERSPACE TRÓJMIASTO (HS3)

+
+
+ {% for event in events %} + + + {% if event.picture_url %} + + {% endif %} + + +
+ Photo de l'événement + +

{{ event.title }}

+
+ {{ event.full_date }} +
+ {% if event.location %} +
Gdzie : {{ event.location }}
+ {% endif %} +
{{ event.description }}
+ {% if event.link %} + Sprawdź na HS3 Events + {% endif %} +
+ {% endfor %} +
+
+

Otrzymujesz ten newsletter ponieważ jesteś super cool 1337 osobą subskrybującą info HS3.

+
+
+ +