commit cbadfc4c7c5639d6e23fe9fb697aa3a157a7f828 Author: Valentin Bachem Date: Fri May 1 22:52:56 2026 +0200 Initial OSMCal to Mobilizon importer diff --git a/import-osmcal-to-mobilizon.py b/import-osmcal-to-mobilizon.py new file mode 100755 index 0000000..b2d1a61 --- /dev/null +++ b/import-osmcal-to-mobilizon.py @@ -0,0 +1,664 @@ +#!/usr/bin/env python3 +""" +Importiert OSMCal-Veranstaltungen im angegebenen Umkreis um Heidelberg nach Mobilizon. + +Datenquellen: +- OSMCal API: + https://osmcal.org/api/v2/events/?in=de + Liefert Titel, Datum, Ort, Koordinaten und Event-URL. + +- OSMCal Einzel-ICS: + https://osmcal.org/event/.ics + Liefert die Originalbeschreibung über DESCRIPTION. + +Ziel: +- Mobilizon GraphQL API: + https://rheinneckar.events/api + +Hinweise: +- Das Script importiert nur neue Events. +- Bereits importierte Events werden in imported-osmcal-events.json gespeichert. +- Bestehende Mobilizon-Entwürfe werden NICHT aktualisiert oder veröffentlicht. +- Veranstaltungsende wird immer auf Startzeit + 3 Stunden gesetzt. + +Voraussetzungen: +vor Ausführung Api-Schlüssel in die .env Datei speichern + +Ausführen: +source .venv/bin/activate +pip install requests python-dotenv icalendar +""" + +import os +import re +import json +import html +import hashlib +from datetime import datetime, timedelta +from math import radians, sin, cos, sqrt, atan2 +from pathlib import Path + +import requests +from dotenv import load_dotenv +from icalendar import Calendar + + +# ------------------------------------------------------------ +# .env laden +# ------------------------------------------------------------ + +SCRIPT_DIR = Path(__file__).resolve().parent +load_dotenv(SCRIPT_DIR / ".env") + + +# ------------------------------------------------------------ +# Konfiguration +# ------------------------------------------------------------ + +# Zentrum: Heidelberg +CENTER_LAT = 49.4094 +CENTER_LON = 8.6947 + +# Radius in Kilometern +RADIUS_KM = 18.0 + +# True = als Entwurf importieren +# False = direkt veröffentlichen +DRAFT = False + +# Feste Dauer je Veranstaltung +EVENT_DURATION_HOURS = 3 + +# OSMCal API für kommende Veranstaltungen in Deutschland +OSMCAL_API = "https://osmcal.org/api/v2/events/?in=de" + +# Mobilizon-Instanz aus .env oder Standardwert +MOBILIZON_INSTANCE = os.environ.get("MOBILIZON_INSTANCE", "https://rheinneckar.events") +MOBILIZON_API = f"{MOBILIZON_INSTANCE.rstrip('/')}/api" + +# Diese Werte müssen in .env stehen +MOBILIZON_TOKEN = os.environ["MOBILIZON_TOKEN"] +ORGANIZER_ACTOR_ID = os.environ["MOBILIZON_ORGANIZER_ACTOR_ID"] +ATTRIBUTED_TO_ID = os.environ["MOBILIZON_ATTRIBUTED_TO_ID"] + +# Datei zum Vermeiden von Duplikaten, hier werden bearbeitete Veranstaltungen gespeichert. +# vorher leeren wenn alle Veranstaltungen neu importiert werden sollen, mit +# echo '{}' > imported-osmcal-events.json +STATE_FILE = SCRIPT_DIR / "imported-osmcal-events.json" + + +# ------------------------------------------------------------ +# Mobilizon GraphQL Mutation +# ------------------------------------------------------------ + +CREATE_EVENT_MUTATION = """ +mutation createEvent( + $organizerActorId: ID! + $title: String! + $attributedToId: ID + $description: String! + $beginsOn: DateTime! + $endsOn: DateTime + $status: EventStatus + $visibility: EventVisibility + $joinOptions: EventJoinOptions + $draft: Boolean + $tags: [String] + $onlineAddress: String + $physicalAddress: AddressInput + $options: EventOptionsInput +) { + createEvent( + organizerActorId: $organizerActorId + attributedToId: $attributedToId + title: $title + description: $description + beginsOn: $beginsOn + endsOn: $endsOn + status: $status + visibility: $visibility + joinOptions: $joinOptions + draft: $draft + tags: $tags + onlineAddress: $onlineAddress + physicalAddress: $physicalAddress + options: $options + ) { + id + uuid + title + url + } +} +""" + + +# ------------------------------------------------------------ +# Allgemeine Hilfsfunktionen +# ------------------------------------------------------------ + +def clean_text(value): + """ + Wandelt Werte in saubere Strings um. + Gibt None zurück, wenn der Wert leer ist. + """ + if value is None: + return None + + value = str(value).strip() + + if not value: + return None + + return value + + +def add_hours_to_iso_datetime(iso_datetime, hours): + """ + Addiert Stunden auf eine ISO-Datetime-Zeichenkette. + + Beispiel: + 2026-05-18T19:00:00+02:00 + -> 2026-05-18T22:00:00+02:00 + """ + dt = datetime.fromisoformat(iso_datetime) + return (dt + timedelta(hours=hours)).isoformat() + + +def distance_km(lat1, lon1, lat2, lon2): + """ + Berechnet die Distanz zwischen zwei Koordinaten per Haversine-Formel. + Rückgabe: Kilometer. + """ + earth_radius_km = 6371.0 + + dlat = radians(lat2 - lat1) + dlon = radians(lon2 - lon1) + + a = ( + sin(dlat / 2) ** 2 + + cos(radians(lat1)) + * cos(radians(lat2)) + * sin(dlon / 2) ** 2 + ) + + return 2 * earth_radius_km * atan2(sqrt(a), sqrt(1 - a)) + + +def extract_osmcal_event_id(event_url): + """ + Extrahiert die numerische OSMCal-ID aus URLs wie: + https://osmcal.org/event/4742/ + """ + if not event_url: + return None + + match = re.search(r"/event/(\d+)/?", event_url) + + if not match: + return None + + return match.group(1) + + +def osmcal_event_ics_url(event_url): + """ + Wandelt eine OSMCal-Web-URL in die Einzel-ICS-URL um. + + Beispiel: + https://osmcal.org/event/4742/ + -> https://osmcal.org/event/4742.ics + """ + event_id = extract_osmcal_event_id(event_url) + + if not event_id: + return None + + return f"https://osmcal.org/event/{event_id}.ics" + + +# ------------------------------------------------------------ +# Statusdatei +# ------------------------------------------------------------ + +def save_state(state): + """ + Speichert die Liste bereits importierter Events. + """ + STATE_FILE.write_text( + json.dumps(state, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def load_state(): + """ + Lädt die lokale Import-Statusdatei. + + Verhalten: + - Datei fehlt: wird mit {} angelegt. + - Datei ist leer: wird mit {} ersetzt. + - Datei ist kaputt: wird gesichert und neu mit {} angelegt. + """ + if not STATE_FILE.exists(): + save_state({}) + return {} + + content = STATE_FILE.read_text(encoding="utf-8").strip() + + if not content: + save_state({}) + return {} + + try: + return json.loads(content) + except json.JSONDecodeError: + backup_file = STATE_FILE.with_suffix(".json.broken") + STATE_FILE.rename(backup_file) + + print(f"Warnung: {STATE_FILE.name} war kein gültiges JSON.") + print(f"Defekte Datei wurde gesichert als: {backup_file.name}") + print(f"{STATE_FILE.name} wird neu mit {{}} angelegt.") + + save_state({}) + return {} + + +def osmcal_event_key(event): + """ + Erzeugt einen stabilen Schlüssel für die Duplikatvermeidung. + + Primär wird die OSMCal-URL genutzt. + Falls sie fehlt, wird ein Hash aus Titel, Startzeit und Ort gebildet. + """ + if event.get("url"): + return event["url"] + + raw = "|".join( + [ + str(event.get("name", "")), + str(event.get("date", {}).get("start", "")), + str(event.get("location", {}).get("short", "")), + ] + ) + + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +# ------------------------------------------------------------ +# OSMCal lesen +# ------------------------------------------------------------ + +def fetch_osmcal_events(): + """ + Holt kommende OSMCal-Events für Deutschland. + """ + response = requests.get( + OSMCAL_API, + headers={ + "Accept-Language": "de", + "Client-App": "rheinneckar-events-import/0.6", + }, + timeout=30, + ) + + response.raise_for_status() + + events = response.json() + + if not isinstance(events, list): + raise RuntimeError("OSMCal-Antwort ist keine Event-Liste.") + + return events + + +def is_near_heidelberg(event): + """ + Prüft, ob ein Event im konfigurierten Radius um Heidelberg liegt. + + OSMCal liefert coords als [lon, lat]. + """ + location = event.get("location") or {} + coords = location.get("coords") + + if not coords or len(coords) != 2: + return False + + try: + lon = float(coords[0]) + lat = float(coords[1]) + except (TypeError, ValueError): + return False + + distance = distance_km(CENTER_LAT, CENTER_LON, lat, lon) + + return distance <= RADIUS_KM + + +def event_distance(event): + """ + Gibt die Entfernung eines Events zu Heidelberg zurück. + Rückgabe: gerundete Kilometer oder None. + """ + location = event.get("location") or {} + coords = location.get("coords") + + if not coords or len(coords) != 2: + return None + + try: + lon = float(coords[0]) + lat = float(coords[1]) + except (TypeError, ValueError): + return None + + return round(distance_km(CENTER_LAT, CENTER_LON, lat, lon), 1) + + +def fetch_osmcal_ics_description(event_url): + """ + Holt die Originalbeschreibung aus der Einzel-ICS-Datei. + + OSMCal-API liefert in der Eventliste keine Beschreibung. + Die Einzel-ICS enthält aber normalerweise DESCRIPTION. + """ + ics_url = osmcal_event_ics_url(event_url) + + if not ics_url: + return None + + response = requests.get( + ics_url, + headers={ + "Accept-Language": "de", + "Client-App": "rheinneckar-events-import/0.6", + }, + timeout=30, + ) + + response.raise_for_status() + + calendar = Calendar.from_ical(response.content) + + for component in calendar.walk(): + if component.name != "VEVENT": + continue + + description = component.get("DESCRIPTION") + + if description: + return str(description).strip() + + return None + + +# ------------------------------------------------------------ +# Umwandlung OSMCal -> Mobilizon +# ------------------------------------------------------------ + +def mobilizon_address_from_osmcal(event): + """ + Wandelt OSMCal-Ortsdaten in Mobilizon AddressInput um. + + Wichtig: + physicalAddress.description ist ein String, keine Liste. + """ + location = event.get("location") or {} + coords = location.get("coords") + + if not coords or len(coords) != 2: + return None + + try: + lon = float(coords[0]) + lat = float(coords[1]) + except (TypeError, ValueError): + return None + + venue = clean_text(location.get("venue")) + short = clean_text(location.get("short")) + detailed = clean_text(location.get("detailed")) + + description = venue or short or detailed or "Ort" + + address = { + "description": description, + "geom": f"{lon};{lat}", + "country": "DE", + } + + if short: + address["locality"] = short + + if detailed: + address["street"] = detailed + + return address + + +def mobilizon_description_from_osmcal(event): + """ + Baut die Mobilizon-Beschreibung. + + Reihenfolge: + 1. Originalbeschreibung aus der OSMCal-ICS + 2. Trennlinie + 3. Importhinweis und Metadaten + + Links werden als HTML erzeugt, nicht als Markdown. + """ + name = clean_text(event.get("name")) or "OSMCal-Veranstaltung" + escaped_name = html.escape(name) + + url = clean_text(event.get("url")) + escaped_url = html.escape(url) if url else None + + location = event.get("location") or {} + + location_text = clean_text( + location.get("venue") + or location.get("detailed") + or location.get("short") + ) + + distance = event_distance(event) + + original_description = clean_text( + event.get("description") + or event.get("details") + or event.get("body") + or event.get("text") + or event.get("summary") + or event.get("content") + ) + + if not original_description and url: + try: + original_description = fetch_osmcal_ics_description(url) + except Exception as exc: + print(f"Warnung: Beschreibung konnte nicht aus ICS gelesen werden: {url} - {exc}") + + parts = [] + + if original_description: + escaped_description = html.escape(original_description).replace("\n", "
") + parts.append(f"

