feat(calendar): implement visual color-coding, conflict-guard, and prep checklists
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: google-calendar
|
||||
description: Read, search, create, update, and delete Google Calendar events across the Family Calendar and Regensburg & more Calendar. Use when the user asks about upcoming appointments ("Was steht an?", "Termine diese Woche", "Wann ist der nächste Arzttermin?"), wants to add appointments or family events, check for scheduling conflicts, or track city events in Regensburg.
|
||||
description: Read, search, create, update, and delete Google Calendar events across the Family Calendar and Regensburg & more Calendar. Supports visual color-coding (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange), conflict & travel-buffer checking, address locations, and packing checklists.
|
||||
---
|
||||
|
||||
# 📅 Google Calendar (Familie & Regensburg)
|
||||
|
||||
Full two-way Google Calendar integration for Moritz and family, managing two distinct calendars via a dedicated GCP Service Account:
|
||||
Full two-way Google Calendar integration for Moritz, Amelie, and family, managing two distinct calendars via a dedicated GCP Service Account:
|
||||
|
||||
1. 👨👩👧 **Familienkalender (`family`)**:
|
||||
- Primary personal & family schedule: doctor visits, daycare/kindergarten, birthdays, visits, work trips, family outings, shared commitments.
|
||||
@@ -16,57 +16,74 @@ Full two-way Google Calendar integration for Moritz and family, managing two dis
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Farbleitsystem (Visual Color Coding)
|
||||
|
||||
OpenClaw assigns Google Calendar colors automatically based on event titles or explicit categories:
|
||||
|
||||
| Farbe | `colorId` | Kategorie / Person | Typische Stichwörter |
|
||||
| :--- | :---: | :--- | :--- |
|
||||
| 🔴 **Rot (Tomate)** | `11` | **Arzt & Gesundheit** | Arzt, Kinderarzt, Zahnarzt, U1..U9, Impfung, Klinik, Physio, Blutabnahme |
|
||||
| 🟡 **Gelb (Banane)** | `5` | **Mila & Kinder** | Mila, Kita, Kindergarten, Krabbelgruppe, Spielplatz, Babyschwimmen, Kinderturnen |
|
||||
| 🔵 **Blau (Heidelbeere)** | `9` | **Moritz** | Moritz, Arbeit, Büro, Job, Dienstreise |
|
||||
| 🟢 **Grün (Salbei)** | `2` | **Amelie** | Amelie, Amy |
|
||||
| 🟠 **Orange (Mandarine)** | `6` | **Familie & Feiern** | Familie, Ausflug, Urlaub, Feier, Party, Geburtstag, Besuch, Oma, Opa, Essen |
|
||||
| ⚪ **Grau (Graphit)** | `8` | **Haushalt & Wartung** | Haushalt, Auto, TÜV, Werkstatt, Müll, Handwerker |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ MCP Tools
|
||||
|
||||
You have access to 5 dedicated calendar tools:
|
||||
You have access to 6 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.
|
||||
- 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.
|
||||
- `query`: Text filter to search summaries and descriptions.
|
||||
|
||||
2. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, description=None, location=None, all_day=False)`:
|
||||
- Adds a new appointment.
|
||||
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_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, description=None, location=None)`:
|
||||
- Updates fields on an existing event.
|
||||
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_delete_event(calendar="family"|"regensburg", event_id)`:
|
||||
5. `calendar_delete_event(calendar="family"|"regensburg", event_id)`:
|
||||
- Deletes an event.
|
||||
|
||||
5. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
||||
6. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
|
||||
- Retrieves full event metadata.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Routing & Operational Rules
|
||||
## 🧭 Routing & Best Practices for OpenClaw
|
||||
|
||||
### 1. Calendar Routing
|
||||
- **Default to `family`**: Any request like *"Erinnere mich an..."*, *"Trage Zahnarzt am Dienstag um 10 Uhr ein"*, *"Wann haben wir diese Woche Zeit?"* always targets the **family** calendar.
|
||||
- **Route to `regensburg`**: Events concerning the city, public festivals, concerts, or cultural events in Regensburg (e.g. *"Trage die Herbstdult ein"*, *"Wann ist das Bürgerfest?"*) belong in the **regensburg** calendar.
|
||||
### 1. Conflict Guard (Terminkonflikte & Puffer)
|
||||
- When a user asks to add an event, check `calendar_check_conflicts` or inspect the `conflict_warning` from `calendar_create_event`.
|
||||
- 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. Timezone & DateTime Precision
|
||||
### 2. 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)
|
||||
- When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*):
|
||||
- Auto-generate or suggest a helpful checklist in `description`:
|
||||
```text
|
||||
📋 Mitnehmen:
|
||||
- U-Heft & Impfpass
|
||||
- Versichertenkarte
|
||||
- 2x Wickelzeug & Schnuller
|
||||
```
|
||||
- Ask proactively in Telegram:
|
||||
> *„Termin eingetragen! 🏥 Soll ich noch eine Notiz oder Packliste (U-Heft, Impfpass, Wechselsachen) im Termin hinterlegen?“*
|
||||
|
||||
### 4. Timezone & DateTime Precision
|
||||
- All dates and times operate in **Europe/Berlin** (`CET`/`CEST`).
|
||||
- Always specify concrete dates and times when confirming actions.
|
||||
|
||||
### 3. Safety & Confirmations
|
||||
- **Conflict awareness**: When adding an appointment, check whether there is already a conflicting event at that time and politely point it out if relevant.
|
||||
- **Deletions require care**: If deleting or overwriting an existing event, confirm the title and date clearly.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Telegram Presentation Style
|
||||
|
||||
Format calendar overviews cleanly for mobile viewing:
|
||||
|
||||
```markdown
|
||||
📅 **Termine diese Woche (Familie)**:
|
||||
• **Mo, 16.09. 09:00 - 10:00**: Kinderarzt U6 🏥
|
||||
• **Mi, 18.09. Ganztägig**: Oma zu Besuch 👵
|
||||
• **Fr, 20.09. 15:30 - 17:00**: Spielplatz-Treffen 🛝
|
||||
|
||||
🏛️ **Regensburg Highlights**:
|
||||
• **Sa, 21.09. ab 18:00**: Jazz-Konzert im Thon-Dittmer-Palais 🎷
|
||||
```
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Google Calendar MCP Server & CLI Tool
|
||||
Integrates Google Calendar (Family & Regensburg calendars) with OpenClaw via MCP stdio or direct CLI.
|
||||
Authenticates using a Google Cloud Service Account.
|
||||
Features:
|
||||
- Color-coding for family members and categories (Arzt=Rot, Mila=Gelb, Moritz=Blau, Amelie=Grün, Familie=Orange, Haushalt=Grau)
|
||||
- Conflict & travel-buffer detection before and during event creation
|
||||
- Rich location and description metadata (for Google Maps navigation & prep checklists)
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -10,7 +14,7 @@ import sys
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# Environment Configuration
|
||||
@@ -22,6 +26,104 @@ CALENDAR_ID_REGENSBURG = os.environ.get("CALENDAR_ID_REGENSBURG", "").strip()
|
||||
|
||||
SCOPES = ["https://www.googleapis.com/auth/calendar"]
|
||||
|
||||
# ==============================================================================
|
||||
# Color Coding Palette (Google Calendar Standard colorIds 1-11)
|
||||
# ==============================================================================
|
||||
COLOR_NAMES = {
|
||||
"11": "Rot (Tomate) - Arzt / Gesundheit / Dringend",
|
||||
"5": "Gelb (Banane) - Mila / Kinder / Baby",
|
||||
"9": "Blau (Heidelbeere) - Moritz / Arbeit",
|
||||
"2": "Grün (Salbei) - Amelie",
|
||||
"6": "Orange (Mandarine) - Familie / Ausflug / Feiern",
|
||||
"8": "Grau (Graphit) - Haushalt / Auto / Notizen",
|
||||
"1": "Lavendel",
|
||||
"3": "Traube / Lila",
|
||||
"4": "Flamingo / Rosa",
|
||||
"7": "Pfau / Türkis",
|
||||
"10": "Basilikum / Dunkelgrün",
|
||||
}
|
||||
|
||||
CATEGORY_TO_COLOR = {
|
||||
# 🔴 Rot: Arzt / Gesundheit
|
||||
"arzt": "11",
|
||||
"kinderarzt": "11",
|
||||
"zahnarzt": "11",
|
||||
"doktor": "11",
|
||||
"gesundheit": "11",
|
||||
"impfung": "11",
|
||||
"impfen": "11",
|
||||
"u-untersuchung": "11",
|
||||
"vorsorge": "11",
|
||||
"klinik": "11",
|
||||
"krankenhaus": "11",
|
||||
"physio": "11",
|
||||
"apotheke": "11",
|
||||
"blutabnahme": "11",
|
||||
# 🟡 Gelb: Mila & Kinder
|
||||
"mila": "5",
|
||||
"kind": "5",
|
||||
"kinder": "5",
|
||||
"kita": "5",
|
||||
"kindergarten": "5",
|
||||
"krabbelgruppe": "5",
|
||||
"baby": "5",
|
||||
"spielplatz": "5",
|
||||
"babyschwimmen": "5",
|
||||
"kinderturnen": "5",
|
||||
# 🔵 Blau: Moritz
|
||||
"moritz": "9",
|
||||
"arbeit": "9",
|
||||
"work": "9",
|
||||
"büro": "9",
|
||||
"job": "9",
|
||||
# 🟢 Grün: Amelie
|
||||
"amelie": "2",
|
||||
"amy": "2",
|
||||
# 🟠 Orange: Familie & Feiern
|
||||
"familie": "6",
|
||||
"family": "6",
|
||||
"ausflug": "6",
|
||||
"urlaub": "6",
|
||||
"feier": "6",
|
||||
"party": "6",
|
||||
"geburtstag": "6",
|
||||
"besuch": "6",
|
||||
"oma": "6",
|
||||
"opa": "6",
|
||||
"essen": "6",
|
||||
"restaurant": "6",
|
||||
# ⚪ Grau: Haushalt & Wartung
|
||||
"haushalt": "8",
|
||||
"auto": "8",
|
||||
"tüv": "8",
|
||||
"werkstatt": "8",
|
||||
"müll": "8",
|
||||
"wartung": "8",
|
||||
}
|
||||
|
||||
|
||||
def resolve_color_id(summary: str, explicit_category_or_color: Optional[str] = None) -> Optional[str]:
|
||||
"""Resolve colorId based on explicit argument or automatic keyword matching."""
|
||||
if explicit_category_or_color:
|
||||
val = str(explicit_category_or_color).strip().lower()
|
||||
if val in COLOR_NAMES:
|
||||
return val
|
||||
if val in CATEGORY_TO_COLOR:
|
||||
return CATEGORY_TO_COLOR[val]
|
||||
|
||||
# Auto-detection from summary keywords
|
||||
summary_lower = summary.lower()
|
||||
for keyword, color_id in CATEGORY_TO_COLOR.items():
|
||||
if keyword in summary_lower:
|
||||
return color_id
|
||||
|
||||
# U-Untersuchung regex-like check (e.g. U1..U9)
|
||||
import re
|
||||
if re.search(r"\bu[1-9]\b", summary_lower):
|
||||
return "11" # Arzt/Vorsorge
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""Load Google Service Account credentials from environment or file."""
|
||||
@@ -29,7 +131,6 @@ def get_credentials():
|
||||
|
||||
sa_json_env = os.environ.get("GOOGLE_CALENDAR_SA_JSON", "").strip()
|
||||
if sa_json_env:
|
||||
# Check if it is a JSON string or a file path
|
||||
if sa_json_env.startswith("{"):
|
||||
try:
|
||||
info = json.loads(sa_json_env)
|
||||
@@ -39,12 +140,10 @@ def get_credentials():
|
||||
elif os.path.isfile(sa_json_env):
|
||||
return service_account.Credentials.from_service_account_file(sa_json_env, scopes=SCOPES)
|
||||
|
||||
# Fallback: Check GOOGLE_APPLICATION_CREDENTIALS
|
||||
gac = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "").strip()
|
||||
if gac and os.path.isfile(gac):
|
||||
return service_account.Credentials.from_service_account_file(gac, scopes=SCOPES)
|
||||
|
||||
# Fallback: Check local secret files in repo
|
||||
candidate_paths = [
|
||||
"/home/openclaw/.openclaw/calendar_sa.json",
|
||||
"/home/node/.openclaw/calendar_sa.json",
|
||||
@@ -72,16 +171,12 @@ def get_calendar_service():
|
||||
|
||||
|
||||
def resolve_calendar_id(calendar_identifier: Optional[str] = None) -> str:
|
||||
"""
|
||||
Resolve calendar alias ('family' or 'regensburg') to its Google Calendar ID.
|
||||
If a full calendar ID (containing '@') is passed, returns it as-is.
|
||||
"""
|
||||
"""Resolve calendar alias ('family' or 'regensburg') to its Google Calendar ID."""
|
||||
ident = (calendar_identifier or "family").strip()
|
||||
ident_lower = ident.lower()
|
||||
|
||||
if ident_lower in ("family", "familie", "familienkalender", "default", "primary"):
|
||||
if not CALENDAR_ID_FAMILY:
|
||||
# If not configured in env, check if it's in a shared calendar list
|
||||
raise ValueError(
|
||||
"CALENDAR_ID_FAMILY is not configured. Please set the environment variable "
|
||||
"CALENDAR_ID_FAMILY to the Google Calendar ID of the family calendar."
|
||||
@@ -96,7 +191,6 @@ def resolve_calendar_id(calendar_identifier: Optional[str] = None) -> str:
|
||||
)
|
||||
return CALENDAR_ID_REGENSBURG
|
||||
|
||||
# Assume direct calendar ID
|
||||
return ident
|
||||
|
||||
|
||||
@@ -112,7 +206,7 @@ def parse_datetime_str(dt_str: str) -> datetime:
|
||||
|
||||
|
||||
def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
|
||||
"""Format an event item into a clean representation."""
|
||||
"""Format an event item into a clean representation including color labels."""
|
||||
start = item.get("start", {})
|
||||
end = item.get("end", {})
|
||||
|
||||
@@ -120,6 +214,9 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
|
||||
start_str = start.get("date") if is_all_day else start.get("dateTime")
|
||||
end_str = end.get("date") if is_all_day else end.get("dateTime")
|
||||
|
||||
color_id = item.get("colorId")
|
||||
color_label = COLOR_NAMES.get(color_id) if color_id else None
|
||||
|
||||
return {
|
||||
"id": item.get("id"),
|
||||
"calendar": calendar_name,
|
||||
@@ -127,6 +224,8 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
|
||||
"start": start_str,
|
||||
"end": end_str,
|
||||
"all_day": is_all_day,
|
||||
"color_id": color_id,
|
||||
"color_name": color_label,
|
||||
"description": item.get("description", ""),
|
||||
"location": item.get("location", ""),
|
||||
"status": item.get("status", "confirmed"),
|
||||
@@ -134,6 +233,100 @@ def format_event(item: Dict[str, Any], calendar_name: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Conflict & Buffer Detection
|
||||
# ==============================================================================
|
||||
|
||||
def check_conflicts(
|
||||
calendar: str = "family",
|
||||
start_time: str = "",
|
||||
end_time: Optional[str] = None,
|
||||
buffer_minutes: int = 15,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if a proposed timeslot conflicts with existing appointments or falls within
|
||||
a tight travel buffer (< buffer_minutes).
|
||||
"""
|
||||
if not start_time:
|
||||
raise ValueError("Parameter 'start_time' is required.")
|
||||
|
||||
service = get_calendar_service()
|
||||
cal_id = resolve_calendar_id(calendar)
|
||||
|
||||
# Parse proposed time
|
||||
dt_start = parse_datetime_str(start_time)
|
||||
if end_time:
|
||||
dt_end = parse_datetime_str(end_time)
|
||||
else:
|
||||
dt_end = dt_start + timedelta(hours=1)
|
||||
|
||||
# Search window: from (start - buffer) to (end + buffer)
|
||||
window_start = dt_start - timedelta(minutes=max(buffer_minutes, 60))
|
||||
window_end = dt_end + timedelta(minutes=max(buffer_minutes, 60))
|
||||
|
||||
params = {
|
||||
"calendarId": cal_id,
|
||||
"timeMin": window_start.isoformat(),
|
||||
"timeMax": window_end.isoformat(),
|
||||
"singleEvents": True,
|
||||
"orderBy": "startTime",
|
||||
}
|
||||
res = service.events().list(**params).execute()
|
||||
items = res.get("items", [])
|
||||
|
||||
direct_conflicts = []
|
||||
buffer_warnings = []
|
||||
|
||||
for item in items:
|
||||
# Ignore all-day transparent events (they don't block time)
|
||||
if item.get("transparency") == "transparent":
|
||||
continue
|
||||
|
||||
item_start_raw = item.get("start", {}).get("dateTime")
|
||||
item_end_raw = item.get("end", {}).get("dateTime")
|
||||
if not item_start_raw or not item_end_raw:
|
||||
continue
|
||||
|
||||
ev_start = parse_datetime_str(item_start_raw)
|
||||
ev_end = parse_datetime_str(item_end_raw)
|
||||
|
||||
# Check direct overlap: ev_start < dt_end and ev_end > dt_start
|
||||
if ev_start < dt_end and ev_end > dt_start:
|
||||
direct_conflicts.append(format_event(item, calendar))
|
||||
else:
|
||||
# Check tight buffer before or after
|
||||
gap_before = (dt_start - ev_end).total_seconds() / 60.0
|
||||
gap_after = (ev_start - dt_end).total_seconds() / 60.0
|
||||
|
||||
if 0 <= gap_before < buffer_minutes:
|
||||
buffer_warnings.append({
|
||||
"type": "tight_buffer_before",
|
||||
"minutes_gap": int(gap_before),
|
||||
"event": format_event(item, calendar),
|
||||
})
|
||||
elif 0 <= gap_after < buffer_minutes:
|
||||
buffer_warnings.append({
|
||||
"type": "tight_buffer_after",
|
||||
"minutes_gap": int(gap_after),
|
||||
"event": format_event(item, calendar),
|
||||
})
|
||||
|
||||
has_conflict = len(direct_conflicts) > 0
|
||||
has_buffer_warning = len(buffer_warnings) > 0
|
||||
|
||||
return {
|
||||
"has_conflict": has_conflict,
|
||||
"has_buffer_warning": has_buffer_warning,
|
||||
"direct_conflicts_count": len(direct_conflicts),
|
||||
"direct_conflicts": direct_conflicts,
|
||||
"buffer_warnings_count": len(buffer_warnings),
|
||||
"buffer_warnings": buffer_warnings,
|
||||
"buffer_minutes_checked": buffer_minutes,
|
||||
"proposed_start": dt_start.isoformat(),
|
||||
"proposed_end": dt_end.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Calendar Operations
|
||||
# ==============================================================================
|
||||
@@ -149,7 +342,6 @@ def list_events(
|
||||
service = get_calendar_service()
|
||||
cal_id = resolve_calendar_id(calendar)
|
||||
|
||||
# Defaults: from start of current day to 30 days ahead
|
||||
now = datetime.now(DEFAULT_TZ)
|
||||
if not time_min:
|
||||
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
@@ -196,8 +388,15 @@ def create_event(
|
||||
description: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
all_day: bool = False,
|
||||
category: Optional[str] = None,
|
||||
color_id: Optional[str] = None,
|
||||
check_conflicts_flag: bool = True,
|
||||
buffer_minutes: int = 15,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new event in the specified calendar."""
|
||||
"""
|
||||
Create a new event in the specified calendar with automatic color-coding
|
||||
and conflict detection.
|
||||
"""
|
||||
if not summary:
|
||||
raise ValueError("Parameter 'summary' is required.")
|
||||
if not start_time:
|
||||
@@ -206,6 +405,20 @@ def create_event(
|
||||
service = get_calendar_service()
|
||||
cal_id = resolve_calendar_id(calendar)
|
||||
|
||||
# 1. Resolve Color
|
||||
chosen_color_id = resolve_color_id(summary, explicit_category_or_color=color_id or category)
|
||||
|
||||
# 2. Check for conflicts if timed event
|
||||
conflict_info = None
|
||||
if check_conflicts_flag and not all_day:
|
||||
conflict_info = check_conflicts(
|
||||
calendar=calendar,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
buffer_minutes=buffer_minutes,
|
||||
)
|
||||
|
||||
# 3. Build Google Event Resource
|
||||
body: Dict[str, Any] = {
|
||||
"summary": summary,
|
||||
}
|
||||
@@ -213,14 +426,14 @@ def create_event(
|
||||
body["description"] = description
|
||||
if location:
|
||||
body["location"] = location
|
||||
if chosen_color_id:
|
||||
body["colorId"] = chosen_color_id
|
||||
|
||||
if all_day:
|
||||
# Expected format YYYY-MM-DD
|
||||
start_date = start_time.split("T")[0]
|
||||
if end_time:
|
||||
end_date = end_time.split("T")[0]
|
||||
else:
|
||||
# Single-day all-day event in Google Calendar ends on the NEXT day
|
||||
dt_start = date.fromisoformat(start_date)
|
||||
end_date = (dt_start + timedelta(days=1)).isoformat()
|
||||
|
||||
@@ -243,11 +456,17 @@ def create_event(
|
||||
}
|
||||
|
||||
created = service.events().insert(calendarId=cal_id, body=body).execute()
|
||||
return {
|
||||
result = {
|
||||
"status": "created",
|
||||
"event": format_event(created, calendar),
|
||||
}
|
||||
|
||||
# Attach conflict warning if overlaps were detected
|
||||
if conflict_info and (conflict_info["has_conflict"] or conflict_info["has_buffer_warning"]):
|
||||
result["conflict_warning"] = conflict_info
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def update_event(
|
||||
calendar: str = "family",
|
||||
@@ -257,6 +476,8 @@ def update_event(
|
||||
end_time: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
color_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing event."""
|
||||
if not event_id:
|
||||
@@ -265,7 +486,6 @@ def update_event(
|
||||
service = get_calendar_service()
|
||||
cal_id = resolve_calendar_id(calendar)
|
||||
|
||||
# Fetch existing
|
||||
existing = service.events().get(calendarId=cal_id, eventId=event_id).execute()
|
||||
|
||||
if summary is not None:
|
||||
@@ -275,6 +495,10 @@ def update_event(
|
||||
if location is not None:
|
||||
existing["location"] = location
|
||||
|
||||
new_color = resolve_color_id(summary or existing.get("summary", ""), explicit_category_or_color=color_id or category)
|
||||
if new_color:
|
||||
existing["colorId"] = new_color
|
||||
|
||||
if start_time is not None:
|
||||
if "date" in existing.get("start", {}):
|
||||
existing["start"]["date"] = start_time.split("T")[0]
|
||||
@@ -369,6 +593,7 @@ def test_connection() -> Dict[str, Any]:
|
||||
"configured_regensburg_id": CALENDAR_ID_REGENSBURG or "(empty)",
|
||||
"regensburg_calendar_check": regensburg_status,
|
||||
"accessible_calendars": calendars,
|
||||
"color_palette": COLOR_NAMES,
|
||||
}
|
||||
|
||||
|
||||
@@ -379,13 +604,13 @@ def test_connection() -> Dict[str, Any]:
|
||||
TOOLS_MANIFEST = [
|
||||
{
|
||||
"name": "calendar_list_events",
|
||||
"description": "List upcoming appointments and events from the Google Calendar. Supports filtering by calendar ('family' or 'regensburg'), date range, and search query.",
|
||||
"description": "List upcoming appointments and events from the Google Calendar. Returns event title, start/end time, color category, location, and description.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"calendar": {
|
||||
"type": "string",
|
||||
"description": "Which calendar to query: 'family' (default) for family appointments, or 'regensburg' for city/local events and activities.",
|
||||
"description": "Which calendar to query: 'family' (default) for family appointments, or 'regensburg' for city/local events.",
|
||||
"default": "family",
|
||||
},
|
||||
"time_min": {
|
||||
@@ -408,21 +633,49 @@ TOOLS_MANIFEST = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "calendar_check_conflicts",
|
||||
"description": "Check whether a proposed appointment time conflicts with existing events in the calendar or has tight travel buffers (< buffer_minutes). Use this before booking new events to warn the user about overlaps.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["start_time"],
|
||||
"properties": {
|
||||
"calendar": {
|
||||
"type": "string",
|
||||
"description": "Which calendar to check: 'family' (default) or 'regensburg'.",
|
||||
"default": "family",
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"description": "Proposed start time in ISO format (e.g. '2026-09-20T14:30:00').",
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"description": "Proposed end time in ISO format (defaults to 1 hour after start_time).",
|
||||
},
|
||||
"buffer_minutes": {
|
||||
"type": "integer",
|
||||
"description": "Buffer minutes to check before and after the event for travel time (default 15).",
|
||||
"default": 15,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "calendar_create_event",
|
||||
"description": "Create a new event or appointment in the Google Calendar.",
|
||||
"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.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["summary", "start_time"],
|
||||
"properties": {
|
||||
"calendar": {
|
||||
"type": "string",
|
||||
"description": "Which calendar to add to: 'family' (default) for family schedule, or 'regensburg' for local events.",
|
||||
"description": "Which calendar to add to: 'family' (default) or 'regensburg'.",
|
||||
"default": "family",
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "Title or summary of the event (e.g. 'Kinderarzt U6 Termin', 'Bürgerfest Regensburg').",
|
||||
"description": "Title or summary of the event (e.g. 'U6 Mila Kinderarzt', 'Moritz Zahnarzt', 'Familienausflug').",
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
@@ -432,25 +685,38 @@ TOOLS_MANIFEST = [
|
||||
"type": "string",
|
||||
"description": "Optional end time in ISO format. If omitted for timed events, defaults to 1 hour after start_time.",
|
||||
},
|
||||
"description": {
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Optional notes or details for the event.",
|
||||
"description": "Optional category/person for color-coding: 'arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt'. If omitted, auto-detected from summary.",
|
||||
},
|
||||
"color_id": {
|
||||
"type": "string",
|
||||
"description": "Explicit Google Calendar colorId (11=Rot/Arzt, 5=Gelb/Mila, 9=Blau/Moritz, 2=Grün/Amelie, 6=Orange/Familie, 8=Grau/Haushalt).",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "Optional location or address.",
|
||||
"description": "Optional address or venue (e.g. 'Prüfeninger Str. 10, Regensburg' or 'Westbad Regensburg'). Triggers navigation reminders on mobile.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional notes or checklist (e.g. '📋 Mitnehmen: U-Heft, Impfpass, Wickelsachen').",
|
||||
},
|
||||
"all_day": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the event lasts all day (default False).",
|
||||
"default": False,
|
||||
},
|
||||
"check_conflicts": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to check for scheduling overlaps and attach warnings (default True).",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "calendar_update_event",
|
||||
"description": "Update an existing event in the Google Calendar.",
|
||||
"description": "Update an existing event in the Google Calendar (title, times, category/color, description, location).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["event_id"],
|
||||
@@ -476,13 +742,21 @@ TOOLS_MANIFEST = [
|
||||
"type": "string",
|
||||
"description": "New end time in ISO format.",
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Category for color-coding ('arzt', 'mila', 'moritz', 'amelie', 'familie', 'haushalt').",
|
||||
},
|
||||
"color_id": {
|
||||
"type": "string",
|
||||
"description": "Explicit colorId.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "New description/notes.",
|
||||
"description": "New description/notes/checklist.",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "New location.",
|
||||
"description": "New location/address.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -553,7 +827,7 @@ def run_mcp_server():
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "google-calendar-mcp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -586,6 +860,13 @@ def run_mcp_server():
|
||||
query=args.get("query"),
|
||||
max_results=args.get("max_results", 25),
|
||||
)
|
||||
elif tool_name == "calendar_check_conflicts":
|
||||
data = check_conflicts(
|
||||
calendar=args.get("calendar", "family"),
|
||||
start_time=args.get("start_time", ""),
|
||||
end_time=args.get("end_time"),
|
||||
buffer_minutes=args.get("buffer_minutes", 15),
|
||||
)
|
||||
elif tool_name == "calendar_create_event":
|
||||
data = create_event(
|
||||
calendar=args.get("calendar", "family"),
|
||||
@@ -595,6 +876,9 @@ def run_mcp_server():
|
||||
description=args.get("description"),
|
||||
location=args.get("location"),
|
||||
all_day=args.get("all_day", False),
|
||||
category=args.get("category"),
|
||||
color_id=args.get("color_id"),
|
||||
check_conflicts_flag=args.get("check_conflicts", True),
|
||||
)
|
||||
elif tool_name == "calendar_update_event":
|
||||
data = update_event(
|
||||
@@ -605,6 +889,8 @@ def run_mcp_server():
|
||||
end_time=args.get("end_time"),
|
||||
description=args.get("description"),
|
||||
location=args.get("location"),
|
||||
category=args.get("category"),
|
||||
color_id=args.get("color_id"),
|
||||
)
|
||||
elif tool_name == "calendar_delete_event":
|
||||
data = delete_event(
|
||||
@@ -669,12 +955,20 @@ def run_cli():
|
||||
sp_list.add_argument("--query", "-q", help="Search text query")
|
||||
sp_list.add_argument("--limit", "-n", type=int, default=25, help="Max results")
|
||||
|
||||
# Conflicts
|
||||
sp_conf = subparsers.add_parser("conflicts", help="Check conflicts")
|
||||
sp_conf.add_argument("start_time", help="Start time")
|
||||
sp_conf.add_argument("--end", dest="end_time", help="End time")
|
||||
sp_conf.add_argument("--calendar", "-c", default="family")
|
||||
sp_conf.add_argument("--buffer", type=int, default=15, help="Buffer minutes")
|
||||
|
||||
# Create
|
||||
sp_create = subparsers.add_parser("create", help="Create event")
|
||||
sp_create.add_argument("summary", help="Event summary/title")
|
||||
sp_create.add_argument("start_time", help="Start time (ISO or YYYY-MM-DD)")
|
||||
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("--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")
|
||||
@@ -688,6 +982,13 @@ def run_cli():
|
||||
|
||||
if args.command == "test":
|
||||
print(json.dumps(test_connection(), indent=2, ensure_ascii=False))
|
||||
elif args.command == "conflicts":
|
||||
print(json.dumps(check_conflicts(
|
||||
calendar=args.calendar,
|
||||
start_time=args.start_time,
|
||||
end_time=args.end_time,
|
||||
buffer_minutes=args.buffer,
|
||||
), indent=2, ensure_ascii=False))
|
||||
elif args.command == "list":
|
||||
print(json.dumps(list_events(
|
||||
calendar=args.calendar,
|
||||
@@ -702,6 +1003,7 @@ def run_cli():
|
||||
summary=args.summary,
|
||||
start_time=args.start_time,
|
||||
end_time=args.end_time,
|
||||
category=args.category,
|
||||
description=args.description,
|
||||
location=args.location,
|
||||
all_day=args.all_day,
|
||||
|
||||
Reference in New Issue
Block a user