mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-28 23:52:09 -04:00
refactor(tools): extract calendar domain into src/tools/calendar.py
Repoints tests/test_caldav_bidirectional_sync.py source-introspection to src/tools/calendar.py (do_manage_calendar moved there).
This commit is contained in:
+2
-512
@@ -51,6 +51,8 @@ from src.tools.cookbook import ( # noqa: F401
|
||||
from src.tools.search import do_search_chats # noqa: F401
|
||||
# Notes domain extracted to src/tools/notes.py (slice 1, #4082/#4071).
|
||||
from src.tools.notes import do_manage_notes # noqa: F401
|
||||
# Calendar domain extracted to src/tools/calendar.py (slice 1, #4082/#4071).
|
||||
from src.tools.calendar import do_manage_calendar # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,518 +127,6 @@ def _parse_tool_args(content):
|
||||
return args
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calendar tool — CalDAV-backed event CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
||||
from datetime import datetime, timedelta
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
||||
from routes.calendar_routes import (
|
||||
_ensure_default_calendar,
|
||||
_parse_dt,
|
||||
_parse_dt_pair,
|
||||
parse_due_for_user,
|
||||
_resolve_base_uid,
|
||||
_push_caldav_event_after_commit,
|
||||
_record_caldav_delete_tombstone,
|
||||
)
|
||||
import uuid as _uuid
|
||||
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
# ── Batch normalization ──
|
||||
# Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]}
|
||||
# instead of individual create_event calls. Iterate and create each.
|
||||
if isinstance(args.get("events"), list) and not args.get("action"):
|
||||
results = []
|
||||
for ev in args["events"]:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
# Normalize start/end from {dateTime: "..."} object to flat string
|
||||
for field, target in [("start", "dtstart"), ("end", "dtend")]:
|
||||
val = ev.pop(field, None)
|
||||
if val and target not in ev:
|
||||
ev[target] = val.get("dateTime", val) if isinstance(val, dict) else val
|
||||
ev.setdefault("action", "create_event")
|
||||
r = await do_manage_calendar(json.dumps(ev), owner=owner)
|
||||
results.append(r)
|
||||
created = [r for r in results if r.get("exit_code") == 0 and not r.get("error")]
|
||||
failed = [r for r in results if r.get("error")]
|
||||
|
||||
if not results:
|
||||
return {"error": "No events to create", "exit_code": 1}
|
||||
|
||||
# Surface both successes and failures
|
||||
parts = []
|
||||
if created:
|
||||
summaries = [r.get("response", "") for r in created]
|
||||
parts.append(f"Created {len(created)} event(s):\n" + "\n".join(summaries))
|
||||
if failed:
|
||||
first_error = failed[0].get("error", "Unknown error")
|
||||
parts.append(f"Failed to create {len(failed)} event(s). First error: {first_error}")
|
||||
|
||||
response = "\n\n".join(parts)
|
||||
# Non-zero exit code for partial or total failure
|
||||
exit_code = 0 if not failed else 1
|
||||
return {"response": response, "exit_code": exit_code, "created_count": len(created), "failed_count": len(failed)}
|
||||
|
||||
# Normalize action — some models emit hyphens ("list-calendars") instead
|
||||
# of underscores. Treat them as equivalent so we don't bounce a
|
||||
# cosmetic typo back to the model and waste a round-trip. Also accept
|
||||
# short forms (`create`, `update`, `delete`) as aliases for the
|
||||
# full `<verb>_event` names — models keep emitting the short forms.
|
||||
action = (args.get("action") or "list_events").replace("-", "_").strip().lower()
|
||||
_ACTION_ALIASES = {
|
||||
"create": "create_event",
|
||||
"update": "update_event",
|
||||
"delete": "delete_event",
|
||||
"list": "list_events",
|
||||
}
|
||||
action = _ACTION_ALIASES.get(action, action)
|
||||
db = SessionLocal()
|
||||
|
||||
def _calendar_query():
|
||||
q = db.query(CalendarCal)
|
||||
if owner is not None:
|
||||
q = q.filter(CalendarCal.owner == owner)
|
||||
return q
|
||||
|
||||
def _event_query():
|
||||
q = db.query(CalendarEvent).join(CalendarCal)
|
||||
if owner is not None:
|
||||
q = q.filter(CalendarCal.owner == owner)
|
||||
return q
|
||||
|
||||
def _reminder_minutes(raw_args) -> Optional[int]:
|
||||
raw = (
|
||||
raw_args.get("reminder_minutes")
|
||||
or raw_args.get("remind_before_minutes")
|
||||
or raw_args.get("alarm_minutes")
|
||||
or raw_args.get("reminder")
|
||||
or raw_args.get("alarm")
|
||||
)
|
||||
if raw in (None, ""):
|
||||
desc = str(raw_args.get("description") or "")
|
||||
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
|
||||
raw = desc
|
||||
if raw in (None, "", False):
|
||||
return None
|
||||
if raw is True:
|
||||
return 10
|
||||
if isinstance(raw, (int, float)):
|
||||
return max(0, int(raw))
|
||||
text = str(raw).strip().lower()
|
||||
if text in {"none", "no", "off", "false"}:
|
||||
return None
|
||||
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)))
|
||||
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)) * 60)
|
||||
if text.isdigit():
|
||||
return max(0, int(text))
|
||||
return None
|
||||
|
||||
def _event_description(raw_args, minutes_before: Optional[int]) -> str:
|
||||
desc = str(raw_args.get("description", "") or "")
|
||||
if minutes_before is None:
|
||||
return desc
|
||||
reminder_only = re.compile(
|
||||
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
|
||||
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
|
||||
re.I,
|
||||
)
|
||||
return "" if reminder_only.match(desc) else desc
|
||||
|
||||
def _parse_event_dt(raw: str) -> tuple[datetime, bool]:
|
||||
"""Parse agent event datetimes in the user's timezone when available."""
|
||||
return _parse_dt_pair(parse_due_for_user(raw))
|
||||
|
||||
def _first_nonempty_arg(*names: str):
|
||||
for name in names:
|
||||
value = args.get(name)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
def _create_calendar_reminder(summary: str, location: str, dtstart: datetime,
|
||||
all_day: bool, minutes_before: int,
|
||||
is_utc: bool = False) -> tuple[Optional[str], Optional[str]]:
|
||||
remind_at = dtstart - timedelta(minutes=minutes_before)
|
||||
now = datetime.utcnow() if is_utc else datetime.now()
|
||||
if dtstart <= now:
|
||||
return None, "event already passed"
|
||||
if remind_at <= now:
|
||||
# If the requested "before" time already passed but the event is
|
||||
# still upcoming, create an immediate Note reminder instead of
|
||||
# silently dropping it.
|
||||
remind_at = now
|
||||
start_fmt = dtstart.strftime("%a %b %d") if all_day else dtstart.strftime("%a %b %d %H:%M")
|
||||
loc = f" @ {location}" if location else ""
|
||||
text = f"{summary}{loc} — {start_fmt}"
|
||||
due_date = remind_at.isoformat() + ("Z" if is_utc else "")
|
||||
expected_title = f"Reminder: {summary}"
|
||||
existing_q = db.query(Note).filter(
|
||||
Note.archived == False, # noqa: E712
|
||||
Note.due_date == due_date,
|
||||
)
|
||||
if owner is not None:
|
||||
existing_q = existing_q.filter(Note.owner == owner)
|
||||
target_title = re.sub(r"^\s*reminder\s*:\s*", "", expected_title.strip().lower())
|
||||
for existing in existing_q.limit(25).all():
|
||||
existing_title = re.sub(r"^\s*reminder\s*:\s*", "", (existing.title or "").strip().lower())
|
||||
if existing_title == target_title:
|
||||
return existing.id, "duplicate reminder already exists"
|
||||
note = Note(
|
||||
id=str(_uuid.uuid4()),
|
||||
owner=owner,
|
||||
title=expected_title,
|
||||
items=json.dumps([{"text": text, "done": False, "checked": False}]),
|
||||
note_type="todo",
|
||||
label="calendar",
|
||||
due_date=due_date,
|
||||
source="calendar",
|
||||
)
|
||||
db.add(note)
|
||||
return note.id, None
|
||||
|
||||
try:
|
||||
if action == "list_calendars":
|
||||
_ensure_default_calendar(db, owner)
|
||||
cals = _calendar_query().all()
|
||||
result = [{"name": c.name, "href": c.id} for c in cals]
|
||||
if result:
|
||||
lines = [f"Found {len(result)} calendar(s):"]
|
||||
for c in result:
|
||||
lines.append(f"- {c['name']} ({c['href'][:8]})")
|
||||
response_text = "\n".join(lines)
|
||||
else:
|
||||
response_text = "No calendars found."
|
||||
return {"response": response_text, "calendars": result, "exit_code": 0}
|
||||
|
||||
elif action == "list_events":
|
||||
try:
|
||||
start_raw = _first_nonempty_arg(
|
||||
"start", "start_date", "range_start", "from", "dtstart", "since"
|
||||
)
|
||||
end_raw = _first_nonempty_arg(
|
||||
"end", "end_date", "range_end", "to", "dtend", "until"
|
||||
)
|
||||
if start_raw:
|
||||
start_dt = _parse_dt(start_raw)
|
||||
else:
|
||||
start_dt = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end_raw:
|
||||
end_dt = _parse_dt(end_raw)
|
||||
else:
|
||||
end_dt = start_dt + timedelta(days=14)
|
||||
except ValueError as e:
|
||||
return {"error": f"Invalid date format: {e}", "exit_code": 1}
|
||||
|
||||
if end_dt <= start_dt:
|
||||
end_dt = start_dt + timedelta(days=1)
|
||||
|
||||
q = _event_query().filter(
|
||||
CalendarEvent.dtstart < end_dt,
|
||||
CalendarEvent.dtend > start_dt,
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
calendar_filter = args.get("calendar")
|
||||
if calendar_filter:
|
||||
q = q.filter(
|
||||
(CalendarEvent.calendar_id == calendar_filter) |
|
||||
(CalendarCal.name == calendar_filter)
|
||||
)
|
||||
rows = q.order_by(CalendarEvent.dtstart).all()
|
||||
events = []
|
||||
for ev in rows:
|
||||
if ev.all_day:
|
||||
s, e = ev.dtstart.strftime("%Y-%m-%d"), ev.dtend.strftime("%Y-%m-%d")
|
||||
else:
|
||||
suffix = "Z" if getattr(ev, "is_utc", False) else ""
|
||||
s, e = ev.dtstart.isoformat() + suffix, ev.dtend.isoformat() + suffix
|
||||
events.append({
|
||||
"uid": ev.uid, "summary": ev.summary or "", "dtstart": s, "dtend": e,
|
||||
"all_day": ev.all_day, "description": ev.description or "",
|
||||
"location": ev.location or "",
|
||||
"calendar": ev.calendar.name if ev.calendar else "",
|
||||
"calendar_href": ev.calendar_id,
|
||||
"event_type": ev.event_type or "",
|
||||
"importance": ev.importance or "normal",
|
||||
})
|
||||
if not events:
|
||||
response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}."
|
||||
else:
|
||||
lines = [f"Found {len(events)} event(s) between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}:"]
|
||||
for ev in events:
|
||||
when = ev["dtstart"]
|
||||
when_str = f"{when} (all day)" if ev.get("all_day") else f"{when} -> {ev.get('dtend', '')}"
|
||||
# Clickable anchor — opens the calendar on the event's day.
|
||||
line = f"- {when_str}: [{ev['summary']}](#event-{ev['uid']})"
|
||||
if ev.get("event_type"):
|
||||
line += f" #{ev['event_type']}"
|
||||
if ev.get("importance") and ev["importance"] != "normal":
|
||||
line += f" !{ev['importance']}"
|
||||
if ev.get("location"):
|
||||
line += f" @ {ev['location']}"
|
||||
if ev.get("calendar"):
|
||||
line += f" ({ev['calendar']})"
|
||||
if ev.get("description"):
|
||||
desc = ev["description"].strip().replace("\n", " ")
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + "..."
|
||||
line += f"\n {desc}"
|
||||
lines.append(line)
|
||||
response_text = "\n".join(lines)
|
||||
return {"response": response_text, "events": events, "exit_code": 0}
|
||||
|
||||
elif action == "create_event":
|
||||
summary = args.get("summary")
|
||||
# Accept the various names models like to use for the start
|
||||
# field: dtstart (canonical), start, start_time, when.
|
||||
dtstart_str = (args.get("dtstart") or args.get("start")
|
||||
or args.get("start_time") or args.get("when"))
|
||||
if not summary or not dtstart_str:
|
||||
return {"error": "summary and dtstart are required", "exit_code": 1}
|
||||
|
||||
# Accept either an href OR a calendar name/short-id like "Main"
|
||||
# or "62e545d8" — saves the model from having to memorize hrefs
|
||||
# after a `list_calendars` call returned short prefixes.
|
||||
cal_href = args.get("calendar_href") or args.get("calendar")
|
||||
cal = None
|
||||
if cal_href:
|
||||
cal = (_calendar_query()
|
||||
.filter(CalendarCal.id == cal_href)
|
||||
.first())
|
||||
if not cal:
|
||||
# Try by name (case-insensitive) or by short-id prefix
|
||||
cal = (_calendar_query()
|
||||
.filter(CalendarCal.name.ilike(cal_href))
|
||||
.first())
|
||||
if not cal:
|
||||
cal = (_calendar_query()
|
||||
.filter(CalendarCal.id.like(f"{cal_href}%"))
|
||||
.first())
|
||||
if not cal:
|
||||
cal = _ensure_default_calendar(db, owner)
|
||||
|
||||
all_day = bool(args.get("all_day", False))
|
||||
try:
|
||||
dtstart, dtstart_is_utc = _parse_event_dt(dtstart_str)
|
||||
except ValueError as e:
|
||||
return {"error": f"Could not parse dtstart {dtstart_str!r}: {e}", "exit_code": 1}
|
||||
dtend_raw = args.get("dtend") or args.get("end") or args.get("end_time")
|
||||
if dtend_raw:
|
||||
try:
|
||||
dtend, dtend_is_utc = _parse_event_dt(dtend_raw)
|
||||
dtstart_is_utc = dtstart_is_utc or dtend_is_utc
|
||||
except ValueError as e:
|
||||
return {"error": f"Could not parse dtend {dtend_raw!r}: {e}", "exit_code": 1}
|
||||
else:
|
||||
# Support duration: "1h", "30m", "90min", "1hr30m"
|
||||
dur = (args.get("duration") or "").strip().lower()
|
||||
delta = None
|
||||
if dur:
|
||||
import re as _re_d
|
||||
h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
|
||||
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
|
||||
if secs > 0:
|
||||
delta = timedelta(seconds=secs)
|
||||
if delta is not None:
|
||||
dtend = dtstart + delta
|
||||
elif all_day:
|
||||
dtend = dtstart + timedelta(days=1)
|
||||
else:
|
||||
dtend = dtstart + timedelta(hours=1)
|
||||
|
||||
# Dedup: if a non-cancelled event with the same title + start time already
|
||||
# exists, return its UID instead of creating a fresh copy. Prevents the
|
||||
# email triage from multiplying events when several emails reference the
|
||||
# same meeting. Compare case-insensitively since LLM-extracted titles
|
||||
# can vary in capitalisation.
|
||||
from sqlalchemy import func as _func
|
||||
existing = (
|
||||
_event_query()
|
||||
.filter(
|
||||
CalendarEvent.dtstart == dtstart,
|
||||
CalendarEvent.status != "cancelled",
|
||||
_func.lower(CalendarEvent.summary) == summary.lower(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
reminder_note_id = None
|
||||
reminder_skipped_reason = None
|
||||
minutes_before = _reminder_minutes(args)
|
||||
if minutes_before is not None:
|
||||
reminder_note_id, reminder_skipped_reason = _create_calendar_reminder(
|
||||
existing.summary or summary,
|
||||
existing.location or "",
|
||||
existing.dtstart,
|
||||
existing.all_day,
|
||||
minutes_before,
|
||||
bool(existing.is_utc),
|
||||
)
|
||||
if reminder_note_id:
|
||||
db.commit()
|
||||
reminder_text = ""
|
||||
if minutes_before is not None:
|
||||
reminder_text = (
|
||||
f"; reminder set {minutes_before} min before"
|
||||
if reminder_note_id
|
||||
else f"; reminder not set ({reminder_skipped_reason or 'reminder time already passed'})"
|
||||
)
|
||||
return {
|
||||
"response": (
|
||||
f"Event already exists: '{summary}' on {dtstart_str}"
|
||||
+ reminder_text
|
||||
),
|
||||
"uid": existing.uid,
|
||||
"reminder_note_id": reminder_note_id,
|
||||
"reminder_skipped_reason": reminder_skipped_reason,
|
||||
"duplicate": True,
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
# Optional tag/category and importance — friendly aliases.
|
||||
event_type = (args.get("event_type") or args.get("tag")
|
||||
or args.get("category") or args.get("type") or "") or None
|
||||
importance = args.get("importance") or "normal"
|
||||
minutes_before = _reminder_minutes(args)
|
||||
|
||||
uid = str(_uuid.uuid4())
|
||||
ev = CalendarEvent(
|
||||
uid=uid, calendar_id=cal.id, summary=summary,
|
||||
description=_event_description(args, minutes_before),
|
||||
location=args.get("location", "") or "",
|
||||
dtstart=dtstart, dtend=dtend, all_day=all_day,
|
||||
is_utc=dtstart_is_utc and not all_day,
|
||||
rrule=args.get("rrule", "") or "",
|
||||
event_type=event_type,
|
||||
importance=importance,
|
||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||
)
|
||||
db.add(ev)
|
||||
reminder_note_id = None
|
||||
reminder_skipped_reason = None
|
||||
if minutes_before is not None:
|
||||
reminder_note_id, reminder_skipped_reason = _create_calendar_reminder(
|
||||
summary,
|
||||
args.get("location", "") or "",
|
||||
dtstart,
|
||||
all_day,
|
||||
minutes_before,
|
||||
dtstart_is_utc and not all_day,
|
||||
)
|
||||
db.commit()
|
||||
if cal.source == "caldav":
|
||||
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||
tag_blurb = f" [{event_type}]" if event_type else ""
|
||||
if minutes_before is None:
|
||||
reminder_blurb = ""
|
||||
elif reminder_note_id:
|
||||
reminder_blurb = f" with reminder {minutes_before} min before"
|
||||
else:
|
||||
reminder_blurb = f" without reminder ({reminder_skipped_reason or 'reminder time already passed'})"
|
||||
# Return a clickable anchor so the agent can surface a link
|
||||
# that opens the calendar on that day. See the markdown
|
||||
# anchor convention ([Name](#event-<uid>)).
|
||||
return {
|
||||
"response": f"Created event [{summary}](#event-{uid}){tag_blurb} on {dtstart_str}{reminder_blurb}",
|
||||
"uid": uid,
|
||||
"anchor": f"[{summary}](#event-{uid})",
|
||||
"reminder_note_id": reminder_note_id,
|
||||
"reminder_skipped_reason": reminder_skipped_reason,
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
elif action == "update_event":
|
||||
uid = args.get("uid")
|
||||
if not uid:
|
||||
return {"error": "uid is required", "exit_code": 1}
|
||||
try:
|
||||
base_uid = _resolve_base_uid(uid)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
if not ev:
|
||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||
if args.get("summary") is not None:
|
||||
ev.summary = args["summary"]
|
||||
if args.get("description") is not None:
|
||||
ev.description = args["description"]
|
||||
if args.get("location") is not None:
|
||||
ev.location = args["location"]
|
||||
if args.get("dtstart") is not None:
|
||||
# Anchor naive/natural-language input to the USER's timezone and
|
||||
# refresh is_utc, exactly like create_event. Parsing with the
|
||||
# raw server-local _parse_dt here (and never touching is_utc)
|
||||
# silently shifted an updated event by the user's UTC offset.
|
||||
_eff_all_day = (
|
||||
args["all_day"] if args.get("all_day") is not None else ev.all_day
|
||||
)
|
||||
ev.dtstart, _su = _parse_event_dt(args["dtstart"])
|
||||
ev.is_utc = bool(_su and not _eff_all_day)
|
||||
if args.get("dtend") is not None:
|
||||
ev.dtend, _eu = _parse_event_dt(args["dtend"])
|
||||
if args.get("all_day") is not None:
|
||||
ev.all_day = args["all_day"]
|
||||
# Tag/category + importance updates (any of these aliases).
|
||||
_tag = (args.get("event_type") or args.get("tag")
|
||||
or args.get("category") or args.get("type"))
|
||||
if _tag is not None:
|
||||
ev.event_type = _tag or None
|
||||
if args.get("importance") is not None:
|
||||
ev.importance = args["importance"]
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
ev.caldav_sync_pending = "update"
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||
return {"response": f"Updated event {uid}", "exit_code": 0}
|
||||
|
||||
elif action == "delete_event":
|
||||
uid = args.get("uid")
|
||||
if not uid:
|
||||
return {"error": "uid is required", "exit_code": 1}
|
||||
try:
|
||||
base_uid = _resolve_base_uid(uid)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
if not ev:
|
||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
|
||||
if is_caldav:
|
||||
_record_caldav_delete_tombstone(db, ev, owner)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||
return {"response": f"Deleted event {uid}", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {
|
||||
"error": f"Unknown action: {action}. Use list_events, create_event, update_event, delete_event, list_calendars",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"manage_calendar error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── Cookbook tools ──
|
||||
|
||||
# In-process loopback base for agent tools that call Odysseus's own API
|
||||
|
||||
@@ -23,3 +23,4 @@ from src.tools.cookbook import ( # noqa: F401
|
||||
)
|
||||
from src.tools.search import do_search_chats # noqa: F401
|
||||
from src.tools.notes import do_manage_notes # noqa: F401
|
||||
from src.tools.calendar import do_manage_calendar # noqa: F401
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Calendar-domain tool implementations.
|
||||
|
||||
Extracted from tool_implementations.py as part of slice 1 (#4082/#4071).
|
||||
Holds the manage_calendar tool (CalDAV-backed event CRUD).
|
||||
``src.tool_implementations`` re-exports these for backward compatibility.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
|
||||
from src.tools._common import _parse_tool_args
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
||||
from datetime import datetime, timedelta
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
||||
from routes.calendar_routes import (
|
||||
_ensure_default_calendar,
|
||||
_parse_dt,
|
||||
_parse_dt_pair,
|
||||
parse_due_for_user,
|
||||
_resolve_base_uid,
|
||||
_push_caldav_event_after_commit,
|
||||
_record_caldav_delete_tombstone,
|
||||
)
|
||||
import uuid as _uuid
|
||||
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
# ── Batch normalization ──
|
||||
# Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]}
|
||||
# instead of individual create_event calls. Iterate and create each.
|
||||
if isinstance(args.get("events"), list) and not args.get("action"):
|
||||
results = []
|
||||
for ev in args["events"]:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
# Normalize start/end from {dateTime: "..."} object to flat string
|
||||
for field, target in [("start", "dtstart"), ("end", "dtend")]:
|
||||
val = ev.pop(field, None)
|
||||
if val and target not in ev:
|
||||
ev[target] = val.get("dateTime", val) if isinstance(val, dict) else val
|
||||
ev.setdefault("action", "create_event")
|
||||
r = await do_manage_calendar(json.dumps(ev), owner=owner)
|
||||
results.append(r)
|
||||
created = [r for r in results if r.get("exit_code") == 0 and not r.get("error")]
|
||||
failed = [r for r in results if r.get("error")]
|
||||
|
||||
if not results:
|
||||
return {"error": "No events to create", "exit_code": 1}
|
||||
|
||||
# Surface both successes and failures
|
||||
parts = []
|
||||
if created:
|
||||
summaries = [r.get("response", "") for r in created]
|
||||
parts.append(f"Created {len(created)} event(s):\n" + "\n".join(summaries))
|
||||
if failed:
|
||||
first_error = failed[0].get("error", "Unknown error")
|
||||
parts.append(f"Failed to create {len(failed)} event(s). First error: {first_error}")
|
||||
|
||||
response = "\n\n".join(parts)
|
||||
# Non-zero exit code for partial or total failure
|
||||
exit_code = 0 if not failed else 1
|
||||
return {"response": response, "exit_code": exit_code, "created_count": len(created), "failed_count": len(failed)}
|
||||
|
||||
# Normalize action — some models emit hyphens ("list-calendars") instead
|
||||
# of underscores. Treat them as equivalent so we don't bounce a
|
||||
# cosmetic typo back to the model and waste a round-trip. Also accept
|
||||
# short forms (`create`, `update`, `delete`) as aliases for the
|
||||
# full `<verb>_event` names — models keep emitting the short forms.
|
||||
action = (args.get("action") or "list_events").replace("-", "_").strip().lower()
|
||||
_ACTION_ALIASES = {
|
||||
"create": "create_event",
|
||||
"update": "update_event",
|
||||
"delete": "delete_event",
|
||||
"list": "list_events",
|
||||
}
|
||||
action = _ACTION_ALIASES.get(action, action)
|
||||
db = SessionLocal()
|
||||
|
||||
def _calendar_query():
|
||||
q = db.query(CalendarCal)
|
||||
if owner is not None:
|
||||
q = q.filter(CalendarCal.owner == owner)
|
||||
return q
|
||||
|
||||
def _event_query():
|
||||
q = db.query(CalendarEvent).join(CalendarCal)
|
||||
if owner is not None:
|
||||
q = q.filter(CalendarCal.owner == owner)
|
||||
return q
|
||||
|
||||
def _reminder_minutes(raw_args) -> Optional[int]:
|
||||
raw = (
|
||||
raw_args.get("reminder_minutes")
|
||||
or raw_args.get("remind_before_minutes")
|
||||
or raw_args.get("alarm_minutes")
|
||||
or raw_args.get("reminder")
|
||||
or raw_args.get("alarm")
|
||||
)
|
||||
if raw in (None, ""):
|
||||
desc = str(raw_args.get("description") or "")
|
||||
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
|
||||
raw = desc
|
||||
if raw in (None, "", False):
|
||||
return None
|
||||
if raw is True:
|
||||
return 10
|
||||
if isinstance(raw, (int, float)):
|
||||
return max(0, int(raw))
|
||||
text = str(raw).strip().lower()
|
||||
if text in {"none", "no", "off", "false"}:
|
||||
return None
|
||||
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)))
|
||||
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)) * 60)
|
||||
if text.isdigit():
|
||||
return max(0, int(text))
|
||||
return None
|
||||
|
||||
def _event_description(raw_args, minutes_before: Optional[int]) -> str:
|
||||
desc = str(raw_args.get("description", "") or "")
|
||||
if minutes_before is None:
|
||||
return desc
|
||||
reminder_only = re.compile(
|
||||
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
|
||||
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
|
||||
re.I,
|
||||
)
|
||||
return "" if reminder_only.match(desc) else desc
|
||||
|
||||
def _parse_event_dt(raw: str) -> tuple[datetime, bool]:
|
||||
"""Parse agent event datetimes in the user's timezone when available."""
|
||||
return _parse_dt_pair(parse_due_for_user(raw))
|
||||
|
||||
def _first_nonempty_arg(*names: str):
|
||||
for name in names:
|
||||
value = args.get(name)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return None
|
||||
|
||||
def _create_calendar_reminder(summary: str, location: str, dtstart: datetime,
|
||||
all_day: bool, minutes_before: int,
|
||||
is_utc: bool = False) -> tuple[Optional[str], Optional[str]]:
|
||||
remind_at = dtstart - timedelta(minutes=minutes_before)
|
||||
now = datetime.utcnow() if is_utc else datetime.now()
|
||||
if dtstart <= now:
|
||||
return None, "event already passed"
|
||||
if remind_at <= now:
|
||||
# If the requested "before" time already passed but the event is
|
||||
# still upcoming, create an immediate Note reminder instead of
|
||||
# silently dropping it.
|
||||
remind_at = now
|
||||
start_fmt = dtstart.strftime("%a %b %d") if all_day else dtstart.strftime("%a %b %d %H:%M")
|
||||
loc = f" @ {location}" if location else ""
|
||||
text = f"{summary}{loc} — {start_fmt}"
|
||||
due_date = remind_at.isoformat() + ("Z" if is_utc else "")
|
||||
expected_title = f"Reminder: {summary}"
|
||||
existing_q = db.query(Note).filter(
|
||||
Note.archived == False, # noqa: E712
|
||||
Note.due_date == due_date,
|
||||
)
|
||||
if owner is not None:
|
||||
existing_q = existing_q.filter(Note.owner == owner)
|
||||
target_title = re.sub(r"^\s*reminder\s*:\s*", "", expected_title.strip().lower())
|
||||
for existing in existing_q.limit(25).all():
|
||||
existing_title = re.sub(r"^\s*reminder\s*:\s*", "", (existing.title or "").strip().lower())
|
||||
if existing_title == target_title:
|
||||
return existing.id, "duplicate reminder already exists"
|
||||
note = Note(
|
||||
id=str(_uuid.uuid4()),
|
||||
owner=owner,
|
||||
title=expected_title,
|
||||
items=json.dumps([{"text": text, "done": False, "checked": False}]),
|
||||
note_type="todo",
|
||||
label="calendar",
|
||||
due_date=due_date,
|
||||
source="calendar",
|
||||
)
|
||||
db.add(note)
|
||||
return note.id, None
|
||||
|
||||
try:
|
||||
if action == "list_calendars":
|
||||
_ensure_default_calendar(db, owner)
|
||||
cals = _calendar_query().all()
|
||||
result = [{"name": c.name, "href": c.id} for c in cals]
|
||||
if result:
|
||||
lines = [f"Found {len(result)} calendar(s):"]
|
||||
for c in result:
|
||||
lines.append(f"- {c['name']} ({c['href'][:8]})")
|
||||
response_text = "\n".join(lines)
|
||||
else:
|
||||
response_text = "No calendars found."
|
||||
return {"response": response_text, "calendars": result, "exit_code": 0}
|
||||
|
||||
elif action == "list_events":
|
||||
try:
|
||||
start_raw = _first_nonempty_arg(
|
||||
"start", "start_date", "range_start", "from", "dtstart", "since"
|
||||
)
|
||||
end_raw = _first_nonempty_arg(
|
||||
"end", "end_date", "range_end", "to", "dtend", "until"
|
||||
)
|
||||
if start_raw:
|
||||
start_dt = _parse_dt(start_raw)
|
||||
else:
|
||||
start_dt = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if end_raw:
|
||||
end_dt = _parse_dt(end_raw)
|
||||
else:
|
||||
end_dt = start_dt + timedelta(days=14)
|
||||
except ValueError as e:
|
||||
return {"error": f"Invalid date format: {e}", "exit_code": 1}
|
||||
|
||||
if end_dt <= start_dt:
|
||||
end_dt = start_dt + timedelta(days=1)
|
||||
|
||||
q = _event_query().filter(
|
||||
CalendarEvent.dtstart < end_dt,
|
||||
CalendarEvent.dtend > start_dt,
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
calendar_filter = args.get("calendar")
|
||||
if calendar_filter:
|
||||
q = q.filter(
|
||||
(CalendarEvent.calendar_id == calendar_filter) |
|
||||
(CalendarCal.name == calendar_filter)
|
||||
)
|
||||
rows = q.order_by(CalendarEvent.dtstart).all()
|
||||
events = []
|
||||
for ev in rows:
|
||||
if ev.all_day:
|
||||
s, e = ev.dtstart.strftime("%Y-%m-%d"), ev.dtend.strftime("%Y-%m-%d")
|
||||
else:
|
||||
suffix = "Z" if getattr(ev, "is_utc", False) else ""
|
||||
s, e = ev.dtstart.isoformat() + suffix, ev.dtend.isoformat() + suffix
|
||||
events.append({
|
||||
"uid": ev.uid, "summary": ev.summary or "", "dtstart": s, "dtend": e,
|
||||
"all_day": ev.all_day, "description": ev.description or "",
|
||||
"location": ev.location or "",
|
||||
"calendar": ev.calendar.name if ev.calendar else "",
|
||||
"calendar_href": ev.calendar_id,
|
||||
"event_type": ev.event_type or "",
|
||||
"importance": ev.importance or "normal",
|
||||
})
|
||||
if not events:
|
||||
response_text = f"No events between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}."
|
||||
else:
|
||||
lines = [f"Found {len(events)} event(s) between {start_dt.date().isoformat()} and {end_dt.date().isoformat()}:"]
|
||||
for ev in events:
|
||||
when = ev["dtstart"]
|
||||
when_str = f"{when} (all day)" if ev.get("all_day") else f"{when} -> {ev.get('dtend', '')}"
|
||||
# Clickable anchor — opens the calendar on the event's day.
|
||||
line = f"- {when_str}: [{ev['summary']}](#event-{ev['uid']})"
|
||||
if ev.get("event_type"):
|
||||
line += f" #{ev['event_type']}"
|
||||
if ev.get("importance") and ev["importance"] != "normal":
|
||||
line += f" !{ev['importance']}"
|
||||
if ev.get("location"):
|
||||
line += f" @ {ev['location']}"
|
||||
if ev.get("calendar"):
|
||||
line += f" ({ev['calendar']})"
|
||||
if ev.get("description"):
|
||||
desc = ev["description"].strip().replace("\n", " ")
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + "..."
|
||||
line += f"\n {desc}"
|
||||
lines.append(line)
|
||||
response_text = "\n".join(lines)
|
||||
return {"response": response_text, "events": events, "exit_code": 0}
|
||||
|
||||
elif action == "create_event":
|
||||
summary = args.get("summary")
|
||||
# Accept the various names models like to use for the start
|
||||
# field: dtstart (canonical), start, start_time, when.
|
||||
dtstart_str = (args.get("dtstart") or args.get("start")
|
||||
or args.get("start_time") or args.get("when"))
|
||||
if not summary or not dtstart_str:
|
||||
return {"error": "summary and dtstart are required", "exit_code": 1}
|
||||
|
||||
# Accept either an href OR a calendar name/short-id like "Main"
|
||||
# or "62e545d8" — saves the model from having to memorize hrefs
|
||||
# after a `list_calendars` call returned short prefixes.
|
||||
cal_href = args.get("calendar_href") or args.get("calendar")
|
||||
cal = None
|
||||
if cal_href:
|
||||
cal = (_calendar_query()
|
||||
.filter(CalendarCal.id == cal_href)
|
||||
.first())
|
||||
if not cal:
|
||||
# Try by name (case-insensitive) or by short-id prefix
|
||||
cal = (_calendar_query()
|
||||
.filter(CalendarCal.name.ilike(cal_href))
|
||||
.first())
|
||||
if not cal:
|
||||
cal = (_calendar_query()
|
||||
.filter(CalendarCal.id.like(f"{cal_href}%"))
|
||||
.first())
|
||||
if not cal:
|
||||
cal = _ensure_default_calendar(db, owner)
|
||||
|
||||
all_day = bool(args.get("all_day", False))
|
||||
try:
|
||||
dtstart, dtstart_is_utc = _parse_event_dt(dtstart_str)
|
||||
except ValueError as e:
|
||||
return {"error": f"Could not parse dtstart {dtstart_str!r}: {e}", "exit_code": 1}
|
||||
dtend_raw = args.get("dtend") or args.get("end") or args.get("end_time")
|
||||
if dtend_raw:
|
||||
try:
|
||||
dtend, dtend_is_utc = _parse_event_dt(dtend_raw)
|
||||
dtstart_is_utc = dtstart_is_utc or dtend_is_utc
|
||||
except ValueError as e:
|
||||
return {"error": f"Could not parse dtend {dtend_raw!r}: {e}", "exit_code": 1}
|
||||
else:
|
||||
# Support duration: "1h", "30m", "90min", "1hr30m"
|
||||
dur = (args.get("duration") or "").strip().lower()
|
||||
delta = None
|
||||
if dur:
|
||||
import re as _re_d
|
||||
h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
|
||||
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
|
||||
if secs > 0:
|
||||
delta = timedelta(seconds=secs)
|
||||
if delta is not None:
|
||||
dtend = dtstart + delta
|
||||
elif all_day:
|
||||
dtend = dtstart + timedelta(days=1)
|
||||
else:
|
||||
dtend = dtstart + timedelta(hours=1)
|
||||
|
||||
# Dedup: if a non-cancelled event with the same title + start time already
|
||||
# exists, return its UID instead of creating a fresh copy. Prevents the
|
||||
# email triage from multiplying events when several emails reference the
|
||||
# same meeting. Compare case-insensitively since LLM-extracted titles
|
||||
# can vary in capitalisation.
|
||||
from sqlalchemy import func as _func
|
||||
existing = (
|
||||
_event_query()
|
||||
.filter(
|
||||
CalendarEvent.dtstart == dtstart,
|
||||
CalendarEvent.status != "cancelled",
|
||||
_func.lower(CalendarEvent.summary) == summary.lower(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
reminder_note_id = None
|
||||
reminder_skipped_reason = None
|
||||
minutes_before = _reminder_minutes(args)
|
||||
if minutes_before is not None:
|
||||
reminder_note_id, reminder_skipped_reason = _create_calendar_reminder(
|
||||
existing.summary or summary,
|
||||
existing.location or "",
|
||||
existing.dtstart,
|
||||
existing.all_day,
|
||||
minutes_before,
|
||||
bool(existing.is_utc),
|
||||
)
|
||||
if reminder_note_id:
|
||||
db.commit()
|
||||
reminder_text = ""
|
||||
if minutes_before is not None:
|
||||
reminder_text = (
|
||||
f"; reminder set {minutes_before} min before"
|
||||
if reminder_note_id
|
||||
else f"; reminder not set ({reminder_skipped_reason or 'reminder time already passed'})"
|
||||
)
|
||||
return {
|
||||
"response": (
|
||||
f"Event already exists: '{summary}' on {dtstart_str}"
|
||||
+ reminder_text
|
||||
),
|
||||
"uid": existing.uid,
|
||||
"reminder_note_id": reminder_note_id,
|
||||
"reminder_skipped_reason": reminder_skipped_reason,
|
||||
"duplicate": True,
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
# Optional tag/category and importance — friendly aliases.
|
||||
event_type = (args.get("event_type") or args.get("tag")
|
||||
or args.get("category") or args.get("type") or "") or None
|
||||
importance = args.get("importance") or "normal"
|
||||
minutes_before = _reminder_minutes(args)
|
||||
|
||||
uid = str(_uuid.uuid4())
|
||||
ev = CalendarEvent(
|
||||
uid=uid, calendar_id=cal.id, summary=summary,
|
||||
description=_event_description(args, minutes_before),
|
||||
location=args.get("location", "") or "",
|
||||
dtstart=dtstart, dtend=dtend, all_day=all_day,
|
||||
is_utc=dtstart_is_utc and not all_day,
|
||||
rrule=args.get("rrule", "") or "",
|
||||
event_type=event_type,
|
||||
importance=importance,
|
||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||
)
|
||||
db.add(ev)
|
||||
reminder_note_id = None
|
||||
reminder_skipped_reason = None
|
||||
if minutes_before is not None:
|
||||
reminder_note_id, reminder_skipped_reason = _create_calendar_reminder(
|
||||
summary,
|
||||
args.get("location", "") or "",
|
||||
dtstart,
|
||||
all_day,
|
||||
minutes_before,
|
||||
dtstart_is_utc and not all_day,
|
||||
)
|
||||
db.commit()
|
||||
if cal.source == "caldav":
|
||||
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||
tag_blurb = f" [{event_type}]" if event_type else ""
|
||||
if minutes_before is None:
|
||||
reminder_blurb = ""
|
||||
elif reminder_note_id:
|
||||
reminder_blurb = f" with reminder {minutes_before} min before"
|
||||
else:
|
||||
reminder_blurb = f" without reminder ({reminder_skipped_reason or 'reminder time already passed'})"
|
||||
# Return a clickable anchor so the agent can surface a link
|
||||
# that opens the calendar on that day. See the markdown
|
||||
# anchor convention ([Name](#event-<uid>)).
|
||||
return {
|
||||
"response": f"Created event [{summary}](#event-{uid}){tag_blurb} on {dtstart_str}{reminder_blurb}",
|
||||
"uid": uid,
|
||||
"anchor": f"[{summary}](#event-{uid})",
|
||||
"reminder_note_id": reminder_note_id,
|
||||
"reminder_skipped_reason": reminder_skipped_reason,
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
elif action == "update_event":
|
||||
uid = args.get("uid")
|
||||
if not uid:
|
||||
return {"error": "uid is required", "exit_code": 1}
|
||||
try:
|
||||
base_uid = _resolve_base_uid(uid)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
if not ev:
|
||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||
if args.get("summary") is not None:
|
||||
ev.summary = args["summary"]
|
||||
if args.get("description") is not None:
|
||||
ev.description = args["description"]
|
||||
if args.get("location") is not None:
|
||||
ev.location = args["location"]
|
||||
if args.get("dtstart") is not None:
|
||||
# Anchor naive/natural-language input to the USER's timezone and
|
||||
# refresh is_utc, exactly like create_event. Parsing with the
|
||||
# raw server-local _parse_dt here (and never touching is_utc)
|
||||
# silently shifted an updated event by the user's UTC offset.
|
||||
_eff_all_day = (
|
||||
args["all_day"] if args.get("all_day") is not None else ev.all_day
|
||||
)
|
||||
ev.dtstart, _su = _parse_event_dt(args["dtstart"])
|
||||
ev.is_utc = bool(_su and not _eff_all_day)
|
||||
if args.get("dtend") is not None:
|
||||
ev.dtend, _eu = _parse_event_dt(args["dtend"])
|
||||
if args.get("all_day") is not None:
|
||||
ev.all_day = args["all_day"]
|
||||
# Tag/category + importance updates (any of these aliases).
|
||||
_tag = (args.get("event_type") or args.get("tag")
|
||||
or args.get("category") or args.get("type"))
|
||||
if _tag is not None:
|
||||
ev.event_type = _tag or None
|
||||
if args.get("importance") is not None:
|
||||
ev.importance = args["importance"]
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||
if is_caldav:
|
||||
ev.caldav_sync_pending = "update"
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||
return {"response": f"Updated event {uid}", "exit_code": 0}
|
||||
|
||||
elif action == "delete_event":
|
||||
uid = args.get("uid")
|
||||
if not uid:
|
||||
return {"error": "uid is required", "exit_code": 1}
|
||||
try:
|
||||
base_uid = _resolve_base_uid(uid)
|
||||
except ValueError as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||
if not ev:
|
||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
|
||||
if is_caldav:
|
||||
_record_caldav_delete_tombstone(db, ev, owner)
|
||||
db.delete(ev)
|
||||
db.commit()
|
||||
if is_caldav:
|
||||
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||
return {"response": f"Deleted event {uid}", "exit_code": 0}
|
||||
|
||||
else:
|
||||
return {
|
||||
"error": f"Unknown action: {action}. Use list_events, create_event, update_event, delete_event, list_calendars",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"manage_calendar error: {e}")
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -53,7 +53,7 @@ def test_http_calendar_writes_mark_pending_and_push_after_commit():
|
||||
|
||||
|
||||
def test_agent_calendar_writes_share_caldav_push_path():
|
||||
source = Path("src/tool_implementations.py").read_text()
|
||||
source = Path("src/tools/calendar.py").read_text()
|
||||
|
||||
assert "_push_caldav_event_after_commit" in source
|
||||
assert 'caldav_sync_pending="create" if cal.source == "caldav" else None' in source
|
||||
|
||||
Reference in New Issue
Block a user