"""Calendar and event tools.""" from __future__ import annotations from datetime import datetime, timezone from fabledassistant.services.events import ( create_event as events_create_event, delete_event as events_delete_event, find_events_by_query, list_events as events_list_events, search_events as events_search_events, update_event as events_update_event, ) from fabledassistant.services.tools._helpers import resolve_project from fabledassistant.services.tools._registry import tool @tool( name="create_event", description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event.", parameters={ "title": {"type": "string", "description": "A descriptive event title"}, "start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)"}, "end": {"type": "string", "description": "Optional end date or datetime"}, "duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"}, "description": {"type": "string", "description": "Optional event description"}, "location": {"type": "string", "description": "Optional event location"}, "color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"}, "all_day": {"type": "boolean", "description": "True for all-day events (birthdays, holidays, deadlines)"}, "recurrence": {"type": "string", "description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)"}, "reminder_minutes": {"type": "integer", "description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)"}, "attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"}, "calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."}, "project": {"type": "string", "description": "Optional project name to associate this event with"}, }, required=["title", "start"], ) async def create_event_tool(*, user_id, arguments, **_ctx): start_str = arguments["start"] end_str = arguments.get("end") all_day = arguments.get("all_day", False) if "T" not in start_str and " " not in start_str: all_day = True start_str = f"{start_str}T00:00:00" try: start_dt = datetime.fromisoformat(start_str) if start_dt.tzinfo is None: start_dt = start_dt.replace(tzinfo=timezone.utc) except (ValueError, TypeError): return {"success": False, "error": f"Invalid start datetime: {start_str!r}"} end_dt = None if end_str: if "T" not in end_str and " " not in end_str: end_str = f"{end_str}T00:00:00" try: end_dt = datetime.fromisoformat(end_str) if end_dt.tzinfo is None: end_dt = end_dt.replace(tzinfo=timezone.utc) except (ValueError, TypeError): return {"success": False, "error": f"Invalid end datetime: {end_str!r}"} project_id = None project_name = arguments.get("project") if project_name: proj = await resolve_project(user_id, project_name) if proj: project_id = proj.id event = await events_create_event( user_id=user_id, title=arguments.get("title", "Untitled Event"), start_dt=start_dt, end_dt=end_dt, all_day=all_day, description=arguments.get("description") or "", location=arguments.get("location") or "", color=arguments.get("color") or "", recurrence=arguments.get("recurrence"), project_id=project_id, duration=arguments.get("duration"), reminder_minutes=arguments.get("reminder_minutes"), attendees=arguments.get("attendees"), calendar_name=arguments.get("calendar_name"), ) return {"success": True, "type": "event", "data": event.to_dict()} @tool( name="list_events", description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Always use full-day UTC ranges: date_from at T00:00:00Z and date_to at T23:59:59Z for the days of interest so events stored in UTC are not missed.", parameters={ "date_from": {"type": "string", "description": "Start of range in ISO 8601 UTC format (e.g. 2025-01-15T00:00:00Z). Use T00:00:00Z for the start of the day."}, "date_to": {"type": "string", "description": "End of range in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day."}, }, required=["date_from", "date_to"], read_only=True, briefing=True, ) async def list_events_tool(*, user_id, arguments, **_ctx): try: date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00")) date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00")) except (ValueError, TypeError, KeyError) as exc: return {"success": False, "error": f"Invalid date range: {exc}"} if date_from.tzinfo is None: date_from = date_from.replace(tzinfo=timezone.utc) if date_to.tzinfo is None: date_to = date_to.replace(tzinfo=timezone.utc) events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to) return { "success": True, "type": "events", "data": {"count": len(events), "events": events}, } @tool( name="search_events", description="Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.", parameters={ "query": {"type": "string", "description": "Search keyword to match against event titles, locations, and descriptions"}, "include_past": {"type": "boolean", "description": "Set to true to include past events in results (default: future events only)"}, }, required=["query"], read_only=True, briefing=True, ) async def search_events_tool(*, user_id, arguments, **_ctx): events = await events_search_events( user_id=user_id, query=arguments.get("query", ""), include_past=arguments.get("include_past", False), ) return { "success": True, "type": "events", "data": { "query": arguments.get("query", ""), "count": len(events), "events": [e.to_dict() for e in events], }, } @tool( name="update_event", description="Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.", parameters={ "query": {"type": "string", "description": "Search term to find the event to update (matches against title)"}, "title": {"type": "string", "description": "New title for the event"}, "start": {"type": "string", "description": "New start datetime in ISO 8601 format"}, "end": {"type": "string", "description": "New end datetime in ISO 8601 format"}, "all_day": {"type": "boolean", "description": "Whether the event is all-day"}, "description": {"type": "string", "description": "New event description"}, "location": {"type": "string", "description": "New event location"}, "color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"}, "recurrence": {"type": "string", "description": "New iCalendar RRULE"}, "reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."}, }, required=["query"], ) async def update_event_tool(*, user_id, arguments, **_ctx): query = arguments.get("query", "") matches = await find_events_by_query(user_id=user_id, query=query) if not matches: return {"success": False, "error": f"No event found matching '{query}'."} event_to_update = matches[0] fields: dict = {} for str_field in ("title", "description", "location", "color", "recurrence"): if arguments.get(str_field) is not None: fields[str_field] = arguments[str_field] if arguments.get("all_day") is not None: fields["all_day"] = arguments["all_day"] if "reminder_minutes" in arguments: rm = arguments["reminder_minutes"] fields["reminder_minutes"] = None if rm == 0 else rm for dt_field, key in (("start_dt", "start"), ("end_dt", "end")): val = arguments.get(key) if val: try: dt = datetime.fromisoformat(val.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) fields[dt_field] = dt except (ValueError, TypeError): return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"} updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields) if updated is None: return {"success": False, "error": "Event not found or update failed."} return {"success": True, "type": "event_updated", "data": updated.to_dict()} @tool( name="delete_event", description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.", parameters={ "query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"}, }, required=["query"], ) async def delete_event_tool(*, user_id, arguments, **_ctx): query = arguments.get("query", "") matches = await find_events_by_query(user_id=user_id, query=query) if not matches: return {"success": False, "error": f"No event found matching '{query}'."} event_to_delete = matches[0] await events_delete_event(user_id=user_id, event_id=event_to_delete.id) return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}} @tool( name="list_calendars", description="List all available calendars. Use this when the user asks which calendars they have.", parameters={}, read_only=True, requires="caldav", ) async def list_calendars_tool(*, user_id, arguments, **_ctx): from fabledassistant.services.caldav import list_calendars calendars = await list_calendars(user_id=user_id) return { "success": True, "type": "calendars", "data": {"count": len(calendars), "calendars": calendars}, }