diff --git a/events.schema.json b/events.schema.json new file mode 100644 index 0000000..be86eff --- /dev/null +++ b/events.schema.json @@ -0,0 +1,130 @@ +{ + "definitions": {}, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://example.com/root.json", + "type": "array", + "title": "The Root Schema", + "items": { + "$id": "#/items", + "type": "object", + "title": "The Items Schema", + "required": [ + "date", + "venue", + "address" + ], + "properties": { + "date": { + "$id": "#/items/properties/date", + "type": "string", + "title": "The Date Schema", + "default": "", + "examples": [ + "2018-12-19" + ], + "pattern": "^(.*)$" + }, + "title": { + "$id": "#/items/properties/title", + "type": "string", + "title": "The Title Schema", + "default": "", + "examples": [ + "11. Hackergarten Stuttgart (Winter Edition)" + ], + "pattern": "^(.*)$" + }, + "venue": { + "$id": "#/items/properties/venue", + "type": "string", + "title": "The Venue Schema", + "default": "", + "examples": [ + "codecentric AG, Stuttgart" + ], + "pattern": "^(.*)$" + }, + "address": { + "$id": "#/items/properties/address", + "type": "string", + "title": "The Address Schema", + "default": "", + "examples": [ + "Heßbrühlstr 7, Stuttgart" + ], + "pattern": "^(.*)$" + }, + "links": { + "$id": "#/items/properties/links", + "type": "array", + "title": "The Links Schema", + "items": { + "$id": "#/items/properties/links/items", + "type": "object", + "title": "The Items Schema", + "required": [ + "title", + "url" + ], + "properties": { + "title": { + "$id": "#/items/properties/links/items/properties/title", + "type": "string", + "title": "The Title Schema", + "default": "", + "examples": [ + "Meetup" + ], + "pattern": "^(.*)$" + }, + "url": { + "$id": "#/items/properties/links/items/properties/url", + "type": "string", + "title": "The Url Schema", + "default": "", + "examples": [ + "https://www.meetup.com/de-DE/Hackergarten-Stuttgart/events/256184268/" + ], + "pattern": "^(.*)$" + } + } + } + }, + "achievements": { + "$id": "#/items/properties/achievements", + "type": "array", + "title": "The Achievements Schema", + "items": { + "$id": "#/items/properties/links/items", + "type": "object", + "title": "The Items Schema", + "required": [ + "title" + ], + "properties": { + "title": { + "$id": "#/items/properties/links/items/properties/title", + "type": "string", + "title": "The Title Schema", + "default": "", + "examples": [ + "Meetup" + ], + "pattern": "^(.*)$" + }, + "url": { + "$id": "#/items/properties/links/items/properties/url", + "type": "string", + "title": "The Url Schema", + "default": "", + "examples": [ + "https://www.meetup.com/de-DE/Hackergarten-Stuttgart/events/256184268/" + ], + "pattern": "^(.*)$" + } + } + } + } + } + } +} diff --git a/mastodon-reminder.py b/mastodon-reminder.py index bb36423..02c0442 100644 --- a/mastodon-reminder.py +++ b/mastodon-reminder.py @@ -1,40 +1,126 @@ -import datetime -import json import os - +import sys +import json +import datetime +import logging +import typer from dotenv import load_dotenv from mastodon import Mastodon +from dataclasses import dataclass +import re +import hashlib -load_dotenv() +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s') -mastodon = Mastodon( - api_base_url=os.getenv("MASTODON_API_BASE_URL"), - client_id=os.getenv("MASTODON_CLIENT_ID"), - client_secret=os.getenv("MASTODON_CLIENT_SECRET"), - access_token=os.getenv("MASTODON_ACCESS_TOKEN"), -) +app = typer.Typer() +@dataclass +class Event: # add fields as needed + date: datetime.date + venue: str + link: str -def send_reminder(date, location, link): - mastodon.toot( - "📢 Reminder: Next Hackergarten is in one week! 📢\n" - f"Join us on the {date} at 18:00 at the {location}.\n\n" - f"More info: {link}" +@app.command() +def remind(event_files: list[str] = typer.Argument(..., help="List of event JSON files (use '-' to read from stdin)"), days_before: int = typer.Option(7, help="Send reminder N days before event")): + """ + Send reminders exactly one week before the event date. + If event_files is ['-'], events are read as a single JSON list from stdin. + """ + mastodon = get_mastodon_client() + today = datetime.datetime.now().date() + for event in get_events_from_files(event_files): + if event.date == today + datetime.timedelta(days=days_before): + send_reminder(mastodon, event.date.strftime("%Y-%m-%d"), event.venue, event.link, days_before) + +@app.command() +def announce(event_files: list[str] = typer.Argument(..., help="List of event JSON files (use '-' to read from stdin)")): + """ + Announce upcoming events now. + If event_files is ['-'], events are read as a single JSON list from stdin. + """ + mastodon = get_mastodon_client() + today = datetime.datetime.now().date() + for event in get_events_from_files(event_files): + if event.date >= today: + send_new_event_announcement(mastodon, event.date.strftime("%Y-%m-%d"), event.venue, event.link) + +@app.command() +def split_events( + event_files: list[str] = typer.Argument(..., help="List of event JSON files (use '-' to read from stdin)"), + output_folder: str = typer.Option(..., "--output-folder", "-o", help="Output folder for individual event files") +): + """ + Split raw events from input files into individual JSON files in the output folder. + Each file is named as __.json. + If event_files is ['-'], events are read as a single JSON list from stdin. + """ + + os.makedirs(output_folder, exist_ok=True) + for event in get_raw_events_from_files(event_files): + date = event.get("date", "unknown") + venue = event.get("venue", "unknown") + # Normalize venue: lowercase, replace spaces with '-', remove non-alphanu + normalized_venue = re.sub(r'[^a-z0-9]', '', venue.lower())[:15] + # Simple hash: md5 of the event JSON string, first 6 chars + event_str = json.dumps(event, sort_keys=True) + event_hash = hashlib.md5(event_str.encode("utf-8")).hexdigest()[:6] + filename = f"{date}_{event_hash}_{normalized_venue}.json" + filepath = os.path.join(output_folder, filename) + with open(filepath, "w", encoding="utf-8") as f: + json.dump([event], f, ensure_ascii=False, indent=2) + logging.info(f"Wrote event to {filepath}") + +def get_mastodon_client(): + mapping = ( + ("api_base_url", "MASTODON_API_BASE_URL"), + ("client_id", "MASTODON_CLIENT_ID"), + ("client_secret", "MASTODON_CLIENT_SECRET"), + ("access_token", "MASTODON_ACCESS_TOKEN") ) + load_dotenv() + params = {key: os.getenv(value) for key, value in mapping} + missing = [value for key, value in mapping if not params.get(key)] + if missing: + raise ValueError(f"Missing required environment variables: {', '.join(missing)}") + return Mastodon(**params) +def get_raw_events_from_files(file_paths: list[str]): + if len(file_paths) == 1 and file_paths[0] == "-": + # Read a single list of events from stdin + data = json.load(sys.stdin) + if not isinstance(data, list): + raise ValueError("Input from stdin must be a JSON list of events.") + yield from data + else: + for path in file_paths: + with open(path) as json_file: + data = json.load(json_file) + yield from data -with open("./events.json") as json_file: - data = json.load(json_file) +def get_events_from_files(file_paths: list[str]): + for raw_event in get_raw_events_from_files(file_paths): + date = datetime.datetime.strptime(raw_event["date"], "%Y-%m-%d").date() + venue = raw_event["venue"] + link = raw_event["link"] + yield Event(date=date, venue=venue, link=link) - for event in data: - # Convert date to datetime object - event_date = datetime.datetime.strptime(event["date"], "%Y-%m-%d").date() - today = datetime.datetime.now().date() +def send_reminder(mastodon, date, location, link, days_before): + message = ( + f"\ud83d\udce2 Reminder: Next Hackergarten is in {days_before} days! \ud83d\udce2\n" + f"Join us on the {date} at 18:00 at the {location}.\n\n" + f"More info: {link}" + ) + mastodon.toot(message) + logging.info(f"Sent reminder for {date} at {location}: {link}") - # Check if event is one week away - if (event_date - today).days == 7: - send_reminder(event["date"], event["venue"], event["links"][0]["url"]) +def send_new_event_announcement(mastodon, date, location, link): + message = ( + "\U0001F389 New Hackergarten Event Announced! \U0001F389\n" + f"Join us on the {date} at 18:00 at the {location}.\n\n" + f"More info: {link}" + ) + mastodon.toot(message) + logging.info(f"Announced new event for {date} at {location}: {link}") - # If events are in the past break for loop - if event_date < today: - break +if __name__ == "__main__": + app() diff --git a/readme.md b/readme.md index 310d49e..c7516b7 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,7 @@ # Mastodon + ## Setup + ### Mastodon-App-Setup Setup the App in Mastodon as follows: * Create a Mastodon Account (if you don’t have one) @@ -27,6 +29,7 @@ Setup the App in Mastodon as follows: * After creating the application, you’ll see the Client Key and Client Secret. * Scroll down to the section labeled Your access token. * Copy the access token displayed in this section. This is the token you'll use to authenticate your app or bot with Mastodon. + ### AP-Setup * create .env file * define the following constants in the .env file @@ -34,4 +37,50 @@ Setup the App in Mastodon as follows: * MASTODON_CLIENT_ID * MASTODON_CLIENT_SECRET * MASTODON_ACCESS_TOKEN -* if you don't know where to get the constants values, see "Mastodon-APP-Setup" \ No newline at end of file +* if you don't know where to get the constants values, see "Mastodon-APP-Setup" + +## Command Line Usage + +The bot provides several subcommands via the CLI: + +### Remind +Send reminders for events N days before the event date. + +``` +python mastodon-reminder.py remind [ ...] --days-before 7 +``` +- Use `--days-before N` to set how many days before the event to send the reminder (default: 7). +- You can pass `-` as the event file to read a single JSON list of events from stdin: + +``` +cat events.json | python mastodon-reminder.py remind - --days-before 3 +``` + +### Announce +Announce the next upcoming event(s). + +``` +python mastodon-reminder.py announce [ ...] +``` +- You can pass `-` as the event file to read from stdin: + +``` +cat events.json | python mastodon-reminder.py announce - +``` + +### Split Events +Split all events from input files into individual Git-friendly JSON files in the output folder. Use those files to send individual messages for single events. + +``` +python mastodon-reminder.py split-events [ ...] --output-folder events/ +``` +- Each event will be written as a separate file in the output folder. +- You can pass `-` as the event file to read from stdin: + +``` +cat events.json | python mastodon-reminder.py split-events - --output-folder events/ +``` + +#### Notes +- All commands accept multiple event files, or a single `-` to read a JSON list of events from stdin. +- Input files must follow the structure defined in `events.schema.json`. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1fd35db..d460165 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ python-dotenv Mastodon.py +typer