Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions events.schema.json
Original file line number Diff line number Diff line change
@@ -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": "^(.*)$"
}
}
}
}
}
}
}
140 changes: 113 additions & 27 deletions mastodon-reminder.py
Original file line number Diff line number Diff line change
@@ -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 <date>_<venue-normalized>_<hash>.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()
51 changes: 50 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -27,11 +29,58 @@ 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
* MASTODON_API_BASE_URL
* MASTODON_CLIENT_ID
* MASTODON_CLIENT_SECRET
* MASTODON_ACCESS_TOKEN
* if you don't know where to get the constants values, see "Mastodon-APP-Setup"
* 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 <event_file1.json> [<event_file2.json> ...] --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 <event_file1.json> [<event_file2.json> ...]
```
- 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 <event_file1.json> [<event_file2.json> ...] --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`.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
python-dotenv
Mastodon.py
typer