feat(google-calendar): add RRULE recurrence, transparency, reminders, move_event, and free-slots finder
This commit is contained in:
@@ -33,30 +33,45 @@ OpenClaw assigns Google Calendar colors automatically based on event titles or e
|
||||
|
||||
## 🛠️ MCP Tools
|
||||
|
||||
You have access to 6 dedicated calendar tools:
|
||||
You have access to 8 dedicated calendar tools:
|
||||
|
||||
1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, max_results=25)`:
|
||||
- Lists appointments within a timeframe including start/end, color name, location, and description.
|
||||
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead.
|
||||
1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`:
|
||||
- Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description.
|
||||
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences.
|
||||
- `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`).
|
||||
- `query`: Text filter to search summaries and descriptions.
|
||||
|
||||
2. `calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`:
|
||||
- Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer.
|
||||
- Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`.
|
||||
|
||||
3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, category=None, color_id=None, description=None, location=None, all_day=False, check_conflicts=True)`:
|
||||
- Adds a new appointment with automatic color coding and overlap detection.
|
||||
- If conflicts or tight buffers are detected, a warning is returned in `conflict_warning`.
|
||||
- For timed events: pass ISO datetime (e.g. `2026-09-20T14:30:00`). If `end_time` is omitted, defaults to 1 hour after start.
|
||||
- For all-day events: set `all_day=True` and provide date `YYYY-MM-DD`.
|
||||
3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`:
|
||||
- Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection.
|
||||
- **`recurrence`**: Standard RFC 5545 rule or shortcuts:
|
||||
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"` (weekly Saturday market)
|
||||
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z"` (daily except Sun until year end)
|
||||
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
|
||||
- **`show_as_busy` / `transparency`**:
|
||||
- For **Regensburg city events, markets, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings!
|
||||
- **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before).
|
||||
- **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors.
|
||||
|
||||
4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, category=None, color_id=None, description=None, location=None)`:
|
||||
- Updates fields or recolors an existing event.
|
||||
4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`:
|
||||
- Updates fields, recurrence, transparency, reminders, or recolors an existing event.
|
||||
- `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence.
|
||||
|
||||
5. `calendar_delete_event(calendar="family"|"regensburg", event_id)`:
|
||||
5. `calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`:
|
||||
- Deletes an event.
|
||||
- `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence.
|
||||
|
||||
6. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
||||
6. `calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
|
||||
- Moves an event atomically from one calendar to another (e.g. turning an interesting city event from Regensburg into a fixed family plan, or correcting a wrongly booked calendar).
|
||||
|
||||
7. `calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`:
|
||||
- Queries `freebusy` to find open, available time windows across calendars.
|
||||
- Perfect for answering questions like: *"Wann haben wir diese Woche am Nachmittag 2 Stunden frei für einen Ausflug?"*.
|
||||
|
||||
8. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
||||
- Retrieves full event metadata.
|
||||
|
||||
---
|
||||
@@ -68,12 +83,21 @@ You have access to 6 dedicated calendar tools:
|
||||
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user:
|
||||
> *„⚠️ Achtung: Am Freitag um 17:30 Uhr hat Moritz schon 'Moritz Gartenparty Arbeit' (17:30 - 18:30). Dein Termin 'Spielplatz' würde sich damit überschneiden. Soll ich ihn trotzdem eintragen?“*
|
||||
|
||||
### 2. Smart Location & Google Maps Integration
|
||||
### 2. Informational Events vs. Family Commitments (Transparency)
|
||||
- When adding city events, festivals, or weekly markets to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`).
|
||||
- This keeps the event clearly visible on the calendar while allowing family appointments to be scheduled in parallel without false conflict alerts.
|
||||
|
||||
### 3. Recurring Series (RRULE)
|
||||
- When the user asks to add recurring appointments (e.g. weekly markets, sports training, trash collection):
|
||||
- Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts.
|
||||
- Do NOT create manual cron scripts or multiple single events when a native Google Calendar recurrence series can be used.
|
||||
|
||||
### 4. Smart Location & Google Maps Integration
|
||||
- If a place or venue is mentioned (e.g. *"Dr. Müller in Prüfening"*, *"Westbad"*):
|
||||
- Resolve the full address or ask the user, and store it in `location`.
|
||||
- This activates Google Maps departure alerts on their smartphone (*„Aufbruch in 15 Min. wegen Verkehr“*).
|
||||
|
||||
### 3. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
|
||||
### 5. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
|
||||
- When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*):
|
||||
- Auto-generate or suggest a helpful checklist in `description`:
|
||||
```text
|
||||
@@ -82,8 +106,8 @@ You have access to 6 dedicated calendar tools:
|
||||
- Versichertenkarte
|
||||
- 2x Wickelzeug & Schnuller
|
||||
```
|
||||
- Ask proactively in Telegram:
|
||||
> *„Termin eingetragen! 🏥 Soll ich noch eine Notiz oder Packliste (U-Heft, Impfpass, Wechselsachen) im Termin hinterlegen?“*
|
||||
- Suggest a reminder (`reminder_minutes=[60, 1440]`):
|
||||
> *„Termin eingetragen! 🏥 Ich habe eine Erinnerung 1 Stunde vorher eingerichtet. Soll ich noch eine Packliste im Termin hinterlegen?“*
|
||||
|
||||
### 4. Timezone & DateTime Precision
|
||||
### 6. Timezone & DateTime Precision
|
||||
- All dates and times operate in **Europe/Berlin** (`CET`/`CEST`).
|
||||
|
||||
@@ -14,7 +14,7 @@ import sys
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from typing import Dict, Any, List, Optional, Tuple, Union
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# Environment Configuration
|
||||
@@ -205,6 +205,88 @@ def parse_datetime_str(dt_str: str) -> datetime:
|
||||
return dt
|
||||
|
||||
|
||||
def normalize_recurrence(val: Any) -> Optional[List[str]]:
|
||||
"""
|
||||
Normalize recurrence input into a list of valid RFC 5545 RRULE/EXRULE strings
|
||||
accepted by Google Calendar API.
|
||||
"""
|
||||
if val is None or isinstance(val, bool):
|
||||
return None
|
||||
|
||||
raw_rules: List[str] = []
|
||||
if isinstance(val, str):
|
||||
val_str = val.strip()
|
||||
if not val_str:
|
||||
return None
|
||||
lower = val_str.lower()
|
||||
if lower in ("weekly", "wöchentlich"):
|
||||
return ["RRULE:FREQ=WEEKLY"]
|
||||
elif lower in ("daily", "täglich"):
|
||||
return ["RRULE:FREQ=DAILY"]
|
||||
elif lower in ("monthly", "monatlich"):
|
||||
return ["RRULE:FREQ=MONTHLY"]
|
||||
elif lower in ("yearly", "jährlich", "annual"):
|
||||
return ["RRULE:FREQ=YEARLY"]
|
||||
elif lower in ("workdays", "werktags", "werktäglich"):
|
||||
return ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"]
|
||||
elif lower in ("weekend", "wochenende"):
|
||||
return ["RRULE:FREQ=WEEKLY;BYDAY=SA,SU"]
|
||||
|
||||
raw_rules = [r.strip() for r in val_str.split("\n") if r.strip()]
|
||||
elif isinstance(val, (list, tuple)):
|
||||
raw_rules = [str(r).strip() for r in val if str(r).strip()]
|
||||
else:
|
||||
return None
|
||||
|
||||
if not raw_rules:
|
||||
return None
|
||||
|
||||
cleaned_rules = []
|
||||
for rule in raw_rules:
|
||||
rule_upper = rule.upper()
|
||||
if any(rule_upper.startswith(p) for p in ("RRULE:", "EXRULE:", "RDATE:", "EXDATE:")):
|
||||
cleaned_rules.append(rule)
|
||||
elif any(rule_upper.startswith(p) for p in ("FREQ=", "UNTIL=", "COUNT=", "INTERVAL=", "BYDAY=", "WKST=")):
|
||||
cleaned_rules.append(f"RRULE:{rule}")
|
||||
else:
|
||||
cleaned_rules.append(f"RRULE:{rule}")
|
||||
|
||||
return cleaned_rules if cleaned_rules else None
|
||||
|
||||
|
||||
def normalize_reminders(val: Any) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Normalize reminder minutes into Google Calendar API reminders resource:
|
||||
{"useDefault": False, "overrides": [{"method": "popup", "minutes": X}, ...]}
|
||||
"""
|
||||
if val is None:
|
||||
return None
|
||||
|
||||
minutes_list: List[int] = []
|
||||
if isinstance(val, int):
|
||||
minutes_list = [val]
|
||||
elif isinstance(val, (list, tuple)):
|
||||
for item in val:
|
||||
try:
|
||||
minutes_list.append(int(item))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
elif isinstance(val, str):
|
||||
val_str = val.strip()
|
||||
if val_str:
|
||||
for part in val_str.split(","):
|
||||
part = part.strip()
|
||||
try:
|
||||
minutes_list.append(int(part))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
return {
|
||||
"useDefault": False,
|
||||
"overrides": [{"method": "popup", "minutes": m} for m in minutes_list]
|
||||
}
|
||||
|
||||
|
||||
def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
|
||||
"""Format an event item into a clean representation including color labels."""
|
||||
start = item.get("start", {})
|
||||
@@ -217,6 +299,17 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
|
||||
color_id = item.get("colorId")
|
||||
color_label = COLOR_NAMES.get(color_id) if color_id else None
|
||||
|
||||
recurrence = item.get("recurrence")
|
||||
recurring_event_id = item.get("recurringEventId")
|
||||
is_recurring = bool(recurrence or recurring_event_id)
|
||||
|
||||
transparency = item.get("transparency", "opaque")
|
||||
show_as_busy = (transparency != "transparent")
|
||||
|
||||
rem = item.get("reminders", {})
|
||||
use_default_rem = rem.get("useDefault", True)
|
||||
reminder_minutes = [ov.get("minutes") for ov in rem.get("overrides", []) if "minutes" in ov] if not use_default_rem else None
|
||||
|
||||
return {
|
||||
"id": item.get("id"),
|
||||
"calendar": calendar_name,
|
||||
@@ -224,6 +317,15 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
|
||||
"start": start_str,
|
||||
"end": end_str,
|
||||
"all_day": is_all_day,
|
||||
"is_recurring": is_recurring,
|
||||
"recurrence": recurrence,
|
||||
"recurring_event_id": recurring_event_id,
|
||||
"show_as_busy": show_as_busy,
|
||||
"transparency": transparency,
|
||||
"reminders": {
|
||||
"use_default": use_default_rem,
|
||||
"reminder_minutes": reminder_minutes,
|
||||
},
|
||||
"color_id": color_id,
|
||||
"color_name": color_label,
|
||||
"description": item.get("description", ""),
|
||||
@@ -336,9 +438,11 @@ def list_events(
|
||||
time_min: Optional[str] = None,
|
||||
time_max: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
color_id: Optional[str] = None,
|
||||
max_results: int = 25,
|
||||
) -> Dict[str, Any]:
|
||||
"""List events from the specified calendar."""
|
||||
"""List events from the specified calendar with optional query, category, and color filters."""
|
||||
service = get_calendar_service()
|
||||
cal_id = resolve_calendar_id(calendar)
|
||||
|
||||
@@ -355,7 +459,7 @@ def list_events(
|
||||
else:
|
||||
time_max_str = parse_datetime_str(time_max).isoformat()
|
||||
|
||||
params = {
|
||||
params: Dict[str, Any] = {
|
||||
"calendarId": cal_id,
|
||||
"timeMin": time_min_str,
|
||||
"timeMax": time_max_str,
|
||||
@@ -369,12 +473,19 @@ def list_events(
|
||||
res = service.events().list(**params).execute()
|
||||
items = res.get("items", [])
|
||||
|
||||
# Filter by category / colorId if requested
|
||||
target_color_id = color_id or (resolve_color_id("", explicit_category_or_color=category) if category else None)
|
||||
if target_color_id:
|
||||
items = [it for it in items if it.get("colorId") == target_color_id]
|
||||
|
||||
events = [format_event(item, calendar) for item in items]
|
||||
return {
|
||||
"calendar": calendar,
|
||||
"calendar_id": cal_id,
|
||||
"time_min": time_min_str,
|
||||
"time_max": time_max_str,
|
||||
"filter_category": category,
|
||||
"filter_color_id": target_color_id,
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
}
|
||||
@@ -390,12 +501,17 @@ def create_event(
|
||||
all_day: bool = False,
|
||||
category: Optional[str] = None,
|
||||
color_id: Optional[str] = None,
|
||||
recurrence: Optional[Any] = None,
|
||||
show_as_busy: Optional[bool] = None,
|
||||
transparency: Optional[str] = None,
|
||||
reminder_minutes: Optional[Any] = None,
|
||||
inclusive_end: bool = True,
|
||||
check_conflicts_flag: bool = True,
|
||||
buffer_minutes: int = 15,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new event in the specified calendar with automatic color-coding
|
||||
and conflict detection.
|
||||
Create a new event in the specified calendar with automatic color-coding,
|
||||
conflict detection, recurrence (RRULE), transparency, and custom reminders.
|
||||
"""
|
||||
if not summary:
|
||||
raise ValueError("Parameter 'summary' is required.")
|
||||
@@ -429,12 +545,37 @@ def create_event(
|
||||
if chosen_color_id:
|
||||
body["colorId"] = chosen_color_id
|
||||
|
||||
# Recurrence rules (RFC 5545 RRULE)
|
||||
norm_rec = normalize_recurrence(recurrence)
|
||||
if norm_rec:
|
||||
body["recurrence"] = norm_rec
|
||||
if conflict_info and (conflict_info["has_conflict"] or conflict_info["has_buffer_warning"]):
|
||||
conflict_info["recurring_note"] = "Conflict check was performed on the initial occurrence only."
|
||||
|
||||
# Event Transparency (Free vs. Busy)
|
||||
if transparency:
|
||||
body["transparency"] = transparency.lower().strip()
|
||||
elif show_as_busy is False:
|
||||
body["transparency"] = "transparent"
|
||||
elif show_as_busy is True:
|
||||
body["transparency"] = "opaque"
|
||||
|
||||
# Custom Reminders
|
||||
norm_rem = normalize_reminders(reminder_minutes)
|
||||
if norm_rem is not None:
|
||||
body["reminders"] = norm_rem
|
||||
|
||||
if all_day:
|
||||
start_date = start_time.split("T")[0]
|
||||
dt_start = date.fromisoformat(start_date)
|
||||
if end_time:
|
||||
end_date = end_time.split("T")[0]
|
||||
end_date_raw = end_time.split("T")[0]
|
||||
dt_end_raw = date.fromisoformat(end_date_raw)
|
||||
if inclusive_end and dt_end_raw >= dt_start:
|
||||
end_date = (dt_end_raw + timedelta(days=1)).isoformat()
|
||||
else:
|
||||
end_date = dt_end_raw.isoformat()
|
||||
else:
|
||||
dt_start = date.fromisoformat(start_date)
|
||||
end_date = (dt_start + timedelta(days=1)).isoformat()
|
||||
|
||||
body["start"] = {"date": start_date}
|
||||
@@ -478,15 +619,30 @@ def update_event(
|
||||
location: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
color_id: Optional[str] = None,
|
||||
recurrence: Optional[Any] = None,
|
||||
show_as_busy: Optional[bool] = None,
|
||||
transparency: Optional[str] = None,
|
||||
reminder_minutes: Optional[Any] = None,
|
||||
inclusive_end: bool = True,
|
||||
update_series: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing event."""
|
||||
"""Update an existing event or series."""
|
||||
if not event_id:
|
||||
raise ValueError("Parameter 'event_id' is required.")
|
||||
|
||||
service = get_calendar_service()
|
||||
cal_id = resolve_calendar_id(calendar)
|
||||
|
||||
existing = service.events().get(calendarId=cal_id, eventId=event_id).execute()
|
||||
target_id = event_id
|
||||
if update_series:
|
||||
try:
|
||||
item_check = service.events().get(calendarId=cal_id, eventId=event_id).execute()
|
||||
if item_check.get("recurringEventId"):
|
||||
target_id = item_check["recurringEventId"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
existing = service.events().get(calendarId=cal_id, eventId=target_id).execute()
|
||||
|
||||
if summary is not None:
|
||||
existing["summary"] = summary
|
||||
@@ -499,11 +655,36 @@ def update_event(
|
||||
if new_color:
|
||||
existing["colorId"] = new_color
|
||||
|
||||
if recurrence is not None:
|
||||
if recurrence == [] or recurrence == "" or recurrence is False:
|
||||
existing.pop("recurrence", None)
|
||||
else:
|
||||
norm_rec = normalize_recurrence(recurrence)
|
||||
if norm_rec:
|
||||
existing["recurrence"] = norm_rec
|
||||
|
||||
if transparency is not None:
|
||||
existing["transparency"] = transparency.lower().strip()
|
||||
elif show_as_busy is not None:
|
||||
existing["transparency"] = "opaque" if show_as_busy else "transparent"
|
||||
|
||||
if reminder_minutes is not None:
|
||||
norm_rem = normalize_reminders(reminder_minutes)
|
||||
if norm_rem is not None:
|
||||
existing["reminders"] = norm_rem
|
||||
|
||||
if start_time is not None:
|
||||
if "date" in existing.get("start", {}):
|
||||
existing["start"]["date"] = start_time.split("T")[0]
|
||||
start_date_str = start_time.split("T")[0]
|
||||
existing["start"]["date"] = start_date_str
|
||||
dt_start = date.fromisoformat(start_date_str)
|
||||
if end_time:
|
||||
existing["end"]["date"] = end_time.split("T")[0]
|
||||
end_date_raw = end_time.split("T")[0]
|
||||
dt_end_raw = date.fromisoformat(end_date_raw)
|
||||
if inclusive_end and dt_end_raw >= dt_start:
|
||||
existing["end"]["date"] = (dt_end_raw + timedelta(days=1)).isoformat()
|
||||
else:
|
||||
existing["end"]["date"] = end_date_raw
|
||||
else:
|
||||
dt_start = parse_datetime_str(start_time)
|
||||
existing["start"] = {
|
||||
@@ -517,26 +698,187 @@ def update_event(
|
||||
"timeZone": DEFAULT_TIMEZONE_STR,
|
||||
}
|
||||
|
||||
updated = service.events().update(calendarId=cal_id, eventId=event_id, body=existing).execute()
|
||||
updated = service.events().update(calendarId=cal_id, eventId=target_id, body=existing).execute()
|
||||
return {
|
||||
"status": "updated",
|
||||
"event": format_event(updated, calendar),
|
||||
"updated_series": (target_id != event_id or update_series),
|
||||
}
|
||||
|
||||
|
||||
def delete_event(calendar: str = "family", event_id: str = "") -> Dict[str, Any]:
|
||||
"""Delete an event from the specified calendar."""
|
||||
def delete_event(
|
||||
calendar: str = "family",
|
||||
event_id: str = "",
|
||||
delete_series: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete an event or recurring series from the specified calendar."""
|
||||
if not event_id:
|
||||
raise ValueError("Parameter 'event_id' is required.")
|
||||
|
||||
service = get_calendar_service()
|
||||
cal_id = resolve_calendar_id(calendar)
|
||||
|
||||
service.events().delete(calendarId=cal_id, eventId=event_id).execute()
|
||||
target_id = event_id
|
||||
if delete_series:
|
||||
try:
|
||||
item = service.events().get(calendarId=cal_id, eventId=event_id).execute()
|
||||
if item.get("recurringEventId"):
|
||||
target_id = item["recurringEventId"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
service.events().delete(calendarId=cal_id, eventId=target_id).execute()
|
||||
return {
|
||||
"status": "deleted",
|
||||
"calendar": calendar,
|
||||
"event_id": event_id,
|
||||
"event_id": target_id,
|
||||
"deleted_series": (target_id != event_id or delete_series),
|
||||
}
|
||||
|
||||
|
||||
def move_event(
|
||||
event_id: str,
|
||||
source_calendar: str = "family",
|
||||
destination_calendar: str = "regensburg",
|
||||
) -> Dict[str, Any]:
|
||||
"""Move an event atomically between calendars (e.g. from regensburg to family or vice versa)."""
|
||||
if not event_id:
|
||||
raise ValueError("Parameter 'event_id' is required.")
|
||||
|
||||
service = get_calendar_service()
|
||||
src_id = resolve_calendar_id(source_calendar)
|
||||
dst_id = resolve_calendar_id(destination_calendar)
|
||||
|
||||
moved = service.events().move(
|
||||
calendarId=src_id,
|
||||
eventId=event_id,
|
||||
destination=dst_id,
|
||||
).execute()
|
||||
|
||||
return {
|
||||
"status": "moved",
|
||||
"source_calendar": source_calendar,
|
||||
"destination_calendar": destination_calendar,
|
||||
"event": format_event(moved, destination_calendar),
|
||||
}
|
||||
|
||||
|
||||
def find_free_slots(
|
||||
calendar: str = "family",
|
||||
time_min: Optional[str] = None,
|
||||
time_max: Optional[str] = None,
|
||||
duration_minutes: int = 60,
|
||||
day_start_hour: int = 8,
|
||||
day_end_hour: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Find available free time windows between day_start_hour and day_end_hour
|
||||
across specified calendar(s) using freebusy.query.
|
||||
"""
|
||||
service = get_calendar_service()
|
||||
|
||||
now = datetime.now(DEFAULT_TZ)
|
||||
if not time_min:
|
||||
dt_start = now
|
||||
else:
|
||||
dt_start = parse_datetime_str(time_min)
|
||||
|
||||
if not time_max:
|
||||
dt_end = dt_start + timedelta(days=7)
|
||||
else:
|
||||
dt_end = parse_datetime_str(time_max)
|
||||
|
||||
# Determine which calendars to query
|
||||
cal_ids = []
|
||||
cal_lower = calendar.lower().strip()
|
||||
if cal_lower in ("both", "alle", "all"):
|
||||
if CALENDAR_ID_FAMILY:
|
||||
cal_ids.append(CALENDAR_ID_FAMILY)
|
||||
if CALENDAR_ID_REGENSBURG:
|
||||
cal_ids.append(CALENDAR_ID_REGENSBURG)
|
||||
else:
|
||||
cal_ids.append(resolve_calendar_id(calendar))
|
||||
|
||||
body = {
|
||||
"timeMin": dt_start.isoformat(),
|
||||
"timeMax": dt_end.isoformat(),
|
||||
"timeZone": DEFAULT_TIMEZONE_STR,
|
||||
"items": [{"id": cid} for cid in cal_ids],
|
||||
}
|
||||
|
||||
fb_res = service.freebusy().query(body=body).execute()
|
||||
cals_dict = fb_res.get("calendars", {})
|
||||
|
||||
busy_intervals = []
|
||||
for cid, cdata in cals_dict.items():
|
||||
for b in cdata.get("busy", []):
|
||||
b_start = parse_datetime_str(b["start"])
|
||||
b_end = parse_datetime_str(b["end"])
|
||||
busy_intervals.append((b_start, b_end))
|
||||
|
||||
busy_intervals.sort(key=lambda x: x[0])
|
||||
|
||||
merged_busy = []
|
||||
for b_start, b_end in busy_intervals:
|
||||
if not merged_busy:
|
||||
merged_busy.append([b_start, b_end])
|
||||
else:
|
||||
prev_start, prev_end = merged_busy[-1]
|
||||
if b_start <= prev_end:
|
||||
merged_busy[-1][1] = max(prev_end, b_end)
|
||||
else:
|
||||
merged_busy.append([b_start, b_end])
|
||||
|
||||
free_slots = []
|
||||
current_day = dt_start.date()
|
||||
end_day = dt_end.date()
|
||||
|
||||
while current_day <= end_day:
|
||||
window_start = datetime(current_day.year, current_day.month, current_day.day, day_start_hour, 0, 0, tzinfo=DEFAULT_TZ)
|
||||
window_end = datetime(current_day.year, current_day.month, current_day.day, day_end_hour, 0, 0, tzinfo=DEFAULT_TZ)
|
||||
|
||||
eff_start = max(window_start, dt_start)
|
||||
eff_end = min(window_end, dt_end)
|
||||
|
||||
if eff_start < eff_end:
|
||||
slot_pointer = eff_start
|
||||
for b_start, b_end in merged_busy:
|
||||
if b_end <= slot_pointer:
|
||||
continue
|
||||
if b_start >= eff_end:
|
||||
break
|
||||
|
||||
if b_start > slot_pointer:
|
||||
gap_minutes = (b_start - slot_pointer).total_seconds() / 60.0
|
||||
if gap_minutes >= duration_minutes:
|
||||
free_slots.append({
|
||||
"start": slot_pointer.isoformat(),
|
||||
"end": b_start.isoformat(),
|
||||
"duration_minutes": int(gap_minutes),
|
||||
"date": current_day.isoformat(),
|
||||
})
|
||||
slot_pointer = max(slot_pointer, b_end)
|
||||
|
||||
if slot_pointer < eff_end:
|
||||
gap_minutes = (eff_end - slot_pointer).total_seconds() / 60.0
|
||||
if gap_minutes >= duration_minutes:
|
||||
free_slots.append({
|
||||
"start": slot_pointer.isoformat(),
|
||||
"end": eff_end.isoformat(),
|
||||
"duration_minutes": int(gap_minutes),
|
||||
"date": current_day.isoformat(),
|
||||
})
|
||||
|
||||
current_day += timedelta(days=1)
|
||||
|
||||
return {
|
||||
"calendar": calendar,
|
||||
"search_min": dt_start.isoformat(),
|
||||
"search_max": dt_end.isoformat(),
|
||||
"requested_duration_minutes": duration_minutes,
|
||||
"day_hours": f"{day_start_hour:02d}:00 - {day_end_hour:02d}:00",
|
||||
"slots_found": len(free_slots),
|
||||
"free_slots": free_slots,
|
||||
}
|
||||
|
||||
|
||||
@@ -604,7 +946,7 @@ def test_connection() -> Dict[str, Any]:
|
||||
TOOLS_MANIFEST = [
|
||||
{
|
||||
"name": "calendar_list_events",
|
||||
"description": "List upcoming appointments and events from the Google Calendar. Returns event title, start/end time, color category, location, and description.",
|
||||
"description": "List upcoming appointments and events from the Google Calendar. Returns event title, start/end time, color category, location, recurrence, and description.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -625,6 +967,14 @@ TOOLS_MANIFEST = [
|
||||
"type": "string",
|
||||
"description": "Optional search term to filter event titles and descriptions.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Optional category filter: 'arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt'.",
|
||||
},
|
||||
"color_id": {
|
||||
"type": "string",
|
||||
"description": "Optional colorId filter ('11' for Arzt, '5' for Mila, '9' for Moritz, etc.).",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of events to return (default 25).",
|
||||
@@ -663,7 +1013,7 @@ TOOLS_MANIFEST = [
|
||||
},
|
||||
{
|
||||
"name": "calendar_create_event",
|
||||
"description": "Create a new appointment in Google Calendar. Supports automatic color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange, Haushalt=Grau), address location, prep checklists, and conflict warnings.",
|
||||
"description": "Create a new appointment in Google Calendar. Supports automatic color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange, Haushalt=Grau), recurring series (RRULE), transparency (busy vs free), phone notification reminders, address location, checklists, and conflict warnings.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["summary", "start_time"],
|
||||
@@ -685,6 +1035,21 @@ TOOLS_MANIFEST = [
|
||||
"type": "string",
|
||||
"description": "Optional end time in ISO format. If omitted for timed events, defaults to 1 hour after start_time.",
|
||||
},
|
||||
"recurrence": {
|
||||
"description": "Optional recurrence rule(s) for repeating events (e.g. ['RRULE:FREQ=WEEKLY;BYDAY=SA'], 'RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z', or shortcuts like 'weekly', 'daily', 'monthly', 'workdays').",
|
||||
},
|
||||
"show_as_busy": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this event marks time as busy. Set False for informational city events, festivals, or notes so they don't block family calendar slots or cause false conflict warnings (default True).",
|
||||
"default": True,
|
||||
},
|
||||
"transparency": {
|
||||
"type": "string",
|
||||
"description": "Explicit Google transparency: 'opaque' (busy) or 'transparent' (free/available).",
|
||||
},
|
||||
"reminder_minutes": {
|
||||
"description": "Optional notification reminder minutes before event (e.g. [15, 60] or 1440 for 1 day before). Sends popup alert to phone.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Optional category/person for color-coding: 'arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt'. If omitted, auto-detected from summary.",
|
||||
@@ -706,6 +1071,11 @@ TOOLS_MANIFEST = [
|
||||
"description": "Whether the event lasts all day (default False).",
|
||||
"default": False,
|
||||
},
|
||||
"inclusive_end": {
|
||||
"type": "boolean",
|
||||
"description": "For all-day events with end_time date, whether the end date is inclusive (default True).",
|
||||
"default": True,
|
||||
},
|
||||
"check_conflicts": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to check for scheduling overlaps and attach warnings (default True).",
|
||||
@@ -716,7 +1086,7 @@ TOOLS_MANIFEST = [
|
||||
},
|
||||
{
|
||||
"name": "calendar_update_event",
|
||||
"description": "Update an existing event in the Google Calendar (title, times, category/color, description, location).",
|
||||
"description": "Update an existing event or recurring series in the Google Calendar (title, times, recurrence, transparency, reminders, category/color, description, location).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["event_id"],
|
||||
@@ -742,6 +1112,25 @@ TOOLS_MANIFEST = [
|
||||
"type": "string",
|
||||
"description": "New end time in ISO format.",
|
||||
},
|
||||
"recurrence": {
|
||||
"description": "Updated recurrence rule(s), or [] / empty string to remove recurrence.",
|
||||
},
|
||||
"show_as_busy": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this event marks time as busy (True) or free (False).",
|
||||
},
|
||||
"transparency": {
|
||||
"type": "string",
|
||||
"description": "Explicit transparency: 'opaque' (busy) or 'transparent' (available).",
|
||||
},
|
||||
"reminder_minutes": {
|
||||
"description": "Updated reminder minutes list before event (e.g. [15, 60]).",
|
||||
},
|
||||
"update_series": {
|
||||
"type": "boolean",
|
||||
"description": "If True and event is an occurrence of a series, updates the entire master series (default False).",
|
||||
"default": False,
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Category for color-coding ('arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt').",
|
||||
@@ -763,7 +1152,7 @@ TOOLS_MANIFEST = [
|
||||
},
|
||||
{
|
||||
"name": "calendar_delete_event",
|
||||
"description": "Delete an event from the Google Calendar.",
|
||||
"description": "Delete an event or recurring series from the Google Calendar.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["event_id"],
|
||||
@@ -777,6 +1166,72 @@ TOOLS_MANIFEST = [
|
||||
"type": "string",
|
||||
"description": "The unique ID of the event to delete.",
|
||||
},
|
||||
"delete_series": {
|
||||
"type": "boolean",
|
||||
"description": "If True and the event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence (default False).",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "calendar_move_event",
|
||||
"description": "Move an event atomically between calendars (e.g. from 'regensburg' to 'family' or vice versa).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["event_id"],
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "The unique ID of the event to move.",
|
||||
},
|
||||
"source_calendar": {
|
||||
"type": "string",
|
||||
"description": "Source calendar ('family' or 'regensburg'). Default 'family'.",
|
||||
"default": "family",
|
||||
},
|
||||
"destination_calendar": {
|
||||
"type": "string",
|
||||
"description": "Destination calendar ('family' or 'regensburg'). Default 'regensburg'.",
|
||||
"default": "regensburg",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "calendar_find_free_slots",
|
||||
"description": "Find available free time windows across calendars (family, regensburg, or both) using Google Calendar freebusy query.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"calendar": {
|
||||
"type": "string",
|
||||
"description": "Which calendar(s) to check: 'family' (default), 'regensburg', or 'both'/'all'.",
|
||||
"default": "family",
|
||||
},
|
||||
"time_min": {
|
||||
"type": "string",
|
||||
"description": "Start of search timeframe in ISO format (defaults to now).",
|
||||
},
|
||||
"time_max": {
|
||||
"type": "string",
|
||||
"description": "End of search timeframe in ISO format (defaults to 7 days ahead).",
|
||||
},
|
||||
"duration_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Minimum length of the free slot required in minutes (default 60).",
|
||||
"default": 60,
|
||||
},
|
||||
"day_start_hour": {
|
||||
"type": "integer",
|
||||
"description": "Earliest hour of the day to consider (default 8 for 08:00).",
|
||||
"default": 8,
|
||||
},
|
||||
"day_end_hour": {
|
||||
"type": "integer",
|
||||
"description": "Latest hour of the day to consider (default 20 for 20:00).",
|
||||
"default": 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -827,7 +1282,7 @@ def run_mcp_server():
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "google-calendar-mcp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -858,6 +1313,8 @@ def run_mcp_server():
|
||||
time_min=args.get("time_min"),
|
||||
time_max=args.get("time_max"),
|
||||
query=args.get("query"),
|
||||
category=args.get("category"),
|
||||
color_id=args.get("color_id"),
|
||||
max_results=args.get("max_results", 25),
|
||||
)
|
||||
elif tool_name == "calendar_check_conflicts":
|
||||
@@ -878,6 +1335,11 @@ def run_mcp_server():
|
||||
all_day=args.get("all_day", False),
|
||||
category=args.get("category"),
|
||||
color_id=args.get("color_id"),
|
||||
recurrence=args.get("recurrence"),
|
||||
show_as_busy=args.get("show_as_busy"),
|
||||
transparency=args.get("transparency"),
|
||||
reminder_minutes=args.get("reminder_minutes"),
|
||||
inclusive_end=args.get("inclusive_end", True),
|
||||
check_conflicts_flag=args.get("check_conflicts", True),
|
||||
)
|
||||
elif tool_name == "calendar_update_event":
|
||||
@@ -891,11 +1353,33 @@ def run_mcp_server():
|
||||
location=args.get("location"),
|
||||
category=args.get("category"),
|
||||
color_id=args.get("color_id"),
|
||||
recurrence=args.get("recurrence"),
|
||||
show_as_busy=args.get("show_as_busy"),
|
||||
transparency=args.get("transparency"),
|
||||
reminder_minutes=args.get("reminder_minutes"),
|
||||
inclusive_end=args.get("inclusive_end", True),
|
||||
update_series=args.get("update_series", False),
|
||||
)
|
||||
elif tool_name == "calendar_delete_event":
|
||||
data = delete_event(
|
||||
calendar=args.get("calendar", "family"),
|
||||
event_id=args.get("event_id", ""),
|
||||
delete_series=args.get("delete_series", False),
|
||||
)
|
||||
elif tool_name == "calendar_move_event":
|
||||
data = move_event(
|
||||
event_id=args.get("event_id", ""),
|
||||
source_calendar=args.get("source_calendar", "family"),
|
||||
destination_calendar=args.get("destination_calendar", "regensburg"),
|
||||
)
|
||||
elif tool_name == "calendar_find_free_slots":
|
||||
data = find_free_slots(
|
||||
calendar=args.get("calendar", "family"),
|
||||
time_min=args.get("time_min"),
|
||||
time_max=args.get("time_max"),
|
||||
duration_minutes=args.get("duration_minutes", 60),
|
||||
day_start_hour=args.get("day_start_hour", 8),
|
||||
day_end_hour=args.get("day_end_hour", 20),
|
||||
)
|
||||
elif tool_name == "calendar_get_event":
|
||||
data = get_event(
|
||||
@@ -953,6 +1437,8 @@ def run_cli():
|
||||
sp_list.add_argument("--min", dest="time_min", help="ISO start time")
|
||||
sp_list.add_argument("--max", dest="time_max", help="ISO end time")
|
||||
sp_list.add_argument("--query", "-q", help="Search text query")
|
||||
sp_list.add_argument("--category", help="Category filter")
|
||||
sp_list.add_argument("--color", dest="color_id", help="Color ID filter")
|
||||
sp_list.add_argument("--limit", "-n", type=int, default=25, help="Max results")
|
||||
|
||||
# Conflicts
|
||||
@@ -962,6 +1448,13 @@ def run_cli():
|
||||
sp_conf.add_argument("--calendar", "-c", default="family")
|
||||
sp_conf.add_argument("--buffer", type=int, default=15, help="Buffer minutes")
|
||||
|
||||
# Free slots
|
||||
sp_slots = subparsers.add_parser("free-slots", help="Find free time windows")
|
||||
sp_slots.add_argument("--calendar", "-c", default="family", help="'family', 'regensburg', or 'both'")
|
||||
sp_slots.add_argument("--min", dest="time_min", help="ISO start time")
|
||||
sp_slots.add_argument("--max", dest="time_max", help="ISO end time")
|
||||
sp_slots.add_argument("--duration", type=int, default=60, help="Required duration in minutes")
|
||||
|
||||
# Create
|
||||
sp_create = subparsers.add_parser("create", help="Create event")
|
||||
sp_create.add_argument("summary", help="Event summary/title")
|
||||
@@ -969,14 +1462,24 @@ def run_cli():
|
||||
sp_create.add_argument("--end", dest="end_time", help="End time")
|
||||
sp_create.add_argument("--calendar", "-c", default="family", help="'family' or 'regensburg'")
|
||||
sp_create.add_argument("--category", help="Category: arzt, mila, moritz, amelie, familie, haushalt")
|
||||
sp_create.add_argument("--recurrence", "-r", help="RRULE recurrence (e.g. 'RRULE:FREQ=WEEKLY;BYDAY=SA' or 'weekly')")
|
||||
sp_create.add_argument("--free", "--transparent", dest="transparent", action="store_true", help="Mark event as free/transparent")
|
||||
sp_create.add_argument("--reminder", dest="reminder_minutes", type=int, action="append", help="Reminder in minutes before event")
|
||||
sp_create.add_argument("--description", "-d", help="Description")
|
||||
sp_create.add_argument("--location", "-l", help="Location")
|
||||
sp_create.add_argument("--all-day", action="store_true", help="All day event")
|
||||
|
||||
# Move
|
||||
sp_move = subparsers.add_parser("move", help="Move event between calendars")
|
||||
sp_move.add_argument("event_id", help="Event ID")
|
||||
sp_move.add_argument("--from", dest="source_calendar", default="family", help="Source calendar")
|
||||
sp_move.add_argument("--to", dest="destination_calendar", default="regensburg", help="Destination calendar")
|
||||
|
||||
# Delete
|
||||
sp_del = subparsers.add_parser("delete", help="Delete event")
|
||||
sp_del.add_argument("event_id", help="Event ID")
|
||||
sp_del.add_argument("--calendar", "-c", default="family", help="'family' or 'regensburg'")
|
||||
sp_del.add_argument("--series", action="store_true", help="Delete entire recurring series")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -989,12 +1492,21 @@ def run_cli():
|
||||
end_time=args.end_time,
|
||||
buffer_minutes=args.buffer,
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "free-slots":
|
||||
print(json.dumps(find_free_slots(
|
||||
calendar=args.calendar,
|
||||
time_min=args.time_min,
|
||||
time_max=args.time_max,
|
||||
duration_minutes=args.duration,
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "list":
|
||||
print(json.dumps(list_events(
|
||||
calendar=args.calendar,
|
||||
time_min=args.time_min,
|
||||
time_max=args.time_max,
|
||||
query=args.query,
|
||||
category=args.category,
|
||||
color_id=args.color_id,
|
||||
max_results=args.limit,
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "create":
|
||||
@@ -1004,14 +1516,24 @@ def run_cli():
|
||||
start_time=args.start_time,
|
||||
end_time=args.end_time,
|
||||
category=args.category,
|
||||
recurrence=args.recurrence,
|
||||
show_as_busy=not args.transparent,
|
||||
reminder_minutes=args.reminder_minutes,
|
||||
description=args.description,
|
||||
location=args.location,
|
||||
all_day=args.all_day,
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "move":
|
||||
print(json.dumps(move_event(
|
||||
event_id=args.event_id,
|
||||
source_calendar=args.source_calendar,
|
||||
destination_calendar=args.destination_calendar,
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "delete":
|
||||
print(json.dumps(delete_event(
|
||||
calendar=args.calendar,
|
||||
event_id=args.event_id,
|
||||
delete_series=args.series,
|
||||
), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
Reference in New Issue
Block a user