457 lines
20 KiB
Python
457 lines
20 KiB
Python
# 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) -> Optional[str]:
|
||
"""
|
||
Resolve connectionId (UserConnection.id) to connectionReference format.
|
||
connectionReference format: connection:{authority}:{externalUsername}
|
||
"""
|
||
if not connectionId or not chatService:
|
||
return None
|
||
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}"
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(f"Could not resolve connectionId {connectionId} to reference: {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 _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,
|
||
) -> 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 and chatService:
|
||
ref = _resolveConnectionIdToReference(chatService, connId)
|
||
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)
|
||
|
||
# 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.setdefault("context", "(from connected AI node)")
|
||
else:
|
||
# AI failed or wrong format: pass incoming email from upstream as 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]
|
||
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]
|
||
|
||
# sharepoint.uploadFile: content from documentList (upstream) if not in params
|
||
if nodeType == "sharepoint.uploadFile" and "content" not in actionParams:
|
||
docList = actionParams.get("documentList") or resolvedParams.get("documentList")
|
||
if docList:
|
||
actionParams["content"] = docList[0] if isinstance(docList, list) and docList else docList
|
||
|
||
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
|