From dc35b4ec9ed058b980c89065484fdbfc1fd4cc9b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <83769052+abhishekpatil4@users.noreply.github.com> Date: Tue, 15 Apr 2025 23:46:36 +0530 Subject: [PATCH] feat: Update Gmail component (#7530) * chore: action params naming * chore: remove comments * chore: replaced MessageTextInput field with StrInput * feat: add google calendar component * feat: replaced loops with hardcoded display-name action-enum mapping to improve performance * chore: format * fix: add type ignore for action_key in getattr call * feat: add google sheets component * fix: format google calendar utils * feat: add google meet Component * chore: minor improvement * chore: format & lint * fix: google meet component * feat: add GitHub component * fix: format * fix: lint * fix: typo * feat: add Slack Component * fix: format * fix: rest bool value to None * chore: disabled slack tools temporarily * fix: add condition to set list variables to None in when action is changed * chore: capitalise display names * fix: update list issues field to MessateTextInput * fix: format/lint in slack component * fix: google calendar logo * fix: revert setting bool field to None * feat: composio-core & composio-core version bump to 0.7.10 * fix: minor bugs * feat: add accepted values to AccessType field in google meet component * feat: add accepted values for entry point access field in Google meet component * fix: Google Calendar display names * feat: replace list with nested list for batch update field in Google sheets * fix: display name in Google sheets * fix: format * fix: titlecase display name in google meet component * feat: set advaced to true for advanced fields * feat: add condition to skip empty list fields in execute_action * chore: improve display names GitHub Component * fix: slack component display names & minor enhancements * feat: update condition to skip empty fields while executing action * feat: fix google calendar field description * feat: update googlemeet component to use new inputs & composio base class * chore: update googlemeet component filename * feat: update github component to use new inputs & composio base class * feat: update google calendar to use new inputs & composio base class * feat: update google sheets component to use new inputs & Composio base class * feat: update slack component to use new inputs & Composio base class * fix: format * chore: cleanup un-used code * chore: format * feat: add missing fields & actions * chore: fix typo * feat: rm other components * feat: improve error message format & revert composio libs bump * chore: revert uv.lock file * update tests * fix: remove duplicate action field in GMAIL_FETCH_EMAILS * fix: remove unused code * fix: add ignore statement --------- Co-authored-by: Edwin Jose --- .../langflow/components/composio/__init__.py | 5 +- .../components/composio/gmail_composio.py | 91 ++++++++++++++----- .../components/bundles/composio/test_base.py | 2 +- .../components/bundles/composio/test_gmail.py | 76 ++++++++-------- 4 files changed, 113 insertions(+), 61 deletions(-) diff --git a/src/backend/base/langflow/components/composio/__init__.py b/src/backend/base/langflow/components/composio/__init__.py index f116072f0..66148b059 100644 --- a/src/backend/base/langflow/components/composio/__init__.py +++ b/src/backend/base/langflow/components/composio/__init__.py @@ -1,4 +1,7 @@ from .composio_api import ComposioAPIComponent from .gmail_composio import ComposioGmailAPIComponent -__all__ = ["ComposioAPIComponent", "ComposioGmailAPIComponent"] +__all__ = [ + "ComposioAPIComponent", + "ComposioGmailAPIComponent", +] diff --git a/src/backend/base/langflow/components/composio/gmail_composio.py b/src/backend/base/langflow/components/composio/gmail_composio.py index 83d1cf11b..7bdb3de4a 100644 --- a/src/backend/base/langflow/components/composio/gmail_composio.py +++ b/src/backend/base/langflow/components/composio/gmail_composio.py @@ -1,3 +1,4 @@ +import json from typing import Any from composio import Action @@ -25,57 +26,86 @@ class ComposioGmailAPIComponent(ComposioBaseComponent): _actions_data: dict = { "GMAIL_SEND_EMAIL": { "display_name": "Send Email", - "action_fields": ["recipient_email", "subject", "body", "cc", "bcc", "is_html"], + "action_fields": [ + "recipient_email", + "subject", + "body", + "cc", + "bcc", + "is_html", + "gmail_user_id", + "attachment", + ], }, "GMAIL_FETCH_EMAILS": { "display_name": "Fetch Emails", - "action_fields": ["max_results", "query"], + "action_fields": [ + "gmail_user_id", + "max_results", + "query", + "page_token", + "label_ids", + "include_spam_trash", + ], "get_result_field": True, "result_field": "messages", }, "GMAIL_GET_PROFILE": { "display_name": "Get User Profile", - "action_fields": [], + "action_fields": ["gmail_user_id"], }, "GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID": { "display_name": "Get Email By ID", - "action_fields": ["message_id"], + "action_fields": ["message_id", "gmail_user_id", "format"], "get_result_field": False, }, "GMAIL_CREATE_EMAIL_DRAFT": { "display_name": "Create Draft Email", - "action_fields": ["recipient_email", "subject", "body", "cc", "bcc", "is_html"], + "action_fields": [ + "recipient_email", + "subject", + "body", + "cc", + "bcc", + "is_html", + "attachment", + "gmail_user_id", + ], }, "GMAIL_FETCH_MESSAGE_BY_THREAD_ID": { "display_name": "Get Message By Thread ID", - "action_fields": ["thread_id"], + "action_fields": ["thread_id", "page_token", "gmail_user_id"], "get_result_field": False, }, "GMAIL_LIST_THREADS": { "display_name": "List Email Threads", - "action_fields": ["max_results", "query"], + "action_fields": ["max_results", "query", "gmail_user_id", "page_token"], }, "GMAIL_REPLY_TO_THREAD": { "display_name": "Reply To Thread", - "action_fields": ["thread_id", "message_body", "recipient_email"], + "action_fields": ["thread_id", "message_body", "recipient_email", "gmail_user_id", "cc", "bcc", "is_html"], }, "GMAIL_LIST_LABELS": { "display_name": "List Email Labels", - "action_fields": [], + "action_fields": ["gmail_user_id"], }, "GMAIL_CREATE_LABEL": { "display_name": "Create Email Label", - "action_fields": ["label_name"], + "action_fields": ["label_name", "label_list_visibility", "message_list_visibility", "gmail_user_id"], }, "GMAIL_GET_PEOPLE": { "display_name": "Get Contacts", - "action_fields": [], + "action_fields": ["resource_name", "person_fields"], }, "GMAIL_REMOVE_LABEL": { "display_name": "Delete Email Label", - "action_fields": ["label_id"], + "action_fields": ["label_id", "gmail_user_id"], "get_result_field": False, }, + "GMAIL_GET_ATTACHMENT": { + "display_name": "Get Attachment", + "action_fields": ["message_id", "attachment_id", "file_name", "gmail_user_id"], + }, } _all_fields = {field for action_data in _actions_data.values() for field in action_data["action_fields"]} _bool_variables = {"is_html", "include_spam_trash"} @@ -144,6 +174,13 @@ class ComposioGmailAPIComponent(ComposioBaseComponent): advanced=True, ), # Email retrieval and management fields + MessageTextInput( + name="gmail_user_id", + display_name="User ID", + info="The user's email address or 'me' for the authenticated user", + show=False, + advanced=True, + ), IntInput( name="max_results", display_name="Max Results", @@ -330,23 +367,35 @@ class ComposioGmailAPIComponent(ComposioBaseComponent): params[field] = value + if params.get("gmail_user_id"): + params["user_id"] = params.pop("gmail_user_id") + result = toolset.execute_action( action=enum_name, params=params, - ).get("data", []) + ) + if not result.get("successful"): + message_str = result.get("data", {}).get("message", "{}") + try: + error_data = json.loads(message_str).get("error", {}) + except json.JSONDecodeError: + error_data = {"error": "Failed to get exact error details"} + return { + "code": error_data.get("code"), + "message": error_data.get("message"), + "errors": error_data.get("errors", []), + "status": error_data.get("status"), + } + + result_data = result.get("data", []) if ( - len(result) != 1 + len(result_data) != 1 and not self._actions_data.get(action_key, {}).get("result_field") and self._actions_data.get(action_key, {}).get("get_result_field") ): - msg = f"Expected a dict with a single key, got {len(result)} keys: {result.keys()}" + msg = f"Expected a dict with a single key, got {len(result_data)} keys: {result_data.keys()}" raise ValueError(msg) - if result: - get_result_field = self._actions_data.get(action_key, {}).get("get_result_field", True) - if get_result_field: - key = self._actions_data.get(action_key, {}).get("result_field", next(iter(result))) - return result.get(key) - return result + return result_data # noqa: TRY300 except Exception as e: logger.error(f"Error executing action: {e}") display_name = self.action[0]["name"] if isinstance(self.action, list) and self.action else str(self.action) diff --git a/src/backend/tests/unit/components/bundles/composio/test_base.py b/src/backend/tests/unit/components/bundles/composio/test_base.py index d46136052..a062bfb5a 100644 --- a/src/backend/tests/unit/components/bundles/composio/test_base.py +++ b/src/backend/tests/unit/components/bundles/composio/test_base.py @@ -15,7 +15,7 @@ class MockComposioToolSet: return [] def execute_action(self, *_, **__): - return {"data": {"response": "mocked response"}} + return {"successful": True, "data": {"result": "mocked response"}} class TestComposioBase(ComponentTestBaseWithoutClient): diff --git a/src/backend/tests/unit/components/bundles/composio/test_gmail.py b/src/backend/tests/unit/components/bundles/composio/test_gmail.py index aed17b1b4..39ddf4d76 100644 --- a/src/backend/tests/unit/components/bundles/composio/test_gmail.py +++ b/src/backend/tests/unit/components/bundles/composio/test_gmail.py @@ -72,9 +72,18 @@ class TestGmailComponent(ComponentTestBaseWithoutClient): component.body = "Test Body" component.is_html = False + # For this specific test, customize the _actions_data to not use get_result_field + component._actions_data = { + "GMAIL_SEND_EMAIL": { + "display_name": "Send Email", + "action_fields": ["recipient_email", "subject", "body", "is_html"], + "get_result_field": False, + } + } + # Execute action result = component.execute_action() - assert result == "mocked response" + assert result == {"result": "mocked response"} def test_execute_action_fetch_emails(self, component_class, default_kwargs, monkeypatch): # Mock Action enum @@ -87,27 +96,24 @@ class TestGmailComponent(ComponentTestBaseWithoutClient): component.max_results = 10 component.query = "from:test@example.com" - # Create a mock for the toolset - mock_toolset = MagicMock() - # The execute_action method needs to return a structure that works with the component's logic - # Based on the error, we need to make sure the 'data' key contains a dictionary with at least one key - mock_toolset.execute_action.return_value = {"data": {"response": "mocked response"}} - - # Patch the _build_wrapper method to return our mock - with patch.object(component, "_build_wrapper", return_value=mock_toolset): - # Also patch the _actions_data to ensure it has the correct structure for GMAIL_FETCH_EMAILS - # This ensures the result_field is set correctly - component._actions_data = { - "GMAIL_FETCH_EMAILS": { - "action_fields": ["max_results", "query"], - "result_field": "response", - "get_result_field": True, - } + # For this specific test, we need to customize the action_data to handle results field + component._actions_data = { + "GMAIL_FETCH_EMAILS": { + "action_fields": ["max_results", "query"], + "result_field": "messages", + "get_result_field": True, } + } - # Execute action + # Create a mock for the toolset with specific structure for this test + mock_toolset = MagicMock() + mock_toolset.execute_action.return_value = {"successful": True, "data": {"messages": "mocked response"}} + + # Patch the _build_wrapper method + with patch.object(component, "_build_wrapper", return_value=mock_toolset): result = component.execute_action() - assert result == "mocked response" + # Based on the component's actual behavior, it returns the entire data dict + assert result == {"messages": "mocked response"} def test_execute_action_get_profile(self, component_class, default_kwargs, monkeypatch): # Mock Action enum @@ -118,9 +124,18 @@ class TestGmailComponent(ComponentTestBaseWithoutClient): component.api_key = "test_key" component.action = [{"name": "Get User Profile"}] + # For this specific test, customize the _actions_data to not use get_result_field + component._actions_data = { + "GMAIL_GET_PROFILE": { + "display_name": "Get User Profile", + "action_fields": ["gmail_user_id"], + "get_result_field": False, + } + } + # Execute action result = component.execute_action() - assert result == "mocked response" + assert result == {"result": "mocked response"} def test_execute_action_invalid_action(self, component_class, default_kwargs): # Setup component @@ -173,24 +188,9 @@ class TestGmailComponent(ComponentTestBaseWithoutClient): # Verify the DataFrame is not empty assert not result.empty - # Verify the DataFrame contains our mock data - # This will depend on how the component processes the data - # We can check for specific column names or values - if hasattr(result, "columns"): - # If the DataFrame has columns, check for expected ones - expected_columns = ["id", "threadId", "subject", "from", "date", "snippet"] - for col in expected_columns: - if col in result.columns: - assert True - break - else: - # If none of the expected columns are found, check if data is in the DataFrame - assert any( - "Test Email" in str(cell) for cell in result.values.flat if hasattr(cell, "__contains__") - ) - else: - # If the DataFrame structure is different, just check for some expected content - assert "Test Email" in str(result) + # Check for expected content in the DataFrame string representation + data_str = str(result) + assert "test email" in data_str def test_update_build_config(self, component_class, default_kwargs): # Test that the Gmail component properly inherits and uses the base component's