# Copyright (c) 2025 Patrick Motsch # Action node executor - maps ai.*, email.*, sharepoint.* to method actions via ActionExecutor. import logging from typing import Dict, Any, List, Optional logger = logging.getLogger(__name__) def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]: """Get node definition by type id for _method, _action, _paramMap.""" from modules.features.automation2.nodeDefinitions import STATIC_NODE_TYPES for node in STATIC_NODE_TYPES: if node.get("id") == nodeType: return node return None def _resolveConnectionIdToReference(chatService, connectionId: str, services=None) -> Optional[str]: """ Resolve connectionId (UserConnection.id) to connectionReference format. connectionReference format: connection:{authority}:{externalUsername} Falls back to interfaceDbApp.getUserConnectionById when chatService resolution fails. """ if not connectionId: return None # Already in reference format if isinstance(connectionId, str) and connectionId.startswith("connection:"): return connectionId # Try chatService first if chatService: try: connections = chatService.getUserConnections() for c in connections or []: conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {}) if str(conn.get("id")) == str(connectionId): authority = conn.get("authority") if hasattr(authority, "value"): authority = authority.value username = conn.get("externalUsername", "") return f"connection:{authority}:{username}" except Exception as e: logger.debug("_resolveConnectionIdToReference chatService: %s", e) # Fallback: interfaceDbApp.getUserConnectionById (automation2 may not have chat.getUserConnections) app = getattr(services, "interfaceDbApp", None) if services else None if app and hasattr(app, "getUserConnectionById"): try: conn = app.getUserConnectionById(str(connectionId)) if conn: authority = getattr(conn, "authority", None) if hasattr(authority, "value"): authority = authority.value else: authority = str(authority) if authority else "outlook" username = getattr(conn, "externalUsername", "") or "" return f"connection:{authority}:{username}" except Exception as e: logger.debug("_resolveConnectionIdToReference getUserConnectionById: %s", e) return None def _extractEmailContentFromUpstream(inp: Any) -> Optional[Dict[str, Any]]: """ Extract {subject, body, to} from upstream node output (e.g. AI node returning JSON). Expects JSON like {"subject": "...", "body": "...", "to": "..."} in documentData. """ if not inp: return None import json docs = inp.get("documents", inp.get("documentList", [])) if isinstance(inp, dict) else [] if not docs: return None doc = docs[0] if isinstance(docs, list) else docs raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None) if not raw: return None try: data = json.loads(raw) if isinstance(raw, str) else raw if isinstance(data, dict) and data.get("subject") and data.get("body"): return { "subject": str(data.get("subject", "")), "body": str(data.get("body", "")), "to": data.get("to"), } except (json.JSONDecodeError, TypeError): pass return None def _extractContextFromUpstream(inp: Any) -> Optional[str]: """ Extract plain text context from upstream node output (e.g. AI node returning txt). Use when _extractEmailContentFromUpstream returns None – the generated document content (email body, summary, etc.) should be passed as context to email.draftEmail. """ if not inp: return None docs = None if isinstance(inp, dict): docs = inp.get("documents") or inp.get("documentList") if not docs and isinstance(inp.get("data"), dict): docs = inp.get("data", {}).get("documents") if not docs or not isinstance(docs, (list, tuple)): return None doc = docs[0] if docs else None if not doc: return None raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") or doc.get("content") if isinstance(doc, dict) else None) if not raw: return None if isinstance(raw, bytes): return raw.decode("utf-8", errors="replace").strip() s = str(raw).strip() return s if s else None def _gatherAttachmentDocumentsFromUpstream( nodeId: str, inputSources: Dict[str, Dict[int, tuple]], nodeOutputs: Dict[str, Any], orderedNodes: List[Dict], visited: Optional[set] = None, ) -> List[Any]: """ Walk upstream from nodeId through AI nodes to collect file documents (e.g. from sharepoint.downloadFile). Used when email.draftEmail has AI upstream – attachments come from file nodes, not AI output. """ visited = visited or set() if nodeId in visited: return [] visited.add(nodeId) docs = [] src = inputSources.get(nodeId, {}).get(0) if not src: return [] srcId, _ = src srcNode = next((n for n in (orderedNodes or []) if n.get("id") == srcId), None) srcType = (srcNode or {}).get("type", "") out = nodeOutputs.get(srcId) if srcType in ("sharepoint.downloadFile", "sharepoint.readFile"): if isinstance(out, dict): for d in out.get("documents") or out.get("documentList") or []: if isinstance(d, dict) and (d.get("documentData") or (d.get("validationMetadata") or {}).get("fileId")): docs.append(d) elif hasattr(d, "documentData") or (getattr(d, "validationMetadata", None) or {}).get("fileId"): docs.append(d.model_dump() if hasattr(d, "model_dump") else d) elif srcType.startswith("ai."): docs.extend( _gatherAttachmentDocumentsFromUpstream(srcId, inputSources, nodeOutputs, orderedNodes, visited) ) return docs def _getIncomingEmailFromUpstream( nodeId: str, inputSources: Dict[str, Dict[int, tuple]], nodeOutputs: Dict[str, Any], orderedNodes: List[Dict], ) -> Optional[tuple]: """ Walk upstream from draftEmail to find email.checkEmail/searchEmail and return (context, documentList). context = formatted incoming email(s) for composeAndDraftEmail. documentList = documents from the email node for attachment/context. """ src = inputSources.get(nodeId, {}).get(0) if not src: return None srcId, _ = src srcNode = next((n for n in (orderedNodes or []) if n.get("id") == srcId), None) srcType = (srcNode or {}).get("type", "") # Direct connection to email node if srcType in ("email.checkEmail", "email.searchEmail"): out = nodeOutputs.get(srcId) return _formatEmailOutputAsContext(out) # Connected via AI node: walk one more step to email source if srcType.startswith("ai."): src2 = inputSources.get(srcId, {}).get(0) if not src2: return None emailNodeId, _ = src2 emailNode = next((n for n in (orderedNodes or []) if n.get("id") == emailNodeId), None) if (emailNode or {}).get("type") in ("email.checkEmail", "email.searchEmail"): out = nodeOutputs.get(emailNodeId) return _formatEmailOutputAsContext(out) return None def _formatEmailOutputAsContext(out: Any) -> Optional[tuple]: """Format email node output as (context, documentList, reply_to) for composeAndDraftEmail. reply_to = sender address of first email (recipient for the reply). """ if not out: return None docs = out.get("documents", out.get("documentList", [])) if isinstance(out, dict) else [] if not docs: return None doc = docs[0] if isinstance(docs, list) else docs raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None) if not raw: return None import json try: data = json.loads(raw) if isinstance(raw, str) else raw except (json.JSONDecodeError, TypeError): return None if not isinstance(data, dict): return None # readEmails: data.emails.emails | searchEmails: data.searchResults.results emails_data = data.get("emails") or {} emails_list = emails_data.get("emails", []) if isinstance(emails_data, dict) else [] if not emails_list: search_results = data.get("searchResults") or {} emails_list = search_results.get("results", []) if isinstance(search_results, dict) else [] if not emails_list: return None reply_to = None parts = ["Reply to the following email(s):", ""] for i, em in enumerate(emails_list[:5]): # max 5 if not isinstance(em, dict): continue fr = em.get("from", em.get("sender", {})) addr = fr.get("emailAddress", {}) if isinstance(fr, dict) else {} from_str = addr.get("address", "") or addr.get("name", "") if from_str and not reply_to: reply_to = addr.get("address", "") or from_str subj = em.get("subject", "") body = em.get("bodyPreview", "") or (em.get("body") or {}).get("content", "") if isinstance(em.get("body"), dict) else "" if body and len(str(body)) > 1500: body = str(body)[:1500] + "..." parts.append(f"From: {from_str}") parts.append(f"Subject: {subj}") parts.append(f"Content:\n{body}") parts.append("") if reply_to: parts.insert(2, f"Recipient (reply to this address): {reply_to}") parts.insert(3, "") context = "\n".join(parts).strip() return (context, docs, reply_to) def _buildSearchQuery( query: str = None, fromAddress: str = None, toAddress: str = None, subjectContains: str = None, bodyContains: str = None, hasAttachment: bool = None, filter: str = None, ) -> str: """ Build Microsoft Graph $search query from discrete params. Uses KQL: from:, to:, subject:, body:, hasattachments: (supported by Graph API). """ if filter and str(filter).strip(): return str(filter).strip() parts = [] if query and str(query).strip(): parts.append(str(query).strip()) if fromAddress and str(fromAddress).strip(): safe = str(fromAddress).strip().replace('"', '') parts.append(f'from:{safe}') if toAddress and str(toAddress).strip(): safe = str(toAddress).strip().replace('"', '') parts.append(f'to:{safe}') if subjectContains and str(subjectContains).strip(): safe = str(subjectContains).strip().replace('"', '') parts.append(f'subject:{safe}') if bodyContains and str(bodyContains).strip(): safe = str(bodyContains).strip().replace('"', '') parts.append(f'body:{safe}') if hasAttachment is True: parts.append("hasattachments:true") return " ".join(parts) if parts else "*" def _buildEmailFilter(fromAddress: str = None, subjectContains: str = None, hasAttachment: bool = None) -> str: """ Build Microsoft Graph API $filter string from discrete email filter params. Used for email.checkEmail (and trigger.newEmail). """ parts = [] if fromAddress and str(fromAddress).strip(): safe = str(fromAddress).strip().replace("'", "''") parts.append(f"from/emailAddress/address eq '{safe}'") if subjectContains and str(subjectContains).strip(): safe = str(subjectContains).strip().replace("'", "''") parts.append(f"contains(subject,'{safe}')") if hasAttachment is True: parts.append("hasAttachments eq true") return " and ".join(parts) if parts else "" def _buildActionParams( node: Dict[str, Any], nodeDef: Dict[str, Any], resolvedParams: Dict[str, Any], chatService, services=None, ) -> Dict[str, Any]: """ Build params for ActionExecutor from node parameters using _paramMap. Resolves connectionId -> connectionReference. Handles _contextFrom for composite params (e.g. email.draftEmail subject+body -> context). """ params = dict(resolvedParams) paramMap = nodeDef.get("_paramMap") or {} contextFrom = nodeDef.get("_contextFrom") or [] # email.checkEmail: build filter from discrete params (fromAddress, subjectContains, hasAttachment) nodeType = node.get("type", "") if nodeType == "email.checkEmail": built = _buildEmailFilter( fromAddress=params.get("fromAddress"), subjectContains=params.get("subjectContains"), hasAttachment=params.get("hasAttachment"), ) raw_filter = (params.get("filter") or "").strip() params["filter"] = built if built else (raw_filter if raw_filter else None) params.pop("fromAddress", None) params.pop("subjectContains", None) params.pop("hasAttachment", None) # email.searchEmail: build query from discrete params (fromAddress, toAddress, subjectContains, bodyContains, hasAttachment) if nodeType == "email.searchEmail": built = _buildSearchQuery( query=params.get("query"), fromAddress=params.get("fromAddress"), toAddress=params.get("toAddress"), subjectContains=params.get("subjectContains"), bodyContains=params.get("bodyContains"), hasAttachment=params.get("hasAttachment"), filter=params.get("filter"), ) params["query"] = built params.pop("fromAddress", None) params.pop("toAddress", None) params.pop("subjectContains", None) params.pop("bodyContains", None) params.pop("hasAttachment", None) params.pop("filter", None) # Resolve connectionId to connectionReference if "connectionId" in params: connId = params.get("connectionId") if connId: ref = _resolveConnectionIdToReference(chatService, connId, services) if ref: params["connectionReference"] = ref else: logger.warning(f"Could not resolve connectionId {connId} to connectionReference") params.pop("connectionId", None) # Build context from multiple params (e.g. subject + body for draft email) if contextFrom: parts = [] for key in contextFrom: val = params.get(key) if val: if key == "subject": parts.append(f"Subject: {val}") elif key == "body": parts.append(f"Body:\n{val}") else: parts.append(str(val)) if parts: params["context"] = "\n\n".join(parts) for k in contextFrom: params.pop(k, None) # Apply paramMap: node param name -> action param name result = {} mappedNodeKeys = {nodeKey for nodeKey, actionKey in paramMap.items() if actionKey and nodeKey in params} for nodeKey, actionKey in paramMap.items(): if nodeKey in params and actionKey: result[actionKey] = params[nodeKey] # Pass through params not used as source for mapping for k, v in params.items(): if k not in mappedNodeKeys and k not in result: result[k] = v return result class ActionNodeExecutor: """Execute ai.*, email.*, sharepoint.* nodes by mapping to method actions.""" def __init__(self, services: Any): self.services = services async def execute( self, node: Dict[str, Any], context: Dict[str, Any], ) -> Any: from modules.features.automation2.nodeRegistry import getNodeTypeToMethodAction from modules.workflows.automation2.graphUtils import resolveParameterReferences from modules.workflows.processing.core.actionExecutor import ActionExecutor nodeType = node.get("type", "") nodeId = node.get("id", "") logger.info("ActionNodeExecutor node %s type=%s", nodeId, nodeType) mapping = getNodeTypeToMethodAction() methodAction = mapping.get(nodeType) if not methodAction: logger.debug("ActionNodeExecutor node %s not in mapping -> None", nodeId) return None methodName, actionName = methodAction logger.info("ActionNodeExecutor node %s method=%s action=%s", nodeId, methodName, actionName) nodeDef = _getNodeDefinition(nodeType) params = dict(node.get("parameters") or {}) resolvedParams = resolveParameterReferences(params, context.get("nodeOutputs", {})) # Merge input from connected nodes (documentList, etc.) inputSources = context.get("inputSources", {}).get(nodeId, {}) if 0 in inputSources: srcId, _ = inputSources[0] inp = context.get("nodeOutputs", {}).get(srcId) if isinstance(inp, dict): resolvedParams.setdefault("documentList", inp.get("documents", inp.get("documentList", []))) elif inp is not None: resolvedParams.setdefault("input", inp) # ai.prompt with email upstream: inject actual email content into prompt so AI has context # (getChatDocumentsFromDocumentList fails in automation2 – workflow has no messages) if nodeType.startswith("ai."): orderedNodes = context.get("_orderedNodes") or [] if 0 in inputSources: srcId, _ = inputSources[0] srcNode = next((n for n in orderedNodes if n.get("id") == srcId), None) srcType = (srcNode or {}).get("type", "") if srcType in ("email.checkEmail", "email.searchEmail"): incoming = _getIncomingEmailFromUpstream( nodeId, context.get("inputSources", {}), context.get("nodeOutputs", {}), orderedNodes, ) if incoming: ctx, _doc_list, _reply_to = incoming if ctx and ctx.strip(): base_prompt = (resolvedParams.get("aiPrompt") or "").strip() resolvedParams["aiPrompt"] = ( f"Eingehende E-Mail:\n{ctx}\n\nAufgabe: {base_prompt}" if base_prompt else f"Eingehende E-Mail:\n{ctx}" ) logger.debug("ai.prompt: injected email context from upstream %s", srcType) chatService = getattr(self.services, "chat", None) actionParams = _buildActionParams(node, nodeDef or {}, resolvedParams, chatService, self.services) # email.checkEmail: pause and wait for new email (background poller will resume) if nodeType == "email.checkEmail": runId = context.get("_runId") workflowId = context.get("workflowId") connRef = actionParams.get("connectionReference") if runId and workflowId and connRef: from modules.workflows.automation2.executors import PauseForEmailWaitError waitConfig = { "connectionReference": connRef, "folder": actionParams.get("folder", "Inbox"), "limit": min(int(actionParams.get("limit") or 10), 50), "filter": actionParams.get("filter"), } raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig) # Fallback: no pause (calls readEmails directly) – needs runId, workflowId, connectionReference if not runId or not workflowId: logger.warning( "email.checkEmail not pausing (runId=%s workflowId=%s) – run must be saved/executed as workflow", runId, workflowId, ) elif not connRef: logger.warning( "email.checkEmail not pausing – connectionReference missing (check connectionId/config)", ) # email.draftEmail: use AI output as emailContent if available; else pass incoming email as context if nodeType == "email.draftEmail": inputSources = context.get("inputSources", {}) nodeOutputs = context.get("nodeOutputs", {}) orderedNodes = context.get("_orderedNodes") or [] if 0 in inputSources.get(nodeId, {}): srcId, _ = inputSources[nodeId][0] srcNode = next((n for n in orderedNodes if n.get("id") == srcId), None) srcType = (srcNode or {}).get("type", "") if srcType.startswith("ai."): inp = nodeOutputs.get(srcId) email_content = _extractEmailContentFromUpstream(inp) if email_content: actionParams["emailContent"] = email_content actionParams["context"] = email_content.get("body", "") or "(from connected AI node)" # Attachments: gather from file nodes upstream of AI (e.g. downloadFile -> AI -> email) attachment_docs = _gatherAttachmentDocumentsFromUpstream( nodeId, inputSources, nodeOutputs, orderedNodes ) if attachment_docs: existing = actionParams.get("documentList") or [] # Prefer file docs from upstream; append any existing that look like binary attachments def _is_binary_attachment(d): if isinstance(d, dict) and d.get("documentData"): try: import json json.loads(d["documentData"]) return False # JSON = email content, not attachment except (TypeError, ValueError): return True return bool(isinstance(d, dict) and (d.get("validationMetadata") or {}).get("fileId")) extra = [x for x in (existing if isinstance(existing, list) else []) if _is_binary_attachment(x)] actionParams["documentList"] = attachment_docs + extra if not email_content: # AI returns plain text (e.g. email.txt): use as email body directly (no extra AI call) ctx = _extractContextFromUpstream(inp) if ctx: actionParams["emailContent"] = { "subject": actionParams.get("subject", "Draft"), "body": ctx, "to": actionParams.get("to"), } actionParams["context"] = ctx else: # Fallback: incoming email from upstream (if flow is email->AI->draft) incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes) if incoming: ctx, doc_list, reply_to = incoming actionParams["context"] = ctx if doc_list and not actionParams.get("documentList"): actionParams["documentList"] = doc_list if reply_to and not actionParams.get("to"): actionParams["to"] = [reply_to] else: doc_count = len(inp.get("documents", [])) if isinstance(inp, dict) else 0 logger.warning( "email.draftEmail: AI upstream returned %d doc(s) but context extraction failed (no subject/body, no plain text). " "Ensure AI node outputs document with documentData.", doc_count, ) actionParams["context"] = "(no context extracted from upstream – check AI node output)" elif srcType in ("sharepoint.downloadFile", "sharepoint.readFile"): # File itself is the context: pass as attachment, use filename as minimal context (no content extraction) if not actionParams.get("context"): inp = nodeOutputs.get(srcId) docs = (inp.get("documents") or inp.get("documentList", [])) if isinstance(inp, dict) else [] doc = docs[0] if docs else None name = None if isinstance(doc, dict): name = doc.get("documentName") or doc.get("fileName") elif doc and hasattr(doc, "documentName"): name = getattr(doc, "documentName", None) or getattr(doc, "fileName", None) ctx = name if name else "Attachment" actionParams["context"] = ctx actionParams["emailContent"] = { "subject": actionParams.get("subject", "Draft"), "body": ctx, "to": actionParams.get("to"), } # documentList already merged from upstream (file as attachment) else: # Direct connection to email.checkEmail/searchEmail: use incoming email as context if not actionParams.get("context"): incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes) if incoming: ctx, doc_list, reply_to = incoming actionParams["context"] = ctx if doc_list and not actionParams.get("documentList"): actionParams["documentList"] = doc_list if reply_to and not actionParams.get("to"): actionParams["to"] = [reply_to] # Generic context handover: when upstream provides documents, pass first doc as content for actions that expect it docList = actionParams.get("documentList") or resolvedParams.get("documentList") if docList and "content" not in actionParams: first = docList[0] if isinstance(docList, list) and docList else docList # Actions like sharepoint.uploadFile consume content from context actionParams["content"] = first executor = ActionExecutor(self.services) logger.info("ActionNodeExecutor node %s calling executeAction(%s, %s)", nodeId, methodName, actionName) result = await executor.executeAction(methodName, actionName, actionParams) out = { "success": result.success, "error": result.error, "documents": [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])], "data": result.model_dump() if hasattr(result, "model_dump") else {"success": result.success, "error": result.error}, } logger.info( "ActionNodeExecutor node %s result: success=%s error=%s doc_count=%d", nodeId, result.success, result.error, len(out.get("documents", [])), ) return out