{escaped_description}

") + parts.append("
") + + parts.append("

Diese Veranstaltung wurde aus dem OpenStreetMap Calendar importiert.

") + + if escaped_url: + parts.append( + f'

Quelle: {escaped_url}

' + ) + + if location_text: + parts.append( + f"

Ort: {html.escape(location_text)}

" + ) + + if distance is not None: + parts.append( + f"

Entfernung von Heidelberg: {distance} km

" + ) + + parts.append(f"

Originaltitel: {escaped_name}

") + + return "\n".join(parts) + + +# ------------------------------------------------------------ +# Mobilizon schreiben +# ------------------------------------------------------------ + +def create_mobilizon_event(event): + """ + Legt ein Event in Mobilizon an. + + Gruppenposting: + - organizerActorId = Profil-ID + - attributedToId = Gruppen-ID + + Ende: + - OSMCal-Endzeit wird ignoriert. + - Mobilizon-Endzeit wird immer auf Startzeit + EVENT_DURATION_HOURS gesetzt. + """ + title = clean_text(event.get("name")) or "OSMCal-Veranstaltung" + + date = event.get("date") or {} + begins_on = clean_text(date.get("start")) + + if not begins_on: + raise ValueError(f"Event ohne Startzeit: {json.dumps(event, ensure_ascii=False)}") + + ends_on = add_hours_to_iso_datetime(begins_on, EVENT_DURATION_HOURS) + + variables = { + "organizerActorId": ORGANIZER_ACTOR_ID, + "attributedToId": ATTRIBUTED_TO_ID, + "title": title, + "description": mobilizon_description_from_osmcal(event), + "beginsOn": begins_on, + "endsOn": ends_on, + "status": "CONFIRMED", + "visibility": "PUBLIC", + "joinOptions": "FREE", + "draft": DRAFT, + "tags": ["osm", "openstreetmap", "rheinneckar"], + "onlineAddress": clean_text(event.get("url")), + "physicalAddress": mobilizon_address_from_osmcal(event), + "options": { + "commentModeration": "CLOSED", + "showStartTime": True, + "showEndTime": True, + }, + } + + # None-Werte entfernen, damit Mobilizon keine unnötigen Nullfelder bekommt. + variables = { + key: value + for key, value in variables.items() + if value is not None + } + + response = requests.post( + MOBILIZON_API, + headers={ + "Authorization": f"Bearer {MOBILIZON_TOKEN}", + "Content-Type": "application/json", + }, + json={ + "query": CREATE_EVENT_MUTATION, + "variables": variables, + }, + timeout=30, + ) + + try: + payload = response.json() + except Exception: + print("Mobilizon hat keine JSON-Antwort geliefert:") + print(response.text) + raise + + if response.status_code >= 400 or payload.get("errors"): + print() + print("Mobilizon-Fehler beim Import dieses Events:") + print(json.dumps(event, indent=2, ensure_ascii=False)) + print() + print("Gesendete Variablen:") + print(json.dumps(variables, indent=2, ensure_ascii=False)) + print() + print("Antwort von Mobilizon:") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + raise RuntimeError("Mobilizon-Import fehlgeschlagen.") + + return payload["data"]["createEvent"] + + +# ------------------------------------------------------------ +# Hauptprogramm +# ------------------------------------------------------------ + +def main(): + state = load_state() + events = fetch_osmcal_events() + + nearby_events = [] + + for event in events: + if event.get("cancelled", False): + continue + + if is_near_heidelberg(event): + nearby_events.append(event) + + print(f"{len(events)} Veranstaltungen aus OSMCal geladen") + print(f"{len(nearby_events)} Veranstaltungen im {RADIUS_KM:g}-km-Umkreis um Heidelberg") + + imported = 0 + skipped = 0 + + for event in nearby_events: + key = osmcal_event_key(event) + + if key in state: + skipped += 1 + continue + + created = create_mobilizon_event(event) + + state[key] = { + "osmcal_url": event.get("url"), + "mobilizon_id": created.get("id"), + "mobilizon_uuid": created.get("uuid"), + "mobilizon_url": created.get("url"), + "title": event.get("name"), + "start": event.get("date", {}).get("start"), + "distance_km": event_distance(event), + "draft": DRAFT, + "duration_hours": EVENT_DURATION_HOURS, + } + + save_state(state) + + imported += 1 + + print( + "Importiert:", + event.get("name"), + "->", + created.get("url") or created.get("uuid") or created.get("id"), + ) + + print() + print(f"Fertig. Importiert: {imported}, übersprungen: {skipped}") + + if DRAFT: + print() + print("Hinweis: DRAFT=True. Die Events wurden als Entwurf angelegt.") + print("Für Veröffentlichung im Script DRAFT = False setzen.") + + +if __name__ == "__main__": + main() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..9fdca0a --- /dev/null +++ b/readme.md @@ -0,0 +1,117 @@ +# OSMCal → Mobilizon Importer +EN: Import events from [OSMCal](https://osmcal.org/) into a Mobilizon group, e.g. `https://rheinneckar.events`. +DE: Importiert Veranstaltungen aus dem OpenStreetMap Calendar nach Mobilizon, filtert sie lokal nach Entfernung um Heidelberg und legt sie als Gruppenveranstaltungen an. + +--- + +## Features / Funktionen + +- Fetches German OSMCal events via API + Lädt deutsche OSMCal-Veranstaltungen über die API + +- Filters events by radius around Heidelberg + Filtert Veranstaltungen nach Radius um Heidelberg + +- Reads the original description from each event’s `.ics` file + Liest die Originalbeschreibung aus der jeweiligen `.ics`-Datei + +- Imports into Mobilizon via GraphQL + Importiert nach Mobilizon per GraphQL + +- Supports Mobilizon group posting + Unterstützt Veröffentlichung als Mobilizon-Gruppe + +- Avoids duplicate imports using a local state file + Verhindert doppelte Importe über eine lokale Statusdatei + +--- + +## Requirements / Voraussetzungen + +- Python 3 +- Mobilizon account +- Membership in the target Mobilizon group +- OAuth token for the Mobilizon API + +Install dependencies: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install requests python-dotenv icalendar + +#auf dem eigenen Rechner voraussetzungen schaffen +#Verzeichnis erstellen und darin ausführen: +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + + +## Configuration / Konfiguration + +Create a .env file: + +MOBILIZON_INSTANCE="https://rheinneckar.events" +MOBILIZON_TOKEN="API token" +MOBILIZON_ORGANIZER_ACTOR_ID="profile actor ID" +MOBILIZON_ATTRIBUTED_TO_ID="group actor ID" + +#The helper script setup-mobilizon.py can create this .env file. + +## Usage / Nutzung + +Run the importer: +```bash +source .venv/bin/activate +./import-osmcal-to-mobilizon.py + +Example output: + 70 Veranstaltungen aus OSMCal geladen + 9 Veranstaltungen im 15-km-Umkreis um Heidelberg + Fertig. Importiert: 9, übersprungen: 0 + +## Settings / Einstellungen +Edit these values in import-osmcal-to-mobilizon.py. +Diese Werte in dieser Datei anpassen + +Radius / Radius um die angegebenen Koordinaten +RADIUS_KM = 18.0 + +Draft mode / Entwurfsmodus +DRAFT = True + Creates drafts / Veranstaltungen werden als Entwurf veröffentlicht. +DRAFT = False + Publishes directly / Veranstaltungen werden direkt veröffentlicht. + +Event duration / Veranstaltungsdauer +EVENT_DURATION_HOURS = 3 + +## Duplicate handling / Umgang mit bereits importierten Veranstaltungen + +Imported events are stored in: +imported-osmcal-events.json + +Veranstaltungen in dieser Datei werden bei einem erneuten Lauf des Skriptes übersprungen. +Events listed there are skipped on future runs. + +Reset for a new test / Zurücksetzen vor einen neuen Lauf: +```bash +echo '{}' > imported-osmcal-events.json + +Vorher alte Entwürfe oder Events in Mobilizon löschen, sonst entstehen Duplikate. +Delete old Mobilizon drafts or events first, otherwise duplicates may be created. + +Notes / Hinweise + +DE: +Bestehende Mobilizon-Events werden nicht aktualisiert. +Bestehende Entwürfe werden nicht automatisch veröffentlicht. +Wenn DRAFT von True auf False geändert wird, werden bereits importierte Events trotzdem übersprungen. +Der Access-Token kann ablaufen und muss dann erneuert werden. + +EN: +Existing Mobilizon events are not updated. +Existing drafts are not automatically published. +If DRAFT is changed from True to False, already imported events are still skipped. +The access token may expire and then has to be renewed. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b80e883 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +certifi==2026.4.22 +charset-normalizer==3.4.7 +icalendar==7.1.0 +idna==3.13 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +requests==2.33.1 +six==1.17.0 +tzdata==2026.2 +urllib3==2.6.3 diff --git a/setup-mobilizon.py b/setup-mobilizon.py new file mode 100755 index 0000000..605532d --- /dev/null +++ b/setup-mobilizon.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 + +import json +import time +from pathlib import Path + +import requests + + +INSTANCE = "https://rheinneckar.events" + +# Wichtig: +# "read" wird benötigt, damit loggedUser.memberships gelesen werden darf. +# Die write:event:* Scopes werden später für das Erstellen/Aktualisieren/Löschen von Events genutzt. +SCOPES = "read write:event:create write:event:update write:event:delete" + +APP_FILE = Path("mobilizon-app.json") +TOKEN_FILE = Path("mobilizon-token.json") +ENV_FILE = Path(".env") + + +def post_form(url, data): + response = requests.post(url, data=data, timeout=30) + print(f"POST {url} -> HTTP {response.status_code}") + + try: + payload = response.json() + except Exception: + print("Keine JSON-Antwort:") + print(response.text) + raise SystemExit(1) + + if response.status_code >= 400: + print("Fehlerantwort:") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + raise SystemExit(1) + + return payload + + +def graphql(access_token, query, variables=None): + response = requests.post( + f"{INSTANCE}/api", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + json={ + "query": query, + "variables": variables or {}, + }, + timeout=30, + ) + + print(f"GraphQL -> HTTP {response.status_code}") + + try: + payload = response.json() + except Exception: + print("Keine JSON-Antwort:") + print(response.text) + raise SystemExit(1) + + if response.status_code >= 400 or payload.get("errors"): + print("GraphQL-Fehler:") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + raise SystemExit(1) + + return payload["data"] + + +def register_app(): + print("1. App wird registriert...") + + app = post_form( + f"{INSTANCE}/apps", + { + "name": "OSMCal Importer", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "website": INSTANCE, + "scope": SCOPES, + }, + ) + + APP_FILE.write_text( + json.dumps(app, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + return app + + +def request_device_code(client_id): + print() + print("2. Login-Code wird angefordert...") + + device = post_form( + f"{INSTANCE}/login/device/code", + { + "client_id": client_id, + "scope": SCOPES, + }, + ) + + print() + print("Öffne diese Adresse im Browser:") + print(device["verification_uri"]) + print() + print("Gib dort diesen Code ein:") + print(device["user_code"]) + print() + print("Danach hier warten. Das Script fragt automatisch ab, ob du bestätigt hast.") + print() + + return device + + +def wait_for_token(client_id, device): + interval = int(device.get("interval", 5)) + expires_in = int(device.get("expires_in", 900)) + deadline = time.time() + expires_in + + while time.time() < deadline: + time.sleep(interval) + + response = requests.post( + f"{INSTANCE}/oauth/token", + data={ + "client_id": client_id, + "device_code": device["device_code"], + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }, + timeout=30, + ) + + try: + payload = response.json() + except Exception: + print("Keine JSON-Antwort beim Token-Abruf:") + print(response.text) + raise SystemExit(1) + + if "access_token" in payload: + print("Token erhalten.") + + TOKEN_FILE.write_text( + json.dumps(payload, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + return payload + + error = payload.get("error") + + if error == "authorization_pending": + print("Noch nicht bestätigt...") + continue + + if error == "slow_down": + print("Server bittet um langsameres Abfragen...") + interval += 5 + continue + + print("Token-Abruf fehlgeschlagen:") + print(json.dumps(payload, indent=2, ensure_ascii=False)) + raise SystemExit(1) + + print("Der Login-Code ist abgelaufen. Script neu starten.") + raise SystemExit(1) + + +def get_memberships(access_token): + print() + print("3. Gruppenmitgliedschaften werden abgefragt...") + + query = """ + query LoggedUserMemberships($page: Int, $limit: Int) { + loggedUser { + id + memberships(page: $page, limit: $limit) { + total + elements { + role + actor { + id + type + preferredUsername + name + } + parent { + id + type + preferredUsername + name + } + } + } + } + } + """ + + data = graphql( + access_token, + query, + variables={ + "page": 1, + "limit": 100, + }, + ) + + memberships_data = data.get("loggedUser", {}).get("memberships") + + if not memberships_data: + print() + print("Keine Gruppenmitgliedschaften gefunden.") + print("Mögliche Ursachen:") + print("- Du bist mit diesem Account in keiner Gruppe Mitglied.") + print("- Die App wurde nicht mit dem Scope 'read' autorisiert.") + print("- Der verwendete Account ist nicht der erwartete Mobilizon-Account.") + raise SystemExit(1) + + memberships = memberships_data.get("elements", []) + + if not memberships: + print() + print("Du bist mit diesem Account in keiner Gruppe Mitglied.") + raise SystemExit(1) + + return memberships + + +def choose_membership(memberships): + print() + print("Deine Gruppen:") + print() + + for index, membership in enumerate(memberships, start=1): + profile = membership["actor"] + group = membership["parent"] + role = membership["role"] + + profile_name = profile.get("name") or profile.get("preferredUsername") or "Profil" + group_name = group.get("name") or group.get("preferredUsername") or "Gruppe" + + print( + f"{index}. Gruppe: {group_name} " + f"(group_id={group['id']}) | " + f"Profil: {profile_name} " + f"(profile_id={profile['id']}) | " + f"Rolle: {role}" + ) + + print() + choice = input("Welche Gruppe soll für den Import verwendet werden? Nummer eingeben: ").strip() + + try: + selected = memberships[int(choice) - 1] + except Exception: + print("Ungültige Auswahl.") + raise SystemExit(1) + + role = selected["role"] + + if role not in ("MEMBER", "MODERATOR", "ADMINISTRATOR"): + print() + print(f"Warnung: Deine Rolle ist {role}.") + print("Falls der Import später fehlschlägt, darf dieses Profil vermutlich keine Gruppen-Events veröffentlichen.") + + return selected + + +def write_env(app, token, membership): + organizer_actor_id = membership["actor"]["id"] + attributed_to_id = membership["parent"]["id"] + + lines = [ + f'MOBILIZON_INSTANCE="{INSTANCE}"', + f'MOBILIZON_CLIENT_ID="{app["client_id"]}"', + f'MOBILIZON_CLIENT_SECRET="{app.get("client_secret", "")}"', + f'MOBILIZON_TOKEN="{token["access_token"]}"', + f'MOBILIZON_REFRESH_TOKEN="{token.get("refresh_token", "")}"', + f'MOBILIZON_ORGANIZER_ACTOR_ID="{organizer_actor_id}"', + f'MOBILIZON_ATTRIBUTED_TO_ID="{attributed_to_id}"', + ] + + ENV_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8") + ENV_FILE.chmod(0o600) + + print() + print("Fertig. .env wurde geschrieben.") + print() + print("Gesetzte Werte:") + print(f"MOBILIZON_ORGANIZER_ACTOR_ID={organizer_actor_id}") + print(f"MOBILIZON_ATTRIBUTED_TO_ID={attributed_to_id}") + print() + print("Hinweis: Der Access-Token läuft nach einigen Stunden ab.") + print("Für erste Tests reicht das. Für Cron-Betrieb sollte später Refresh-Token-Unterstützung ergänzt werden.") + + +def main(): + app = register_app() + device = request_device_code(app["client_id"]) + token = wait_for_token(app["client_id"], device) + memberships = get_memberships(token["access_token"]) + selected = choose_membership(memberships) + write_env(app, token, selected) + + +if __name__ == "__main__": + main()