diff --git a/modules/chat/handling/handlingTasks.py b/modules/chat/handling/handlingTasks.py index c04658f7..c588e0a7 100644 --- a/modules/chat/handling/handlingTasks.py +++ b/modules/chat/handling/handlingTasks.py @@ -593,6 +593,23 @@ class HandlingTasks: else: action.setError(result.error or "Action execution failed") logger.error(f"✗ Action failed: {result.error}") + + # ⚠️ IMPORTANT: Create error message for failed actions so user can see what went wrong + await self.createActionMessage(action, result, workflow, result_label, [], task_step, task_index) + + # Create database log entry for action failure + if total_actions is not None: + self.chatInterface.createWorkflowLog({ + "workflowId": workflow.id, + "message": f"❌ Task {task_num} - Action {action_num}/{total_actions} failed: {result.error}", + "type": "error" + }) + else: + self.chatInterface.createWorkflowLog({ + "workflowId": workflow.id, + "message": f"❌ Task {task_num} - Action {action_num}/? failed: {result.error}", + "type": "error" + }) # Extract document filenames for the ActionResult document_filenames = [] @@ -670,15 +687,20 @@ class HandlingTasks: # Create a more meaningful message that includes task context task_objective = task_step.objective if task_step else 'Unknown task' - # Build a user-friendly message - if created_documents and len(created_documents) > 0: - doc_names = [doc.filename if hasattr(doc, 'filename') else str(doc) for doc in created_documents[:3]] - if len(created_documents) > 3: - doc_names.append(f"... and {len(created_documents) - 3} more") - - message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nGenerated {len(created_documents)} document(s): {', '.join(doc_names)}" + # Build a user-friendly message based on success/failure + if result.success: + if created_documents and len(created_documents) > 0: + doc_names = [doc.filename if hasattr(doc, 'filename') else str(doc) for doc in created_documents[:3]] + if len(created_documents) > 3: + doc_names.append(f"... and {len(created_documents) - 3} more") + + message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nGenerated {len(created_documents)} document(s): {', '.join(doc_names)}" + else: + message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nAction executed successfully" else: - message_text = f"✅ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} completed\n\nObjective: {task_objective}\n\nAction executed successfully" + # ⚠️ FAILURE MESSAGE - Show error details to user + error_details = result.error if result.error else "Unknown error occurred" + message_text = f"❌ Task {task_index or '?'} - Action {action.execMethod}.{action.execAction} failed\n\nObjective: {task_objective}\n\nError: {error_details}\n\nPlease check the connection and try again." message_data = { "workflowId": workflow.id, @@ -694,6 +716,11 @@ class HandlingTasks: "documents": created_documents } + # Add debugging for error messages + if not result.success: + logger.info(f"Creating ERROR message: {message_text}") + logger.info(f"Message data: {message_data}") + message = self.chatInterface.createWorkflowMessage(message_data) if message: workflow.messages.append(message) diff --git a/modules/chat/serviceCenter.py b/modules/chat/serviceCenter.py index b5595ba6..3e3d6aa1 100644 --- a/modules/chat/serviceCenter.py +++ b/modules/chat/serviceCenter.py @@ -441,24 +441,53 @@ class ServiceCenter: return [] def getConnectionReferenceList(self) -> List[str]: - """Get list of all UserConnection objects as references""" + """Get list of all UserConnection objects as references with enhanced state information""" connections = [] # Get user connections through AppObjects interface user_connections = self.interfaceApp.getUserConnections(self.user.id) for conn in user_connections: - connections.append(self.getConnectionReferenceFromUserConnection(conn)) + # Get enhanced connection reference with state information + enhanced_ref = self.getConnectionReferenceFromUserConnection(conn) + connections.append(enhanced_ref) # Sort by connection reference return sorted(connections) def getConnectionReferenceFromUserConnection(self, connection: UserConnection) -> str: - """Get connection reference from UserConnection""" - return f"connection:{connection.authority.value}:{connection.externalUsername}:{connection.id}" + """Get connection reference from UserConnection with enhanced state information""" + # Get token information to check if it's expired + token = None + token_status = "unknown" + try: + token = self.interfaceApp.getToken(connection.authority.value) + if token: + if hasattr(token, 'expiresAt') and token.expiresAt: + import time + current_time = time.time() + if current_time > token.expiresAt: + token_status = "expired" + else: + token_status = "valid" + else: + token_status = "no_expiration" + else: + token_status = "no_token" + except Exception as e: + token_status = f"error: {str(e)}" + + # Build enhanced reference with state information + base_ref = f"connection:{connection.authority.value}:{connection.externalUsername}:{connection.id}" + state_info = f" [status:{connection.status.value}, token:{token_status}]" + + return base_ref + state_info def getUserConnectionFromConnectionReference(self, connectionReference: str) -> Optional[UserConnection]: - """Get UserConnection from reference string""" + """Get UserConnection from reference string (handles both old and enhanced formats)""" try: - # Parse reference format: connection:{authority}:{username}:{id} - parts = connectionReference.split(':') + # Parse reference format: connection:{authority}:{username}:{id} [status:..., token:...] + # Remove state information if present + base_reference = connectionReference.split(' [')[0] + + parts = base_reference.split(':') if len(parts) != 4 or parts[0] != "connection": return None diff --git a/modules/methods/methodOutlook.py b/modules/methods/methodOutlook.py index 6c9ed260..e051b631 100644 --- a/modules/methods/methodOutlook.py +++ b/modules/methods/methodOutlook.py @@ -26,7 +26,17 @@ class MethodOutlook(MethodBase): """Get Microsoft connection from connection reference""" try: userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference) - if not userConnection or userConnection.authority.value != "msft" or userConnection.status.value != "active": + if not userConnection: + logger.warning(f"No user connection found for reference: {connectionReference}") + return None + + if userConnection.authority.value != "msft": + logger.warning(f"Connection {userConnection.id} is not Microsoft (authority: {userConnection.authority.value})") + return None + + # Check if connection is active or pending (pending means OAuth in progress) + if userConnection.status.value not in ["active", "pending"]: + logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}") return None # Get the corresponding token for this user and authority @@ -35,6 +45,16 @@ class MethodOutlook(MethodBase): logger.warning(f"No token found for user {userConnection.userId} and authority {userConnection.authority.value}") return None + # Check if token is expired + if hasattr(token, 'expiresAt') and token.expiresAt: + import time + current_time = time.time() + if current_time > token.expiresAt: + logger.warning(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})") + return None + + logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}") + return { "id": userConnection.id, "accessToken": token.tokenAccess, @@ -45,6 +65,118 @@ class MethodOutlook(MethodBase): logger.error(f"Error getting Microsoft connection: {str(e)}") return None + def _sanitizeSearchQuery(self, query: str) -> str: + """ + Sanitize and validate search query for Microsoft Graph API + + Microsoft Graph API has specific requirements for search queries: + - Escape special characters properly + - Handle search operators correctly + - Ensure query format is valid + """ + if not query: + return "" + + # Clean the query + clean_query = query.strip() + + # Remove any double quotes that might cause issues + clean_query = clean_query.replace('"', '') + + # Handle common search operators + if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']): + # This is an advanced search query, return as-is + return clean_query + + # For basic text search, ensure it's safe for contains() filter + # Remove any characters that might break the OData filter syntax + import re + # Remove or escape characters that could break OData filter syntax + safe_query = re.sub(r'[\\\'"]', '', clean_query) + + return safe_query + + def _buildSearchParameters(self, query: str, folder: str, limit: int) -> Dict[str, Any]: + """ + Build search parameters for Microsoft Graph API + + This method handles the complexity of building search parameters + while avoiding conflicts between $search and $filter parameters. + """ + params = { + "$top": limit, + "$orderby": "receivedDateTime desc" + } + + if not query or not query.strip(): + # No query specified, just get emails from folder + if folder and folder.lower() != "all": + params["$filter"] = f"parentFolderId eq '{folder}'" + return params + + clean_query = self._sanitizeSearchQuery(query) + + # Check if this is a complex search query with multiple operators + if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']): + # This is an advanced search query, use $search + # Microsoft Graph API supports complex search syntax + params["$search"] = f'"{clean_query}"' + logger.info(f"Using advanced search query: {clean_query}") + + # Note: When using $search, we cannot combine it with $filter for folder + # We'll need to filter results after the API call + if folder and folder.lower() != "all": + logger.info(f"Will filter results by folder '{folder}' after search") + else: + # Use $filter for basic text search in subject and body + # Microsoft Graph API supports contains() for text search + filter_parts = [f"contains(subject,'{clean_query}') or contains(body/content,'{clean_query}')"] + + # Add folder filter if specified + if folder and folder.lower() != "all": + filter_parts.append(f"parentFolderId eq '{folder}'") + + # Combine all filter parts + params["$filter"] = " and ".join(f"({part})" for part in filter_parts) + logger.info(f"Using basic text search filter: {clean_query}") + + return params + + def _getFolderId(self, folder_name: str, connection: Dict[str, Any]) -> Optional[str]: + """ + Get the folder ID for a given folder name + + This is needed for proper filtering when using advanced search queries + """ + try: + import requests + + graph_url = "https://graph.microsoft.com/v1.0" + headers = { + "Authorization": f"Bearer {connection['accessToken']}", + "Content-Type": "application/json" + } + + # Get mail folders + api_url = f"{graph_url}/me/mailFolders" + response = requests.get(api_url, headers=headers) + + if response.status_code == 200: + folders_data = response.json() + for folder in folders_data.get("value", []): + if folder.get("displayName", "").lower() == folder_name.lower(): + return folder.get("id") + + logger.warning(f"Folder '{folder_name}' not found") + return None + else: + logger.warning(f"Could not retrieve folders: {response.status_code}") + return None + + except Exception as e: + logger.warning(f"Error getting folder ID for '{folder_name}': {str(e)}") + return None + @action async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult: """ @@ -284,11 +416,34 @@ class MethodOutlook(MethodBase): message["attachments"].append(attachment) # Create the draft message - api_url = f"{graph_url}/me/messages" + # First, get the Drafts folder ID to ensure the draft is created there + drafts_folder_id = self._getFolderId("Drafts", connection) + + if drafts_folder_id: + # Create draft in the Drafts folder specifically + api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages" + logger.info(f"Creating draft in Drafts folder (ID: {drafts_folder_id})") + else: + # Fallback: create in default location + api_url = f"{graph_url}/me/messages" + logger.warning("Could not find Drafts folder, creating draft in default location") + response = requests.post(api_url, headers=headers, json=message) response.raise_for_status() draft_data = response.json() + + # Get the actual folder information for the created draft + actual_folder = "Drafts" + if drafts_folder_id: + actual_folder = "Drafts" + else: + # Try to determine where the draft was actually created + if "parentFolderId" in draft_data: + actual_folder = f"Folder ID: {draft_data['parentFolderId']}" + else: + actual_folder = "Default location" + draft_result = { "status": "draft_created", "messageId": draft_data.get("id", "unknown"), @@ -297,12 +452,13 @@ class MethodOutlook(MethodBase): "cc": cc, "bcc": bcc, "attachments": len(attachments) if attachments else 0, - "draftLocation": "Drafts folder", + "draftLocation": actual_folder, + "draftsFolderId": drafts_folder_id, "apiResponse": response.status_code, "draftData": draft_data } - logger.info(f"Successfully created email draft for {len(to)} recipients with {len(attachments) if attachments else 0} attachments") + logger.info(f"Successfully created email draft for {len(to)} recipients with {len(attachments) if attachments else 0} attachments in {actual_folder}") except ImportError: logger.error("requests module not available, falling back to simulation") @@ -420,13 +576,31 @@ class MethodOutlook(MethodBase): limit = parameters.get("limit", 20) expectedDocumentFormats = parameters.get("expectedDocumentFormats", []) - if not connectionReference or not query: + # Validate parameters + if not connectionReference: return self._createResult( success=False, data={}, - error="Connection reference and query are required" + error="Connection reference is required" ) + if not query or not query.strip(): + return self._createResult( + success=False, + data={}, + error="Search query is required and cannot be empty" + ) + + # Validate limit + try: + limit = int(limit) + if limit <= 0 or limit > 1000: # Microsoft Graph API has limits + limit = 20 + logger.warning(f"Limit {limit} is out of range, using default value 20") + except (ValueError, TypeError): + limit = 20 + logger.warning(f"Invalid limit value, using default value 20") + # Get Microsoft connection connection = self._getMicrosoftConnection(connectionReference) if not connection: @@ -449,31 +623,88 @@ class MethodOutlook(MethodBase): # Build the search API request api_url = f"{graph_url}/me/messages" - params = { - "$top": limit, - "$orderby": "receivedDateTime desc", - "$search": f'"{query}"' - } + params = self._buildSearchParameters(query, folder, limit) - # Add folder filter if specified - if folder and folder.lower() != "all": - params["$filter"] = f"parentFolderId eq '{folder}'" + logger.info(f"Search API parameters: {params}") # Make the API call response = requests.get(api_url, headers=headers, params=params) + + # Log response details for debugging + logger.debug(f"Microsoft Graph API response status: {response.status_code}") + logger.debug(f"Microsoft Graph API response headers: {dict(response.headers)}") + + if response.status_code != 200: + # Log detailed error information + try: + error_data = response.json() + logger.error(f"Microsoft Graph API error: {response.status_code} - {error_data}") + except: + logger.error(f"Microsoft Graph API error: {response.status_code} - {response.text}") + + # Check for specific error types and provide helpful messages + if response.status_code == 400: + logger.error("Bad Request (400) - Check search query format and parameters") + logger.error(f"Search query: '{query}'") + logger.error(f"Search parameters: {params}") + logger.error(f"API URL: {api_url}") + elif response.status_code == 401: + logger.error("Unauthorized (401) - Check access token and permissions") + elif response.status_code == 403: + logger.error("Forbidden (403) - Check API permissions and scopes") + elif response.status_code == 429: + logger.error("Too Many Requests (429) - Rate limit exceeded") + + # Fall back to simulation on API error + raise Exception(f"Microsoft Graph API returned {response.status_code}: {response.text}") + response.raise_for_status() search_data = response.json() + emails = search_data.get("value", []) + + logger.info(f"Successfully retrieved {len(emails)} emails from Microsoft Graph API") + + # Apply folder filtering if needed and we used $search + if folder and folder.lower() != "all" and "$search" in params: + # Get the actual folder ID for proper filtering + folder_id = self._getFolderId(folder, connection) + + if folder_id: + # Filter results by folder ID + filtered_emails = [] + for email in emails: + if email.get("parentFolderId") == folder_id: + filtered_emails.append(email) + emails = filtered_emails + logger.info(f"Filtered {len(filtered_emails)} emails for folder '{folder}' (ID: {folder_id}) from {len(search_data.get('value', []))} total results") + else: + # Fallback: try to filter by folder name (less reliable) + filtered_emails = [] + for email in emails: + # Check if email has folder information + if hasattr(email, 'parentFolderId') and email.get('parentFolderId'): + if email.get('parentFolderId') == folder: + filtered_emails.append(email) + else: + # If no folder info, include the email (less strict filtering) + filtered_emails.append(email) + logger.debug(f"Email {email.get('id', 'unknown')} has no folder info, including in results") + + emails = filtered_emails + logger.info(f"Filtered {len(filtered_emails)} emails for folder '{folder}' (fallback filtering) from {len(search_data.get('value', []))} total results") + search_result = { "query": query, - "results": search_data.get("value", []), - "count": len(search_data.get("value", [])), + "results": emails, + "count": len(emails), "folder": folder, "limit": limit, - "apiResponse": search_data + "apiResponse": search_data, + "searchParams": params } - logger.info(f"Successfully searched emails with query '{query}', found {len(search_data.get('value', []))} results") + logger.info(f"Successfully searched emails with query '{query}', found {len(emails)} results") except ImportError: logger.error("requests module not available, falling back to simulation") @@ -555,6 +786,173 @@ class MethodOutlook(MethodBase): except Exception as e: logger.error(f"Error searching emails: {str(e)}") + return self._createResult( + success=False, + data={}, + error=str(e) + ) + + @action + async def listDrafts(self, parameters: Dict[str, Any]) -> ActionResult: + """ + List email drafts in Outlook + + Parameters: + connectionReference (str): Reference to the Microsoft connection + folder (str, optional): Folder to search for drafts (default: "Drafts") + limit (int, optional): Maximum number of drafts to list (default: 20) + expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description + """ + try: + connectionReference = parameters.get("connectionReference") + folder = parameters.get("folder", "Drafts") + limit = parameters.get("limit", 20) + expectedDocumentFormats = parameters.get("expectedDocumentFormats", []) + + if not connectionReference: + return self._createResult( + success=False, + data={}, + error="Connection reference is required" + ) + + # Get Microsoft connection + connection = self._getMicrosoftConnection(connectionReference) + if not connection: + return self._createResult( + success=False, + data={}, + error="No valid Microsoft connection found for the provided connection reference" + ) + + # List drafts using Microsoft Graph API + try: + import requests + + # Microsoft Graph API endpoint for messages + graph_url = "https://graph.microsoft.com/v1.0" + headers = { + "Authorization": f"Bearer {connection['accessToken']}", + "Content-Type": "application/json" + } + + # Get the folder ID for the specified folder + folder_id = self._getFolderId(folder, connection) + + if folder_id: + # List messages in the specific folder + api_url = f"{graph_url}/me/mailFolders/{folder_id}/messages" + logger.info(f"Listing messages in folder '{folder}' (ID: {folder_id})") + else: + # Fallback: list all messages (might include drafts) + api_url = f"{graph_url}/me/messages" + logger.warning(f"Could not find folder '{folder}', listing all messages") + + params = { + "$top": limit, + "$orderby": "lastModifiedDateTime desc", + "$select": "id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,lastModifiedDateTime,parentFolderId,isDraft" + } + + # Make the API call + response = requests.get(api_url, headers=headers, params=params) + response.raise_for_status() + + messages_data = response.json() + messages = messages_data.get("value", []) + + # Filter for drafts if we're looking at all messages + if not folder_id: + drafts = [msg for msg in messages if msg.get("isDraft", False)] + messages = drafts + logger.info(f"Filtered {len(drafts)} drafts from {len(messages_data.get('value', []))} total messages") + + drafts_result = { + "folder": folder, + "folderId": folder_id, + "drafts": messages, + "count": len(messages), + "limit": limit, + "apiResponse": messages_data + } + + logger.info(f"Successfully retrieved {len(messages)} drafts from folder '{folder}'") + + except ImportError: + logger.error("requests module not available, falling back to simulation") + # Fallback to simulation + drafts_prompt = f""" + Simulate listing email drafts in Microsoft Outlook. + + Connection: {connection['id']} + Folder: {folder} + Limit: {limit} + + Please provide: + 1. List of email drafts with subject, recipients, and modification date + 2. Draft status and location information + 3. Summary of draft statistics + """ + drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt) + except Exception as e: + logger.error(f"Error listing drafts via Microsoft Graph API: {str(e)}") + # Fallback to simulation on API error + drafts_prompt = f""" + Simulate listing email drafts in Microsoft Outlook. + + Connection: {connection['id']} + Folder: {folder} + Limit: {limit} + + Please provide: + 1. List of email drafts with subject, recipients, and modification date + 2. Draft status and location information + 3. Summary of draft statistics + """ + drafts_result = await self.service.interfaceAiCalls.callAiTextAdvanced(drafts_prompt) + + # Create result data + result_data = { + "connectionReference": connectionReference, + "folder": folder, + "limit": limit, + "draftsResult": drafts_result, + "connection": { + "id": connection["id"], + "authority": "microsoft", + "reference": connectionReference + }, + "timestamp": datetime.now(UTC).isoformat() + } + + # Determine output format based on expected formats + output_extension = ".json" # Default + output_mime_type = "application/json" # Default + + if expectedDocumentFormats and len(expectedDocumentFormats) > 0: + # Use the first expected format + expected_format = expectedDocumentFormats[0] + output_extension = expected_format.get("extension", ".json") + output_mime_type = expected_format.get("mimeType", "application/json") + logger.info(f"Using expected format: {output_extension} ({output_mime_type})") + else: + logger.info("No expected format specified, using default .json format") + + return self._createResult( + success=True, + data={ + "documents": [ + { + "documentName": f"outlook_drafts_list_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}", + "documentData": result_data, + "mimeType": output_mime_type + } + ] + } + ) + + except Exception as e: + logger.error(f"Error listing drafts: {str(e)}") return self._createResult( success=False, data={}, diff --git a/modules/methods/methodSharepoint.py b/modules/methods/methodSharepoint.py index 884974c3..7205676d 100644 --- a/modules/methods/methodSharepoint.py +++ b/modules/methods/methodSharepoint.py @@ -28,7 +28,17 @@ class MethodSharepoint(MethodBase): """Get Microsoft connection from connection reference""" try: userConnection = self.service.getUserConnectionFromConnectionReference(connectionReference) - if not userConnection or userConnection.authority.value != "msft" or userConnection.status.value != "active": + if not userConnection: + logger.warning(f"No user connection found for reference: {connectionReference}") + return None + + if userConnection.authority.value != "msft": + logger.warning(f"Connection {userConnection.id} is not Microsoft (authority: {userConnection.authority.value})") + return None + + # Check if connection is active or pending (pending means OAuth in progress) + if userConnection.status.value not in ["active", "pending"]: + logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}") return None # Get the corresponding token for this user and authority @@ -37,6 +47,16 @@ class MethodSharepoint(MethodBase): logger.warning(f"No token found for user {userConnection.userId} and authority {userConnection.authority.value}") return None + # Check if token is expired + if hasattr(token, 'expiresAt') and token.expiresAt: + import time + current_time = time.time() + if current_time > token.expiresAt: + logger.warning(f"Token for connection {userConnection.id} is expired (expiresAt: {token.expiresAt}, current: {current_time})") + return None + + logger.info(f"Successfully retrieved Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}") + return { "id": userConnection.id, "accessToken": token.tokenAccess,