diff --git a/src/services/adk/tool_builder.py b/src/services/adk/tool_builder.py index d222e29..9e3a069 100644 --- a/src/services/adk/tool_builder.py +++ b/src/services/adk/tool_builder.py @@ -507,6 +507,7 @@ def build_tools( create_check_availability_tool, create_calendar_event_tool, create_cancel_event_tool, + create_edit_event_tool, ) # Use agent_id from parameter if available, otherwise try to get from config @@ -545,8 +546,18 @@ def build_tools( ) ) + # Add edit_event tool with configs from agent.config.integrations + self.tools.append( + create_edit_event_tool( + agent_id=effective_agent_id, + calendar_config=google_calendar_config, + credentials_config=google_calendar_credentials, + db=db + ) + ) + logger.info( - f"Added Google Calendar tools (check_availability, create_event, cancel_event) " + f"Added Google Calendar tools (check_availability, create_event, cancel_event, edit_event) " f"for agent {effective_agent_id}" ) except Exception as e: diff --git a/src/services/adk/tools/google_calendar/__init__.py b/src/services/adk/tools/google_calendar/__init__.py index 89b269a..138ad2d 100644 --- a/src/services/adk/tools/google_calendar/__init__.py +++ b/src/services/adk/tools/google_calendar/__init__.py @@ -3,9 +3,11 @@ from .check_availability import create_check_availability_tool from .create_event import create_calendar_event_tool from .cancel_event import create_cancel_event_tool +from .edit_event import create_edit_event_tool __all__ = [ "create_check_availability_tool", "create_calendar_event_tool", "create_cancel_event_tool", + "create_edit_event_tool", ] diff --git a/src/services/adk/tools/google_calendar/edit_event.py b/src/services/adk/tools/google_calendar/edit_event.py new file mode 100644 index 0000000..f903170 --- /dev/null +++ b/src/services/adk/tools/google_calendar/edit_event.py @@ -0,0 +1,285 @@ +"""Google Calendar event edit / reschedule (update) tool.""" + +from typing import Optional, Dict, Any +from datetime import datetime, timedelta +from google.adk.tools import FunctionTool, ToolContext +from googleapiclient.errors import HttpError +import traceback + +from .base import GoogleCalendarClient +from src.utils.logger import setup_logger + +logger = setup_logger(__name__) + + +def _credentials_have_secrets(creds: Optional[Dict[str, Any]]) -> bool: + """Check whether credentials contain the fields required to refresh the token.""" + if not creds: + return False + return bool( + creds.get("refresh_token") + and creds.get("client_id") + and creds.get("client_secret") + ) + + +def _load_full_credentials_from_db(agent_id: str) -> Optional[Dict[str, Any]]: + """ + Load the full (unsanitized) Google Calendar credentials for an agent directly + from the database (the credentials that reach the tool via agent.config.integrations + are sanitized and lack refresh_token/client_id/client_secret). + """ + import os + + dsn = os.environ.get("POSTGRES_CONNECTION_STRING") + if not dsn or not agent_id: + return None + + try: + import psycopg2 + + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor() + cur.execute( + "SELECT config FROM evo_core_agent_integrations " + "WHERE agent_id = %s AND provider = %s LIMIT 1", + (str(agent_id), "google_calendar_credentials"), + ) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + except Exception as e: + logger.error(f"Failed to load full Google Calendar credentials from DB: {e}") + return None + + +def _event_label(event: Dict[str, Any]) -> Dict[str, Any]: + """Compact, human-friendly representation of an event.""" + start = event.get("start", {}) + end = event.get("end", {}) + return { + "id": event.get("id"), + "summary": event.get("summary", "(sem título)"), + "start": start.get("dateTime") or start.get("date"), + "end": end.get("dateTime") or end.get("date"), + } + + +def create_edit_event_tool( + agent_id: Optional[str] = None, + calendar_config: Optional[Dict[str, Any]] = None, + credentials_config: Optional[Dict[str, Any]] = None, + db=None, +) -> FunctionTool: + """ + Create a tool for editing / rescheduling an existing Google Calendar event. + + Returns: + FunctionTool for updating calendar events + """ + client = GoogleCalendarClient(db=db) + + config = calendar_config.get("settings", calendar_config) if calendar_config else {} + timezone = config.get("timezone", "America/Sao_Paulo") + + async def edit_calendar_event( + start_date: str, + end_date: str, + new_start_date: Optional[str] = None, + new_end_date: Optional[str] = None, + new_title: Optional[str] = None, + new_description: Optional[str] = None, + title: Optional[str] = None, + calendar_id: str = "primary", + event_id: Optional[str] = None, + tool_context: Optional[ToolContext] = None, + ) -> Dict[str, Any]: + """ + Edit / reschedule an existing Google Calendar meeting. + + Use this tool when a customer wants to CHANGE a meeting they already booked + (move it to another time, or change its title/description). + + How it works: + - Locate the existing meeting via the time range (start_date/end_date), optionally + filtered by title, or directly by event_id. + - Apply only the fields provided: new_start_date/new_end_date (reschedule), + new_title, new_description. + - If only new_start_date is given (no new_end_date), the original duration is kept. + - If several meetings match, the list is returned so you can ask which one to edit. + + Args: + start_date: Start of the search range where the CURRENT meeting is (ISO format) + end_date: End of the search range where the CURRENT meeting is (ISO format) + new_start_date: New start date/time (ISO) to move the meeting to + new_end_date: New end date/time (ISO); if omitted, keeps the original duration + new_title: New title for the meeting + new_description: New description for the meeting + title: Optional text to match against the current title (case-insensitive) + calendar_id: Which calendar to use + event_id: Optional exact event id (skips the search) + tool_context: Tool execution context + """ + try: + effective_agent_id = agent_id + if not effective_agent_id: + return {"status": "error", "message": "Agent ID is required but was not provided"} + if not calendar_config: + return {"status": "error", "message": "Google Calendar integration not configured for this agent"} + if not credentials_config: + return {"status": "error", "message": "Google Calendar credentials not configured for this agent"} + + if not any([new_start_date, new_end_date, new_title, new_description]): + return { + "status": "error", + "message": "Informe ao menos uma alteração (novo horário, novo título ou nova descrição).", + } + + # Load full credentials from DB when the ones passed are sanitized. + effective_credentials = credentials_config + if not _credentials_have_secrets(effective_credentials): + full_creds = _load_full_credentials_from_db(effective_agent_id) + if _credentials_have_secrets(full_creds): + effective_credentials = full_creds + logger.info("Loaded full Google Calendar credentials from database") + else: + return { + "status": "error", + "message": "Google Calendar credentials are incomplete (missing OAuth secrets)", + } + + # EVO-2171: operate on the calendar the user picked in the UI + # (settings.selectedCalendarId) — the same one create_event/check_availability + # use — so edit locates and patches the event on the correct calendar. Fall + # back to the tool arg / primary when no calendar is configured. + _cfg = calendar_config.get("settings", calendar_config) if calendar_config else {} + resolved_calendar_id = (_cfg.get("selectedCalendarId") or "").strip() or calendar_id or "primary" + + service = client.get_calendar_service(effective_credentials) + + # ---- locate the target event ---- + target = None + if event_id: + try: + target = service.events().get(calendarId=resolved_calendar_id, eventId=event_id).execute() + except HttpError as e: + return {"status": "error", "message": f"Google Calendar API error: {str(e)}"} + else: + try: + start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00")) + except ValueError as e: + return { + "status": "error", + "message": f"Invalid date format: {str(e)}. Use ISO format like '2026-07-20T09:00:00'", + } + if end_dt <= start_dt: + end_dt = start_dt.replace(hour=23, minute=59, second=59, microsecond=0) + + logger.info( + f"Searching event to edit from {start_dt} to {end_dt} " + f"(title={title!r}) in calendar {resolved_calendar_id}" + ) + result = await client.check_availability(effective_credentials, start_dt, end_dt, resolved_calendar_id) + if result.get("status") == "error": + return result + events = result.get("events", []) + if title: + needle = title.strip().lower() + events = [e for e in events if needle in (e.get("summary", "").lower())] + + if not events: + return {"status": "error", "message": "Nenhuma reunião encontrada no período informado para editar."} + if len(events) > 1: + return { + "status": "needs_clarification", + "message": ( + f"Encontrei {len(events)} reuniões nesse período. Pergunte ao cliente qual " + f"editar e use o horário exato (ou o event_id) desta lista." + ), + "events": [_event_label(e) for e in events], + } + target = events[0] + + target_id = target.get("id") + + # ---- build the patch body (only changed fields) ---- + body: Dict[str, Any] = {} + if new_title: + body["summary"] = new_title + if new_description is not None: + body["description"] = new_description + + # Reschedule handling + def _parse(dt_str: str) -> datetime: + return datetime.fromisoformat(dt_str.replace("Z", "+00:00")) + + if new_start_date: + body["start"] = {"dateTime": _parse(new_start_date).isoformat(), "timeZone": timezone} + if new_end_date: + body["end"] = {"dateTime": _parse(new_end_date).isoformat(), "timeZone": timezone} + else: + # keep original duration + try: + o_start = _parse(target["start"].get("dateTime")) + o_end = _parse(target["end"].get("dateTime")) + duration = o_end - o_start + except Exception: + duration = timedelta(hours=1) + new_end_dt = _parse(new_start_date) + duration + body["end"] = {"dateTime": new_end_dt.isoformat(), "timeZone": timezone} + elif new_end_date: + body["end"] = {"dateTime": _parse(new_end_date).isoformat(), "timeZone": timezone} + + try: + updated = service.events().patch( + calendarId=resolved_calendar_id, eventId=target_id, body=body, sendUpdates="all" + ).execute() + except HttpError as e: + return {"status": "error", "message": f"Google Calendar API error: {str(e)}"} + + logger.info(f"Edited calendar event: {target_id} ({updated.get('summary')})") + return { + "status": "success", + "message": f"Reunião '{updated.get('summary', '(sem título)')}' atualizada com sucesso.", + "event": { + **_event_label(updated), + "link": updated.get("htmlLink"), + }, + } + + except Exception as e: + logger.error(f"Unexpected error in edit_calendar_event: {str(e)}") + logger.error(traceback.format_exc()) + return {"status": "error", "message": f"Failed to edit calendar event: {str(e)}"} + + edit_calendar_event.__name__ = "edit_calendar_event" + edit_calendar_event.__doc__ = f"""Edit or reschedule an existing Google Calendar meeting. + +Use this when the customer wants to CHANGE a meeting already booked (move the time, or change title/description). + +How it works: +1. Locate the current meeting by its time range (start_date/end_date, timezone {timezone}), optionally by title, or by event_id. +2. Provide what changes: new_start_date/new_end_date (to reschedule), new_title, new_description. +3. If only new_start_date is given, the original duration is preserved. +4. If several meetings match, you get the list to ask which one to edit. + +Args: + start_date (str): Start of the range where the CURRENT meeting is (ISO, e.g. '2026-07-20T00:00:00') + end_date (str): End of the range where the CURRENT meeting is (ISO) + new_start_date (str, optional): New start (ISO) to move the meeting to + new_end_date (str, optional): New end (ISO); if omitted keeps the original duration + new_title (str, optional): New meeting title + new_description (str, optional): New meeting description + title (str, optional): Text to match the current title (case-insensitive) + calendar_id (str, optional): Calendar to use (defaults to the same as create/cancel) + event_id (str, optional): Exact event id (skips the search) + +Examples: +- Move the Jul 20 9am meeting to 2pm: start_date='2026-07-20T09:00:00', end_date='2026-07-20T10:00:00', new_start_date='2026-07-20T14:00:00' +- Rename a meeting: start_date='2026-07-20T00:00:00', end_date='2026-07-20T23:59:59', new_title='Reunião remarcada' +""" + + return edit_calendar_event diff --git a/tests/unit/test_google_calendar_edit.py b/tests/unit/test_google_calendar_edit.py new file mode 100644 index 0000000..077783f --- /dev/null +++ b/tests/unit/test_google_calendar_edit.py @@ -0,0 +1,181 @@ +"""EVO-2170: edit_calendar_event tool (edit / reschedule an existing meeting).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from src.services.adk.tools.google_calendar import create_edit_event_tool +from src.services.adk.tools.google_calendar.base import GoogleCalendarClient + +AGENT_ID = "c9cbd608-ee35-49fe-8200-fadaaa682d87" +CALENDAR_CONFIG = {"settings": {"timezone": "America/Sao_Paulo", "connected": True}} + +FULL_CREDS = {"refresh_token": "rt", "client_id": "cid", "client_secret": "sec", "token": "tok"} +SANITIZED_CREDS = {"connected": True, "email": "x@y.com", "provider": "google_calendar_credentials"} + + +def _event(eid, summary, start, end): + return { + "id": eid, + "summary": summary, + "start": {"dateTime": start}, + "end": {"dateTime": end}, + } + + +TARGET = _event("evt-1", "Reunião", "2026-07-20T14:00:00-03:00", "2026-07-20T15:00:00-03:00") + + +def _mock_service(target=None): + svc = MagicMock() + svc.events.return_value.patch.return_value.execute.return_value = { + "id": "evt-1", + "summary": "Reunião", + "start": {"dateTime": "2026-07-20T16:30:00-03:00"}, + "end": {"dateTime": "2026-07-20T17:30:00-03:00"}, + "htmlLink": "https://calendar.google.com/evt-1", + } + if target is not None: + svc.events.return_value.get.return_value.execute.return_value = target + return svc + + +def _tool(creds=FULL_CREDS): + return create_edit_event_tool( + agent_id=AGENT_ID, calendar_config=CALENDAR_CONFIG, credentials_config=creds, db=None + ) + + +def _run_search(tool, events, svc, **kwargs): + with patch.object(GoogleCalendarClient, "get_calendar_service", return_value=svc), patch.object( + GoogleCalendarClient, + "check_availability", + new=AsyncMock(return_value={"status": "success", "available": False, "events": events}), + ): + return asyncio.run( + tool(start_date="2026-07-20T00:00:00", end_date="2026-07-20T23:59:59", **kwargs) + ) + + +def test_reschedule_new_start_only_preserves_duration(): + svc = _mock_service() + res = _run_search(_tool(), [TARGET], svc, new_start_date="2026-07-20T16:30:00-03:00") + assert res["status"] == "success" + body = svc.events.return_value.patch.call_args.kwargs["body"] + assert "16:30:00" in body["start"]["dateTime"] + assert "17:30:00" in body["end"]["dateTime"] # original 1h duration preserved + assert "summary" not in body # title untouched + + +def test_change_title_only_patches_summary_no_times(): + svc = _mock_service() + res = _run_search(_tool(), [TARGET], svc, new_title="Reunião remarcada") + assert res["status"] == "success" + body = svc.events.return_value.patch.call_args.kwargs["body"] + assert body["summary"] == "Reunião remarcada" + assert "start" not in body and "end" not in body + + +def test_explicit_new_start_and_end_are_used(): + svc = _mock_service() + res = _run_search( + _tool(), + [TARGET], + svc, + new_start_date="2026-07-20T09:00:00-03:00", + new_end_date="2026-07-20T10:30:00-03:00", + ) + assert res["status"] == "success" + body = svc.events.return_value.patch.call_args.kwargs["body"] + assert "09:00:00" in body["start"]["dateTime"] + assert "10:30:00" in body["end"]["dateTime"] + + +def test_no_change_field_returns_error(): + svc = _mock_service() + res = _run_search(_tool(), [TARGET], svc) # no new_* provided + assert res["status"] == "error" + assert "alteração" in res["message"].lower() + svc.events.return_value.patch.assert_not_called() + + +def test_multiple_matches_ask_for_disambiguation(): + svc = _mock_service() + other = _event("evt-2", "Outra", "2026-07-20T11:00:00-03:00", "2026-07-20T12:00:00-03:00") + res = _run_search(_tool(), [TARGET, other], svc, new_title="X") + assert res["status"] == "needs_clarification" + assert len(res["events"]) == 2 + svc.events.return_value.patch.assert_not_called() + + +def test_no_match_returns_informative_message(): + svc = _mock_service() + res = _run_search(_tool(), [], svc, new_title="X") + assert res["status"] == "error" + assert "nenhuma" in res["message"].lower() + svc.events.return_value.patch.assert_not_called() + + +def test_event_id_locates_via_get_then_patches(): + svc = _mock_service(target=TARGET) + ca = AsyncMock() + with patch.object(GoogleCalendarClient, "get_calendar_service", return_value=svc), patch.object( + GoogleCalendarClient, "check_availability", new=ca + ): + res = asyncio.run( + _tool()( + start_date="2026-07-20T00:00:00", + end_date="2026-07-20T23:59:59", + event_id="evt-1", + new_start_date="2026-07-20T16:30:00-03:00", + ) + ) + assert res["status"] == "success" + svc.events.return_value.get.assert_called_once_with(calendarId="primary", eventId="evt-1") + ca.assert_not_called() # no search when event_id is given + svc.events.return_value.patch.assert_called_once() + + +def test_sanitized_credentials_are_reloaded_from_db(): + svc = _mock_service() + gcs = MagicMock(return_value=svc) + with patch( + "src.services.adk.tools.google_calendar.edit_event._load_full_credentials_from_db", + return_value=FULL_CREDS, + ) as load_mock, patch.object( + GoogleCalendarClient, "get_calendar_service", new=gcs + ), patch.object( + GoogleCalendarClient, + "check_availability", + new=AsyncMock(return_value={"status": "success", "available": False, "events": [TARGET]}), + ): + res = asyncio.run( + _tool(SANITIZED_CREDS)( + start_date="2026-07-20T00:00:00", + end_date="2026-07-20T23:59:59", + new_title="X", + ) + ) + load_mock.assert_called_once_with(AGENT_ID) + assert res["status"] == "success" + gcs.assert_called_once_with(FULL_CREDS) + + +def test_uses_selected_calendar_from_config(): + # EVO-2171: locates and patches on settings.selectedCalendarId, not primary. + cfg = {"settings": {"timezone": "America/Sao_Paulo", "selectedCalendarId": "mcc@agencia.bid"}} + svc = _mock_service() + ca = AsyncMock(return_value={"status": "success", "available": False, "events": [TARGET]}) + tool = create_edit_event_tool( + agent_id=AGENT_ID, calendar_config=cfg, credentials_config=FULL_CREDS, db=None + ) + with patch.object(GoogleCalendarClient, "get_calendar_service", return_value=svc), patch.object( + GoogleCalendarClient, "check_availability", new=ca + ): + res = asyncio.run( + tool(start_date="2026-07-20T00:00:00", end_date="2026-07-20T23:59:59", new_title="X") + ) + assert res["status"] == "success" + assert ca.call_args[0][3] == "mcc@agencia.bid" # search on the selected calendar + assert svc.events.return_value.patch.call_args.kwargs["calendarId"] == "mcc@agencia.bid"