# Copyright (c) 2025 Patrick Motsch # Action node executor - maps ai.*, email.*, sharepoint.*, clickup.* to method actions via ActionExecutor. # # Unified handover format for all nodes: # - Node output: { success, error?, documents, documentList, data } – documents and documentList are identical # - Input merge: downstream receives documents via _getDocumentsFromUpstream(inp) – reads documents or documentList # - Incoming email handover: (context, documentList, reply_to, subject) via _formatEmailOutputAsContext / _unpackIncomingEmail import json import logging import re from typing import Dict, Any, List, Optional logger = logging.getLogger(__name__) # UserConnection.id (UUID) when connectionId could not be mapped to connection:authority:username _USER_CONNECTION_ID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE, ) def _is_user_connection_id(val: Any) -> bool: if val is None or isinstance(val, (dict, list)): return False s = str(val).strip() return bool(_USER_CONNECTION_ID_RE.match(s)) def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]: """Get node definition by type id for _method, _action, _paramMap.""" from modules.features.graphicalEditor.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. Uses unified handover: documents/documentList. """ if not inp: return None import json docs = _getDocumentsFromUpstream(inp) 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. Uses unified handover: documents/documentList. """ if not inp: return None docs = _getDocumentsFromUpstream(inp) if not docs: 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 _payloadToContext(payload: Any) -> Optional[str]: """Convert payload (e.g. from form) to readable text for document context.""" if payload is None: return None if isinstance(payload, str) and payload.strip(): return payload.strip() if isinstance(payload, dict): try: import json return json.dumps(payload, ensure_ascii=False, indent=2) except (TypeError, ValueError): lines = [f"{k}: {v}" for k, v in payload.items()] return "\n".join(lines) if lines else None return str(payload).strip() if str(payload).strip() else None def _getContextFromUpstream(out: Any) -> Optional[str]: """ Get context from upstream node output. Prefers explicit 'context' field; falls back to documents/documentList (first doc's documentData), then payload. Handles: AI (context), form (payload or top-level field dict), upload (document refs). """ if not out or not isinstance(out, dict): return None ctx = out.get("context") if isinstance(ctx, str) and ctx.strip(): return ctx.strip() doc_ctx = _extractContextFromUpstream(out) if doc_ctx: return doc_ctx payload = out.get("payload") if payload is not None: return _payloadToContext(payload) if "documents" not in out and "documentList" not in out and "success" not in out: return _payloadToContext(out) return None def _extractContextFromResult(result: Any) -> Optional[str]: """ Extract plain text context from ActionResult (ActionExecutor result). Used to populate 'context' in unified output for AI nodes. """ if not result or not hasattr(result, "documents"): return None docs = result.documents or [] if not docs: return None doc = docs[0] raw = getattr(doc, "documentData", None) if hasattr(doc, "documentData") else (doc.get("documentData") if isinstance(doc, dict) else None) if not raw: return None if isinstance(raw, bytes): return raw.decode("utf-8", errors="replace").strip() return str(raw).strip() if str(raw).strip() 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 _getDocumentsFromUpstream(out): 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 _getDocumentsFromUpstream(out: Any) -> list: """Unified: extract documents list from any node output. Supports: documents, documentList, data.documents. Also: input.upload result format { file, files, fileIds } - converts to doc refs with validationMetadata.fileId. """ if not out or not isinstance(out, dict): return [] docs = out.get("documents") or out.get("documentList") if not docs and isinstance(out.get("data"), dict): docs = out.get("data", {}).get("documents") or out.get("data", {}).get("documentList") if not docs: # input.upload task result: { file: {id, fileName}, files: [...], fileIds: [...] } def _file_to_doc(f: Any) -> Optional[Dict[str, Any]]: if isinstance(f, dict): fid = f.get("id") fname = f.get("fileName") or f.get("filename") or "file" if fid: return { "documentName": fname, "fileName": fname, "validationMetadata": {"fileId": str(fid)}, } elif isinstance(f, str): return {"documentName": "file", "fileName": "file", "validationMetadata": {"fileId": f}} return None file_obj = out.get("file") files_arr = out.get("files") or [] file_ids = out.get("fileIds") or [] if file_obj: d = _file_to_doc(file_obj) if d: docs = [d] if not docs and files_arr: docs = [d for f in files_arr for d in [_file_to_doc(f)] if d] if not docs and file_ids: docs = [_file_to_doc(fid) for fid in file_ids if _file_to_doc(fid)] if not docs: return [] return docs if isinstance(docs, (list, tuple)) else [docs] def _unpackIncomingEmail(incoming: Optional[tuple]) -> Optional[tuple]: """ Unified handover: (context, documentList, reply_to, subject). Returns (ctx, doc_list, reply_to, subject) or None. """ if not incoming or not isinstance(incoming, (list, tuple)): return None ctx = incoming[0] if len(incoming) > 0 else None doc_list = incoming[1] if len(incoming) > 1 else [] reply_to = incoming[2] if len(incoming) > 2 else None subject = incoming[3] if len(incoming) > 3 else "" return (ctx, doc_list or [], reply_to, subject) 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, subject) for composeAndDraftEmail. reply_to = sender address of first email (recipient for the reply). subject = original subject (for Re: prefix). Returns unified handover: (text, files/docs, reply_to, subject). """ if not out: return None docs = _getDocumentsFromUpstream(out) 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 first_subject = "" 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", "") if subj and not first_subject: first_subject = subj 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, first_subject) 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 elif _is_user_connection_id(connId): # Automation2 worker often has no chat user connection list; pass UUID through — # method helpers (e.g. ClickupConnectionHelper) resolve via interfaceDbApp.getUserConnectionById. params["connectionReference"] = str(connId).strip() 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.*, clickup.* 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.graphicalEditor.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", {})) if nodeType == "clickup.updateTask": from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries merge_clickup_task_update_entries(resolvedParams) # Merge input from connected nodes (unified handover: documents/documentList, context) inputSources = context.get("inputSources", {}).get(nodeId, {}) if 0 in inputSources: srcId, _ = inputSources[0] inp = context.get("nodeOutputs", {}).get(srcId) docs = _getDocumentsFromUpstream(inp) if isinstance(inp, dict) else [] if docs: resolvedParams.setdefault("documentList", docs) elif inp is not None: resolvedParams.setdefault("input", inp) # file.create: build context from contentSources (concatenated) or fallback to upstream if nodeType == "file.create": sources = resolvedParams.get("contentSources") if not isinstance(sources, list): sources = [resolvedParams.get("contentSource")] if resolvedParams.get("contentSource") else [] parts = [] for s in sources: if s is None or s == "": continue if isinstance(s, str): txt = s.strip() elif isinstance(s, dict): txt = _payloadToContext(s) if s else "" else: txt = str(s) if txt: parts.append(txt) upstream_context = _getContextFromUpstream(inp) if parts: parts_joined = "\n\n".join(parts) # When upstream is AI and user only selected prompt, use full context (prompt + response) if ( isinstance(inp, dict) and upstream_context and len(upstream_context) > len(parts_joined) ): prompt_only = (inp.get("prompt") or "").strip() if prompt_only and parts_joined.strip() == prompt_only: resolvedParams["context"] = upstream_context else: resolvedParams["context"] = parts_joined else: resolvedParams["context"] = parts_joined else: if upstream_context: resolvedParams["context"] = upstream_context # 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 = _unpackIncomingEmail(_getIncomingEmailFromUpstream( nodeId, context.get("inputSources", {}), context.get("nodeOutputs", {}), orderedNodes, )) if incoming: ctx, _doc_list, _reply_to, _ = incoming if ctx and ctx.strip(): # Set "prompt" so _paramMap (prompt→aiPrompt) passes it through to ai.process base_prompt = ( (resolvedParams.get("prompt") or resolvedParams.get("aiPrompt") or "") ).strip() resolvedParams["prompt"] = ( 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) # ai.prompt: use simpleMode by default – direct AI call, no document pipeline (chapters/sections) # For short prompts like "formuliere eine passende email" this avoids ~13 AI calls and verbose output if nodeType == "ai.prompt" and "simpleMode" not in actionParams: actionParams["simpleMode"] = True # 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) # Reply flow: get incoming email metadata (replyTo, subject, original docs) when email->AI->draft incoming = _unpackIncomingEmail(_getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)) reply_to = None reply_subject = None reply_docs = [] if incoming: inc_ctx, doc_list, reply_to, first_subject = incoming reply_docs = doc_list reply_subject = ("Re: " + first_subject) if first_subject else None if email_content: # Merge reply metadata when available merged = dict(email_content) if reply_to and not merged.get("to"): merged["to"] = reply_to if isinstance(reply_to, list) else [reply_to] if reply_subject and not merged.get("subject"): merged["subject"] = reply_subject actionParams["emailContent"] = merged actionParams["context"] = merged.get("body", "") or "(from connected AI node)" if reply_docs: actionParams["replySourceDocuments"] = reply_docs # 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 or context: use as email body directly (no extra AI call) ctx = _getContextFromUpstream(inp) if ctx: # Reply flow: get incoming email metadata (replyTo, subject, original docs) incoming = _unpackIncomingEmail(_getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)) reply_to = None reply_subject = None reply_docs = [] if incoming: inc_ctx, doc_list, reply_to, first_subject = incoming reply_docs = doc_list reply_subject = ("Re: " + first_subject) if first_subject else None actionParams["emailContent"] = { "subject": reply_subject or actionParams.get("subject", "Draft"), "body": ctx, "to": [reply_to] if reply_to else (actionParams.get("to") or []), } actionParams["context"] = ctx if reply_to and not actionParams.get("to"): actionParams["to"] = [reply_to] # Reply flow: attach original email(s) for proper reply if reply_docs: actionParams["replySourceDocuments"] = reply_docs else: # Fallback: incoming email from upstream (AI returned nothing usable) incoming = _unpackIncomingEmail(_getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)) if incoming: inc_ctx, doc_list, reply_to, first_subject = incoming actionParams["context"] = inc_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] if first_subject and not actionParams.get("subject"): actionParams["subject"] = "Re: " + first_subject actionParams["replySourceDocuments"] = doc_list else: doc_count = len(_getDocumentsFromUpstream(inp)) 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 = _getDocumentsFromUpstream(inp) 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 = _unpackIncomingEmail(_getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)) if incoming: inc_ctx, doc_list, reply_to, first_subject = incoming actionParams["context"] = inc_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] if first_subject and not actionParams.get("subject"): actionParams["subject"] = "Re: " + first_subject actionParams["replySourceDocuments"] = doc_list # 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 / clickup.uploadAttachment 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) # Extract context from result for unified output (AI text for downstream file nodes) extracted_context = _extractContextFromResult(result) if result else None # AI nodes: include prompt in output; context = prompt + AI response (für file.create etc.) prompt_text = (resolvedParams.get("prompt") or resolvedParams.get("aiPrompt") or "") if not isinstance(prompt_text, str): prompt_text = str(prompt_text) if prompt_text else "" prompt_text = (prompt_text or "").strip() if nodeType.startswith("ai.") and prompt_text: full_context = ( f"{prompt_text}\n\n{extracted_context}" if extracted_context else prompt_text ) else: full_context = extracted_context or "" out_prompt = prompt_text if nodeType.startswith("ai.") else "" docs_list = [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])] # result = AI response text (for contentSources refs: prompt + context + result = full output, optionally duplicated) out_result = extracted_context if nodeType.startswith("ai.") else None out = { "success": result.success, "error": result.error, "documents": docs_list, "documentList": docs_list, "prompt": out_prompt, "context": full_context, "result": out_result, "data": result.model_dump() if hasattr(result, "model_dump") else {"success": result.success, "error": result.error}, } if result.success and docs_list and nodeType.startswith("clickup."): try: d0 = docs_list[0] if isinstance(docs_list[0], dict) else {} raw = d0.get("documentData") if isinstance(raw, str) and raw.strip(): parsed = json.loads(raw) if isinstance(parsed, dict) and parsed.get("id") is not None: out["taskId"] = str(parsed["id"]) out["clickupTask"] = parsed except (json.JSONDecodeError, TypeError, ValueError): pass logger.info( "ActionNodeExecutor node %s result: success=%s error=%s doc_count=%d", nodeId, result.success, result.error, len(out.get("documents", [])), ) return out