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.

+
+
+ +