fix: composio tool calls in composio connectors

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-05 18:57:10 -07:00
parent 4e174f17f2
commit 5e87a7a251
13 changed files with 1347 additions and 550 deletions

View file

@ -1027,6 +1027,505 @@ class ComposioService:
logger.error(f"Failed to list Calendar events: {e!s}")
return [], str(e)
@staticmethod
def _unwrap_response_data(data: Any) -> Any:
"""Composio responses often nest the meaningful payload under
``data.data.response_data``. Walk that envelope safely and return
whichever inner dict actually has the result keys."""
if not isinstance(data, dict):
return data
inner = data.get("data", data)
if isinstance(inner, dict):
return inner.get("response_data", inner)
return inner
@staticmethod
def _split_email_csv(value: str | None) -> list[str] | None:
"""Tools accept comma-separated cc/bcc strings; Composio expects an array."""
if not value:
return None
addrs = [e.strip() for e in value.split(",") if e.strip()]
return addrs or None
# ===== Gmail write methods =====
async def send_gmail_email(
self,
connected_account_id: str,
entity_id: str,
to: str,
subject: str,
body: str,
cc: str | None = None,
bcc: str | None = None,
is_html: bool = False,
) -> tuple[str | None, str | None, str | None]:
"""Send a Gmail message via the Composio ``GMAIL_SEND_EMAIL`` toolkit.
Returns:
Tuple of (message_id, thread_id, error). On success ``error`` is
None and at least one of the IDs is populated when Composio
returns them; on failure both IDs are None.
"""
try:
params: dict[str, Any] = {
"recipient_email": to,
"subject": subject,
"body": body,
"is_html": is_html,
}
if cc:
cc_list = self._split_email_csv(cc)
if cc_list:
params["cc"] = cc_list
if bcc:
bcc_list = self._split_email_csv(bcc)
if bcc_list:
params["bcc"] = bcc_list
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GMAIL_SEND_EMAIL",
params=params,
entity_id=entity_id,
)
if not result.get("success"):
return None, None, result.get("error", "Unknown error")
payload = self._unwrap_response_data(result.get("data", {}))
message_id = None
thread_id = None
if isinstance(payload, dict):
message_id = (
payload.get("id")
or payload.get("message_id")
or payload.get("messageId")
)
thread_id = payload.get("threadId") or payload.get("thread_id")
return message_id, thread_id, None
except Exception as e:
logger.error(f"Failed to send Gmail email: {e!s}")
return None, None, str(e)
async def create_gmail_draft(
self,
connected_account_id: str,
entity_id: str,
to: str,
subject: str,
body: str,
cc: str | None = None,
bcc: str | None = None,
is_html: bool = False,
) -> tuple[str | None, str | None, str | None, str | None]:
"""Create a Gmail draft via the Composio ``GMAIL_CREATE_EMAIL_DRAFT`` toolkit.
Returns:
Tuple of (draft_id, message_id, thread_id, error). On success
``error`` is None and ``draft_id`` is populated.
"""
try:
params: dict[str, Any] = {
"recipient_email": to,
"subject": subject,
"body": body,
"is_html": is_html,
}
cc_list = self._split_email_csv(cc)
if cc_list:
params["cc"] = cc_list
bcc_list = self._split_email_csv(bcc)
if bcc_list:
params["bcc"] = bcc_list
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GMAIL_CREATE_EMAIL_DRAFT",
params=params,
entity_id=entity_id,
)
if not result.get("success"):
return None, None, None, result.get("error", "Unknown error")
payload = self._unwrap_response_data(result.get("data", {}))
draft_id = None
message_id = None
thread_id = None
if isinstance(payload, dict):
draft_id = payload.get("id") or payload.get("draft_id")
draft_message = payload.get("message") or {}
if isinstance(draft_message, dict):
message_id = draft_message.get("id") or draft_message.get(
"message_id"
)
thread_id = draft_message.get("threadId") or draft_message.get(
"thread_id"
)
if message_id is None:
message_id = payload.get("message_id") or payload.get("messageId")
if thread_id is None:
thread_id = payload.get("thread_id") or payload.get("threadId")
return draft_id, message_id, thread_id, None
except Exception as e:
logger.error(f"Failed to create Gmail draft: {e!s}")
return None, None, None, str(e)
async def update_gmail_draft(
self,
connected_account_id: str,
entity_id: str,
draft_id: str,
to: str | None = None,
subject: str | None = None,
body: str | None = None,
cc: str | None = None,
bcc: str | None = None,
is_html: bool = False,
) -> tuple[str | None, str | None, str | None]:
"""Update an existing Gmail draft via ``GMAIL_UPDATE_DRAFT``.
Returns:
Tuple of (draft_id, message_id, error).
"""
try:
params: dict[str, Any] = {
"draft_id": draft_id,
"is_html": is_html,
}
if to:
params["recipient_email"] = to
if subject is not None:
params["subject"] = subject
if body is not None:
params["body"] = body
cc_list = self._split_email_csv(cc)
if cc_list:
params["cc"] = cc_list
bcc_list = self._split_email_csv(bcc)
if bcc_list:
params["bcc"] = bcc_list
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GMAIL_UPDATE_DRAFT",
params=params,
entity_id=entity_id,
)
if not result.get("success"):
return None, None, result.get("error", "Unknown error")
payload = self._unwrap_response_data(result.get("data", {}))
new_draft_id = draft_id
message_id = None
if isinstance(payload, dict):
new_draft_id = payload.get("id") or payload.get("draft_id") or draft_id
draft_message = payload.get("message") or {}
if isinstance(draft_message, dict):
message_id = draft_message.get("id") or draft_message.get(
"message_id"
)
if message_id is None:
message_id = payload.get("message_id") or payload.get("messageId")
return new_draft_id, message_id, None
except Exception as e:
logger.error(f"Failed to update Gmail draft: {e!s}")
return None, None, str(e)
async def trash_gmail_message(
self,
connected_account_id: str,
entity_id: str,
message_id: str,
) -> str | None:
"""Move a Gmail message to trash via ``GMAIL_MOVE_TO_TRASH``.
Returns the error message on failure, ``None`` on success.
"""
try:
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GMAIL_MOVE_TO_TRASH",
params={"message_id": message_id},
entity_id=entity_id,
)
if not result.get("success"):
return result.get("error", "Unknown error")
return None
except Exception as e:
logger.error(f"Failed to trash Gmail message: {e!s}")
return str(e)
# ===== Google Calendar write methods =====
async def create_calendar_event(
self,
connected_account_id: str,
entity_id: str,
summary: str,
start_datetime: str,
end_datetime: str,
timezone: str | None = None,
description: str | None = None,
location: str | None = None,
attendees: list[str] | None = None,
calendar_id: str = "primary",
) -> tuple[str | None, str | None, str | None]:
"""Create a Google Calendar event via ``GOOGLECALENDAR_CREATE_EVENT``.
Composio strips trailing timezone info on ``start_datetime`` /
``end_datetime`` and uses the ``timezone`` field as the IANA name,
so callers may pass ISO 8601 strings with or without offsets.
Returns:
Tuple of (event_id, html_link, error).
"""
try:
params: dict[str, Any] = {
"summary": summary,
"start_datetime": start_datetime,
"end_datetime": end_datetime,
"calendar_id": calendar_id,
}
if timezone:
params["timezone"] = timezone
if description:
params["description"] = description
if location:
params["location"] = location
if attendees:
params["attendees"] = [a for a in attendees if a]
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GOOGLECALENDAR_CREATE_EVENT",
params=params,
entity_id=entity_id,
)
if not result.get("success"):
return None, None, result.get("error", "Unknown error")
payload = self._unwrap_response_data(result.get("data", {}))
event_id = None
html_link = None
if isinstance(payload, dict):
event_id = payload.get("id") or payload.get("event_id")
html_link = payload.get("htmlLink") or payload.get("html_link")
return event_id, html_link, None
except Exception as e:
logger.error(f"Failed to create Calendar event: {e!s}")
return None, None, str(e)
async def update_calendar_event(
self,
connected_account_id: str,
entity_id: str,
event_id: str,
summary: str | None = None,
start_time: str | None = None,
end_time: str | None = None,
timezone: str | None = None,
description: str | None = None,
location: str | None = None,
attendees: list[str] | None = None,
calendar_id: str = "primary",
) -> tuple[str | None, str | None, str | None]:
"""Patch an existing Google Calendar event via ``GOOGLECALENDAR_PATCH_EVENT``.
Uses PATCH (not PUT) semantics so omitted fields are preserved.
Returns:
Tuple of (event_id, html_link, error).
"""
try:
params: dict[str, Any] = {
"event_id": event_id,
"calendar_id": calendar_id,
}
if summary is not None:
params["summary"] = summary
if start_time is not None:
params["start_time"] = start_time
if end_time is not None:
params["end_time"] = end_time
if timezone:
params["timezone"] = timezone
if description is not None:
params["description"] = description
if location is not None:
params["location"] = location
if attendees is not None:
params["attendees"] = [a for a in attendees if a]
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GOOGLECALENDAR_PATCH_EVENT",
params=params,
entity_id=entity_id,
)
if not result.get("success"):
return None, None, result.get("error", "Unknown error")
payload = self._unwrap_response_data(result.get("data", {}))
new_event_id = event_id
html_link = None
if isinstance(payload, dict):
new_event_id = payload.get("id") or payload.get("event_id") or event_id
html_link = payload.get("htmlLink") or payload.get("html_link")
return new_event_id, html_link, None
except Exception as e:
logger.error(f"Failed to patch Calendar event: {e!s}")
return None, None, str(e)
async def delete_calendar_event(
self,
connected_account_id: str,
entity_id: str,
event_id: str,
calendar_id: str = "primary",
) -> str | None:
"""Delete a Google Calendar event via ``GOOGLECALENDAR_DELETE_EVENT``.
Returns the error message on failure, ``None`` on success (idempotent
on already-deleted events).
"""
try:
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GOOGLECALENDAR_DELETE_EVENT",
params={
"event_id": event_id,
"calendar_id": calendar_id,
},
entity_id=entity_id,
)
if not result.get("success"):
return result.get("error", "Unknown error")
return None
except Exception as e:
logger.error(f"Failed to delete Calendar event: {e!s}")
return str(e)
# ===== Google Drive write methods =====
@staticmethod
def _drive_web_view_link(file_id: str, mime_type: str | None) -> str:
"""Synthesize a Google Drive ``webViewLink`` from id + mimeType.
Composio's ``GOOGLEDRIVE_CREATE_FILE_FROM_TEXT`` returns flat
metadata (id, name, mimeType) but does not always include a
``webViewLink``. We rebuild the canonical UI URL based on the
Workspace MIME type so callers can keep using a single field.
"""
if not file_id:
return ""
mt = (mime_type or "").lower()
if mt == "application/vnd.google-apps.document":
return f"https://docs.google.com/document/d/{file_id}/edit"
if mt == "application/vnd.google-apps.spreadsheet":
return f"https://docs.google.com/spreadsheets/d/{file_id}/edit"
if mt == "application/vnd.google-apps.presentation":
return f"https://docs.google.com/presentation/d/{file_id}/edit"
if mt == "application/vnd.google-apps.folder":
return f"https://drive.google.com/drive/folders/{file_id}"
return f"https://drive.google.com/file/d/{file_id}/view"
async def create_drive_file_from_text(
self,
connected_account_id: str,
entity_id: str,
name: str,
mime_type: str,
content: str | None = None,
parent_id: str | None = None,
) -> tuple[dict[str, Any] | None, str | None]:
"""Create a Google Drive file from text via ``GOOGLEDRIVE_CREATE_FILE_FROM_TEXT``.
Composio's tool requires ``text_content`` even for "empty" files;
an empty string is accepted. Native Workspace types (Docs, Sheets)
are produced by setting ``mime_type`` to the Google Apps MIME, and
Drive auto-converts the text payload (e.g. CSV Sheet).
Returns:
Tuple of (file_meta, error). ``file_meta`` keys:
``id``, ``name``, ``mimeType``, ``webViewLink``.
"""
try:
params: dict[str, Any] = {
"file_name": name,
"mime_type": mime_type,
"text_content": content if content is not None else "",
}
if parent_id:
params["parent_id"] = parent_id
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GOOGLEDRIVE_CREATE_FILE_FROM_TEXT",
params=params,
entity_id=entity_id,
)
if not result.get("success"):
return None, result.get("error", "Unknown error")
payload = self._unwrap_response_data(result.get("data", {}))
file_id: str | None = None
file_name: str | None = name
mime: str | None = mime_type
web_view_link: str | None = None
if isinstance(payload, dict):
file_id = (
payload.get("id") or payload.get("file_id") or payload.get("fileId")
)
file_name = payload.get("name") or payload.get("file_name") or name
mime = payload.get("mimeType") or payload.get("mime_type") or mime_type
web_view_link = payload.get("webViewLink") or payload.get(
"web_view_link"
)
if not file_id:
return None, "Composio response did not include a file id"
if not web_view_link:
web_view_link = self._drive_web_view_link(file_id, mime)
return (
{
"id": file_id,
"name": file_name,
"mimeType": mime,
"webViewLink": web_view_link,
},
None,
)
except Exception as e:
logger.error(f"Failed to create Drive file: {e!s}")
return None, str(e)
async def trash_drive_file(
self,
connected_account_id: str,
entity_id: str,
file_id: str,
) -> str | None:
"""Move a Google Drive file to trash via ``GOOGLEDRIVE_TRASH_FILE``.
Returns the error message on failure, ``None`` on success.
"""
try:
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GOOGLEDRIVE_TRASH_FILE",
params={"file_id": file_id},
entity_id=entity_id,
)
if not result.get("success"):
return result.get("error", "Unknown error")
return None
except Exception as e:
logger.error(f"Failed to trash Drive file: {e!s}")
return str(e)
# ===== User Info Methods =====
async def get_connected_account_email(