From d0f8444bacfdb26aafb2bf9044cb0b07c0907938 Mon Sep 17 00:00:00 2001 From: idittrich-valueon Date: Wed, 25 Mar 2026 09:38:06 +0100 Subject: [PATCH] next version of visual workflow editor with ClickUp Connections --- app.py | 16 + env_dev.env | 5 + env_int.env | 5 + env_prod.env | 5 + modules/auth/csrf.py | 2 + modules/connectors/connectorDbPostgre.py | 3 + modules/connectors/connectorResolver.py | 6 + modules/connectors/connectorTicketsClickup.py | 3 +- .../connectors/providerClickup/__init__.py | 7 + .../providerClickup/connectorClickup.py | 268 ++++++++++++++ modules/datamodels/datamodelDataSource.py | 4 +- modules/datamodels/datamodelUam.py | 8 +- .../datamodelFeatureAutomation2.py | 6 + modules/features/automation2/entryPoints.py | 96 +++++ .../interfaceFeatureAutomation2.py | 191 ++++++++-- .../features/automation2/mainAutomation2.py | 4 + .../automation2/nodeDefinitions/__init__.py | 6 +- .../automation2/nodeDefinitions/ai.py | 2 - .../automation2/nodeDefinitions/clickup.py | 227 ++++++++++++ .../automation2/nodeDefinitions/data.py | 58 --- .../automation2/nodeDefinitions/file.py | 60 +++ .../automation2/nodeDefinitions/flow.py | 38 +- .../automation2/nodeDefinitions/input.py | 9 +- .../automation2/nodeDefinitions/triggers.py | 62 +++- modules/features/automation2/nodeRegistry.py | 7 + .../automation2/routeFeatureAutomation2.py | 254 ++++++++++++- .../workspace/routeFeatureWorkspace.py | 1 + modules/routes/routeClickup.py | 288 +++++++++++++++ modules/routes/routeDataConnections.py | 8 +- modules/routes/routeSecurityClickup.py | 280 ++++++++++++++ modules/serviceCenter/registry.py | 7 + .../services/serviceAgent/mainServiceAgent.py | 1 + .../services/serviceClickup/__init__.py | 7 + .../serviceClickup/mainServiceClickup.py | 223 +++++++++++ .../renderers/rendererDocx.py | 5 +- .../renderers/rendererText.py | 36 +- .../serviceGeneration/subDocumentUtility.py | 145 ++++++++ modules/shared/eventManagement.py | 31 +- .../automation2/clickupTaskUpdateMerge.py | 174 +++++++++ .../workflows/automation2/executionEngine.py | 158 +++++++- .../automation2/executors/__init__.py | 2 - .../executors/actionNodeExecutor.py | 338 +++++++++++++++-- .../automation2/executors/dataExecutor.py | 120 ------ .../automation2/executors/flowExecutor.py | 256 +++++++++---- .../automation2/executors/ioExecutor.py | 10 +- .../automation2/executors/triggerExecutor.py | 41 +- modules/workflows/automation2/graphUtils.py | 72 +++- modules/workflows/automation2/runEnvelope.py | 109 ++++++ modules/workflows/automation2/scheduleCron.py | 34 ++ .../automation2/subAutomation2Schedule.py | 304 +++++++++++++++ .../methods/methodClickup/__init__.py | 6 + .../methods/methodClickup/actions/__init__.py | 3 + .../methodClickup/actions/create_task.py | 213 +++++++++++ .../methods/methodClickup/actions/get_task.py | 40 ++ .../methodClickup/actions/list_tasks.py | 51 +++ .../methodClickup/actions/search_tasks.py | 221 +++++++++++ .../methodClickup/actions/update_task.py | 57 +++ .../actions/upload_attachment.py | 88 +++++ .../methods/methodClickup/helpers/__init__.py | 2 + .../methodClickup/helpers/connection.py | 50 +++ .../methodClickup/helpers/pathparse.py | 26 ++ .../methods/methodClickup/methodClickup.py | 349 ++++++++++++++++++ .../workflows/methods/methodFile/__init__.py | 6 + .../methods/methodFile/actions/__init__.py | 6 + .../methods/methodFile/actions/create.py | 147 ++++++++ .../methods/methodFile/methodFile.py | 81 ++++ .../composeAndDraftEmailWithContext.py | 77 +++- .../methodSharepoint/actions/uploadFile.py | 12 +- modules/workflows/workflowManager.py | 2 +- .../workflows/test_automation2_graphUtils.py | 62 ++++ 70 files changed, 5058 insertions(+), 443 deletions(-) create mode 100644 modules/connectors/providerClickup/__init__.py create mode 100644 modules/connectors/providerClickup/connectorClickup.py create mode 100644 modules/features/automation2/entryPoints.py create mode 100644 modules/features/automation2/nodeDefinitions/clickup.py delete mode 100644 modules/features/automation2/nodeDefinitions/data.py create mode 100644 modules/features/automation2/nodeDefinitions/file.py create mode 100644 modules/routes/routeClickup.py create mode 100644 modules/routes/routeSecurityClickup.py create mode 100644 modules/serviceCenter/services/serviceClickup/__init__.py create mode 100644 modules/serviceCenter/services/serviceClickup/mainServiceClickup.py create mode 100644 modules/workflows/automation2/clickupTaskUpdateMerge.py delete mode 100644 modules/workflows/automation2/executors/dataExecutor.py create mode 100644 modules/workflows/automation2/runEnvelope.py create mode 100644 modules/workflows/automation2/scheduleCron.py create mode 100644 modules/workflows/automation2/subAutomation2Schedule.py create mode 100644 modules/workflows/methods/methodClickup/__init__.py create mode 100644 modules/workflows/methods/methodClickup/actions/__init__.py create mode 100644 modules/workflows/methods/methodClickup/actions/create_task.py create mode 100644 modules/workflows/methods/methodClickup/actions/get_task.py create mode 100644 modules/workflows/methods/methodClickup/actions/list_tasks.py create mode 100644 modules/workflows/methods/methodClickup/actions/search_tasks.py create mode 100644 modules/workflows/methods/methodClickup/actions/update_task.py create mode 100644 modules/workflows/methods/methodClickup/actions/upload_attachment.py create mode 100644 modules/workflows/methods/methodClickup/helpers/__init__.py create mode 100644 modules/workflows/methods/methodClickup/helpers/connection.py create mode 100644 modules/workflows/methods/methodClickup/helpers/pathparse.py create mode 100644 modules/workflows/methods/methodClickup/methodClickup.py create mode 100644 modules/workflows/methods/methodFile/__init__.py create mode 100644 modules/workflows/methods/methodFile/actions/__init__.py create mode 100644 modules/workflows/methods/methodFile/actions/create.py create mode 100644 modules/workflows/methods/methodFile/methodFile.py create mode 100644 tests/unit/workflows/test_automation2_graphUtils.py diff --git a/app.py b/app.py index 8268377a..b88e6a15 100644 --- a/app.py +++ b/app.py @@ -21,6 +21,7 @@ from datetime import datetime from modules.shared.configuration import APP_CONFIG from modules.shared.eventManagement import eventManager from modules.workflows.automation import subAutomationSchedule +from modules.workflows.automation2 import subAutomation2Schedule from modules.features.automation2.emailPoller import start as startAutomation2EmailPoller from modules.features.automation2.emailPoller import stop as stopAutomation2EmailPoller from modules.interfaces.interfaceDbApp import getRootInterface @@ -355,7 +356,15 @@ async def lifespan(app: FastAPI): logger.warning(f"Could not initialize feature containers: {e}") # --- Init Managers --- + import asyncio + try: + main_loop = asyncio.get_running_loop() + eventManager.set_event_loop(main_loop) + subAutomation2Schedule.set_main_loop(main_loop) + except RuntimeError: + pass subAutomationSchedule.start(eventUser) # Automation scheduler + subAutomation2Schedule.start(eventUser) # Automation2 schedule trigger (cron) # Automation2 email poller: started on-demand when a run pauses for email.checkEmail eventManager.start() @@ -386,6 +395,7 @@ async def lifespan(app: FastAPI): # --- Stop Managers --- stopAutomation2EmailPoller(eventUser) # Automation2 email poller (no-op if not running) + subAutomation2Schedule.stop(eventUser) # Automation2 schedule eventManager.stop() subAutomationSchedule.stop(eventUser) # Automation scheduler @@ -560,6 +570,12 @@ app.include_router(msftRouter) from modules.routes.routeSecurityGoogle import router as googleRouter app.include_router(googleRouter) +from modules.routes.routeSecurityClickup import router as clickupRouter +app.include_router(clickupRouter) + +from modules.routes.routeClickup import router as clickupApiRouter +app.include_router(clickupApiRouter) + from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter app.include_router(voiceGoogleRouter) diff --git a/env_dev.env b/env_dev.env index e6643ca9..2a6d715c 100644 --- a/env_dev.env +++ b/env_dev.env @@ -46,6 +46,11 @@ Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.ap Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM= Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback +# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. +Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 +Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0 +Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback + # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09 STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGcHNWTWpBWkFHRExtdU01N3RyZzNsMjhUS3NiVTNCZmMwN2NEcFZ6UkQ1a2I0aUkyNU4wR2dUdHJXYmtkaEFRUnFpcThObHBEQmJkdEFnT1FXeUxOTlU3UDFNRzl6LWdpRFpYdExvY3FTTG9MTkswdEhrVkNKQVFucnBjSnhLNm4= diff --git a/env_int.env b/env_int.env index d7105469..5f331e5c 100644 --- a/env_int.env +++ b/env_int.env @@ -46,6 +46,11 @@ Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.ap Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo= Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback +# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. +Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 +Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0 +Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback + # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = 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 STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnBudkpGamJBNW91VUdEaThWRTFiTWpyb3NqSDJJcGtjNkhUVVZqVElxUWExY05KcllSYVk1SkRuS1NjYWpZUk1uU29nb2pzdXUxRzBsOEgyRWtmUEw3dUF4ejFIXzNwTVZRM1R1bVVhTUs4ZHJMT0V4Xy1pcHVfWlBaQV9wVXo5MGlQYXA= diff --git a/env_prod.env b/env_prod.env index f10b996e..a4bdea05 100644 --- a/env_prod.env +++ b/env_prod.env @@ -46,6 +46,11 @@ Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.ap Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o= Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback +# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly. +Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4 +Service_CLICKUP_CLIENT_SECRET = CZECD706WLSX6UV13YI4ACNW50ADZHHXDAJALHE0YE030QFSI6Y9HP4Y61JT7CF0 +Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback + # Stripe Billing (both end with _SECRET for encryption script) STRIPE_SECRET_KEY_SECRET = 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 STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= diff --git a/modules/auth/csrf.py b/modules/auth/csrf.py index ba21435b..7cc0c07c 100644 --- a/modules/auth/csrf.py +++ b/modules/auth/csrf.py @@ -35,6 +35,8 @@ class CSRFMiddleware(BaseHTTPMiddleware): "/api/google/auth/login/callback", "/api/google/auth/connect", "/api/google/auth/connect/callback", + "/api/clickup/auth/connect", + "/api/clickup/auth/connect/callback", "/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature) } diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 67cceb45..f0e38fdd 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -1026,6 +1026,9 @@ class DatabaseConnector: continue colType = fields.get(key, "TEXT") logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}") + if val is None: + where_parts.append(f'"{key}" IS NULL') + continue if isinstance(val, dict): op = val.get("operator", "equals") v = val.get("value", "") diff --git a/modules/connectors/connectorResolver.py b/modules/connectors/connectorResolver.py index 4304378e..8ffdd73f 100644 --- a/modules/connectors/connectorResolver.py +++ b/modules/connectors/connectorResolver.py @@ -52,6 +52,12 @@ class ConnectorResolver: except ImportError: logger.debug("FtpConnector not available (stub)") + try: + from modules.connectors.providerClickup.connectorClickup import ClickupConnector + ConnectorResolver._providerRegistry["clickup"] = ClickupConnector + except ImportError: + logger.warning("ClickupConnector not available") + async def resolve(self, connectionId: str) -> ProviderConnector: """Resolve connectionId to a ProviderConnector with a fresh access token.""" connection = await self._loadConnection(connectionId) diff --git a/modules/connectors/connectorTicketsClickup.py b/modules/connectors/connectorTicketsClickup.py index 37480aa9..af02b44a 100644 --- a/modules/connectors/connectorTicketsClickup.py +++ b/modules/connectors/connectorTicketsClickup.py @@ -9,6 +9,7 @@ from typing import Optional import logging import aiohttp from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute +from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header logger = logging.getLogger(__name__) @@ -30,7 +31,7 @@ class ConnectorTicketClickup(TicketBase): def _headers(self) -> dict: return { - "Authorization": self.apiToken, + "Authorization": clickup_authorization_header(self.apiToken), "Content-Type": "application/json", } diff --git a/modules/connectors/providerClickup/__init__.py b/modules/connectors/providerClickup/__init__.py new file mode 100644 index 00000000..12439593 --- /dev/null +++ b/modules/connectors/providerClickup/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp provider connector.""" + +from .connectorClickup import ClickupConnector + +__all__ = ["ClickupConnector"] diff --git a/modules/connectors/providerClickup/connectorClickup.py b/modules/connectors/providerClickup/connectorClickup.py new file mode 100644 index 00000000..cd49570e --- /dev/null +++ b/modules/connectors/providerClickup/connectorClickup.py @@ -0,0 +1,268 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows). + +Path convention (leading slash, no trailing slash except root): + / — authorized workspaces (teams) + /team/{teamId} — spaces in the workspace + /team/{teamId}/space/{spaceId} — folders + folderless lists + /team/{teamId}/space/{spaceId}/folder/{folderId} — lists in folder + /team/{teamId}/list/{listId} — tasks in list (rows) + /team/{teamId}/list/{listId}/task/{taskId} — single task (download = JSON) +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any, Dict, List, Optional + +from modules.connectors.connectorProviderBase import ( + ProviderConnector, + ServiceAdapter, + DownloadResult, +) +from modules.datamodels.datamodelDataSource import ExternalEntry +from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService + +logger = logging.getLogger(__name__) + +# type metadata for ExternalEntry.metadata["cuType"] +_CU_TEAM = "team" +_CU_SPACE = "space" +_CU_FOLDER = "folder" +_CU_LIST = "list" +_CU_TASK = "task" + + +def _norm(path: str) -> str: + p = (path or "").strip() or "/" + if not p.startswith("/"): + p = "/" + p + if p != "/" and p.endswith("/"): + p = p.rstrip("/") + return p + + +class ClickupListsAdapter(ServiceAdapter): + """Maps ClickUp hierarchy + list tasks to browse/download/upload/search.""" + + def __init__(self, access_token: str): + self._token = access_token + # Minimal service instance for API calls (no ServiceCenter context) + self._svc = ClickupService(context=None, get_service=lambda _: None) + self._svc.setAccessToken(access_token) + + async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]: + p = _norm(path) + out: List[ExternalEntry] = [] + + if p == "/": + data = await self._svc.getAuthorizedTeams() + if isinstance(data, dict) and data.get("error"): + logger.warning(f"ClickUp browse root: {data.get('error')}") + return [] + teams = data.get("teams", []) if isinstance(data, dict) else [] + for t in teams: + tid = str(t.get("id", "")) + name = t.get("name") or tid + out.append( + ExternalEntry( + name=name, + path=f"/team/{tid}", + isFolder=True, + metadata={"cuType": _CU_TEAM, "id": tid, "raw": t}, + ) + ) + return out + + m = re.match(r"^/team/([^/]+)$", p) + if m: + team_id = m.group(1) + data = await self._svc.getSpaces(team_id) + if isinstance(data, dict) and data.get("error"): + return [] + spaces = data.get("spaces", []) if isinstance(data, dict) else [] + for s in spaces: + sid = str(s.get("id", "")) + name = s.get("name") or sid + out.append( + ExternalEntry( + name=name, + path=f"/team/{team_id}/space/{sid}", + isFolder=True, + metadata={"cuType": _CU_SPACE, "id": sid, "raw": s}, + ) + ) + return out + + m = re.match(r"^/team/([^/]+)/space/([^/]+)$", p) + if m: + team_id, space_id = m.group(1), m.group(2) + folders_r = await self._svc.getFolders(space_id) + lists_r = await self._svc.getFolderlessLists(space_id) + if isinstance(folders_r, dict) and not folders_r.get("error"): + for f in folders_r.get("folders", []) or []: + fid = str(f.get("id", "")) + name = f.get("name") or fid + out.append( + ExternalEntry( + name=name, + path=f"/team/{team_id}/space/{space_id}/folder/{fid}", + isFolder=True, + metadata={"cuType": _CU_FOLDER, "id": fid, "raw": f}, + ) + ) + if isinstance(lists_r, dict) and not lists_r.get("error"): + for lst in lists_r.get("lists", []) or []: + lid = str(lst.get("id", "")) + name = lst.get("name") or lid + out.append( + ExternalEntry( + name=name, + path=f"/team/{team_id}/list/{lid}", + isFolder=True, + metadata={"cuType": _CU_LIST, "id": lid, "raw": lst}, + ) + ) + return out + + m = re.match(r"^/team/([^/]+)/space/([^/]+)/folder/([^/]+)$", p) + if m: + team_id, _space_id, folder_id = m.group(1), m.group(2), m.group(3) + data = await self._svc.getListsInFolder(folder_id) + if isinstance(data, dict) and data.get("error"): + return [] + for lst in data.get("lists", []) or []: + lid = str(lst.get("id", "")) + name = lst.get("name") or lid + out.append( + ExternalEntry( + name=name, + path=f"/team/{team_id}/list/{lid}", + isFolder=True, + metadata={"cuType": _CU_LIST, "id": lid, "raw": lst}, + ) + ) + return out + + m = re.match(r"^/team/([^/]+)/list/([^/]+)$", p) + if m: + team_id, list_id = m.group(1), m.group(2) + page = 0 + while True: + data = await self._svc.getTasksInList(list_id, page=page) + if isinstance(data, dict) and data.get("error"): + break + tasks = data.get("tasks", []) if isinstance(data, dict) else [] + for task in tasks: + tid = str(task.get("id", "")) + name = task.get("name") or tid + out.append( + ExternalEntry( + name=name, + path=f"/team/{team_id}/list/{list_id}/task/{tid}", + isFolder=False, + metadata={ + "cuType": _CU_TASK, + "id": tid, + "task": task, + }, + ) + ) + if len(tasks) < 100: + break + page += 1 + return out + + m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p) + if m: + team_id, list_id, task_id = m.group(1), m.group(2), m.group(3) + out.append( + ExternalEntry( + name=f"task-{task_id}.json", + path=p, + isFolder=False, + metadata={"cuType": _CU_TASK, "id": task_id, "listId": list_id, "teamId": team_id}, + ) + ) + return out + + logger.warning(f"ClickUp browse: unsupported path {p}") + return [] + + async def download(self, path: str) -> Any: + p = _norm(path) + m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p) + if not m: + return b"" + task_id = m.group(3) + data = await self._svc.getTask(task_id) + if isinstance(data, dict) and data.get("error"): + return json.dumps(data).encode("utf-8") + payload = json.dumps(data, indent=2).encode("utf-8") + return DownloadResult(data=payload, fileName=f"task-{task_id}.json", mimeType="application/json") + + async def upload(self, path: str, data: bytes, fileName: str) -> dict: + """Upload attachment to a task. Path must be .../list/{listId}/task/{taskId}.""" + p = _norm(path) + m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p) + if not m: + return {"error": "Path must be /team/{teamId}/list/{listId}/task/{taskId} for upload"} + task_id = m.group(3) + return await self._svc.uploadTaskAttachment(task_id, data, fileName) + + async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]: + base = _norm(path or "/") + team_id: Optional[str] = None + mt = re.match(r"^/team/([^/]+)", base) + if mt: + team_id = mt.group(1) + if not team_id: + teams = await self._svc.getAuthorizedTeams() + if not isinstance(teams, dict) or teams.get("error"): + return [] + tl = teams.get("teams") or [] + if not tl: + return [] + team_id = str(tl[0].get("id", "")) + + out: List[ExternalEntry] = [] + page = 0 + while True: + data = await self._svc.searchTeamTasks(team_id, query=query, page=page) + if isinstance(data, dict) and data.get("error"): + break + tasks = data.get("tasks", []) if isinstance(data, dict) else [] + for task in tasks: + tid = str(task.get("id", "")) + name = task.get("name") or tid + list_obj = task.get("list") or {} + lid = str(list_obj.get("id", "")) if list_obj else "" + if not lid: + continue + out.append( + ExternalEntry( + name=name, + path=f"/team/{team_id}/list/{lid}/task/{tid}", + isFolder=False, + metadata={"cuType": _CU_TASK, "id": tid, "task": task}, + ) + ) + if len(tasks) < 25: + break + page += 1 + return out + + +class ClickupConnector(ProviderConnector): + """One ClickUp connection → clickup virtual file service.""" + + def getAvailableServices(self) -> List[str]: + return ["clickup"] + + def getServiceAdapter(self, service: str) -> ServiceAdapter: + if service != "clickup": + raise ValueError(f"ClickUp only supports 'clickup' service, got '{service}'") + return ClickupListsAdapter(self.accessToken) diff --git a/modules/datamodels/datamodelDataSource.py b/modules/datamodels/datamodelDataSource.py index f8238fab..baeac5ae 100644 --- a/modules/datamodels/datamodelDataSource.py +++ b/modules/datamodels/datamodelDataSource.py @@ -17,7 +17,9 @@ class DataSource(BaseModel): """Configured external data source linked to a UserConnection.""" id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") connectionId: str = Field(description="FK to UserConnection") - sourceType: str = Field(description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder") + sourceType: str = Field( + description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)" + ) path: str = Field(description="External path (e.g. '/sites/MySite/Documents/Reports')") label: str = Field(description="User-visible label (often the last path segment)") displayPath: Optional[str] = Field( diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index 22d94ebe..78858c8d 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -21,6 +21,7 @@ class AuthAuthority(str, Enum): LOCAL = "local" GOOGLE = "google" MSFT = "msft" + CLICKUP = "clickup" class ConnectionStatus(str, Enum): ACTIVE = "active" @@ -141,7 +142,12 @@ class UserConnection(BaseModel): @property def displayLabel(self) -> str: """Human-readable label for display in dropdowns""" - authorityLabels = {"msft": "Microsoft", "google": "Google", "local": "Local"} + authorityLabels = { + "msft": "Microsoft", + "google": "Google", + "local": "Local", + "clickup": "ClickUp", + } return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}" diff --git a/modules/features/automation2/datamodelFeatureAutomation2.py b/modules/features/automation2/datamodelFeatureAutomation2.py index f505c7d0..4ccab460 100644 --- a/modules/features/automation2/datamodelFeatureAutomation2.py +++ b/modules/features/automation2/datamodelFeatureAutomation2.py @@ -36,6 +36,11 @@ class Automation2Workflow(BaseModel): description="Whether workflow is active", json_schema_extra={"frontend_type": "checkbox", "frontend_required": False}, ) + invocations: List[Dict[str, Any]] = Field( + default_factory=list, + description="Entry points / starts (manual, form, schedule, webhook, …) configured outside the canvas", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + ) registerModelLabels( @@ -48,6 +53,7 @@ registerModelLabels( "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, "graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"}, "active": {"en": "Active", "de": "Aktiv", "fr": "Actif"}, + "invocations": {"en": "Starts / Entry points", "de": "Starts / Einstiegspunkte", "fr": "Points d'entrée"}, }, ) diff --git a/modules/features/automation2/entryPoints.py b/modules/features/automation2/entryPoints.py new file mode 100644 index 00000000..2bcc74ce --- /dev/null +++ b/modules/features/automation2/entryPoints.py @@ -0,0 +1,96 @@ +# Copyright (c) 2025 Patrick Motsch +""" +Workflow entry points (Starts) — configuration outside the flow editor. + +Kinds align with run envelope trigger.type where applicable. +""" + +import uuid +from typing import Any, Dict, List, Optional + +# On-demand (gear: Manueller Trigger, Formular) +KINDS_ON_DEMAND = frozenset({"manual", "form", "api"}) + +# Always-on (gear: Zeitplan, Immer aktiv, plus legacy listener kinds) +KINDS_ALWAYS_ON = frozenset({"schedule", "always_on", "email", "webhook", "event"}) + +ALL_KINDS = KINDS_ON_DEMAND | KINDS_ALWAYS_ON + + +def category_for_kind(kind: str) -> str: + if kind in KINDS_ALWAYS_ON: + return "always_on" + return "on_demand" + + +def default_manual_entry_point() -> Dict[str, Any]: + """Single default manual start when a workflow has no invocations yet.""" + return { + "id": str(uuid.uuid4()), + "kind": "manual", + "category": "on_demand", + "enabled": True, + "title": { + "de": "Jetzt ausführen", + "en": "Run now", + "fr": "Exécuter", + }, + "description": {}, + "config": {}, + } + + +def _normalize_title(title: Any) -> Dict[str, str]: + if isinstance(title, dict): + return {k: str(v) for k, v in title.items() if v is not None} + if isinstance(title, str) and title.strip(): + return {"de": title, "en": title, "fr": title} + return {"de": "Start", "en": "Start", "fr": "Départ"} + + +def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]: + """Validate and normalize a single entry point dict.""" + kind = (raw.get("kind") or "manual").strip() + if kind not in ALL_KINDS: + kind = "manual" + cat = raw.get("category") + if cat not in ("on_demand", "always_on"): + cat = category_for_kind(kind) + eid = raw.get("id") or str(uuid.uuid4()) + enabled = raw.get("enabled", True) + if not isinstance(enabled, bool): + enabled = bool(enabled) + config = raw.get("config") if isinstance(raw.get("config"), dict) else {} + desc = raw.get("description") if isinstance(raw.get("description"), dict) else {} + return { + "id": str(eid), + "kind": kind, + "category": cat, + "enabled": enabled, + "title": _normalize_title(raw.get("title")), + "description": desc, + "config": config, + } + + +def normalize_invocations_list(items: Optional[List[Any]]) -> List[Dict[str, Any]]: + if not items: + return [default_manual_entry_point()] + out: List[Dict[str, Any]] = [] + for raw in items: + if isinstance(raw, dict): + out.append(normalize_invocation_entry(raw)) + if not out: + return [default_manual_entry_point()] + return out + + +# Schedule / cron: wire an external job runner (APScheduler, Celery, system cron) to call +# POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet. + + +def find_invocation(workflow: Dict[str, Any], entry_point_id: str) -> Optional[Dict[str, Any]]: + for inv in workflow.get("invocations") or []: + if isinstance(inv, dict) and inv.get("id") == entry_point_id: + return inv + return None diff --git a/modules/features/automation2/interfaceFeatureAutomation2.py b/modules/features/automation2/interfaceFeatureAutomation2.py index cdc9bccf..b38b21db 100644 --- a/modules/features/automation2/interfaceFeatureAutomation2.py +++ b/modules/features/automation2/interfaceFeatureAutomation2.py @@ -30,6 +30,7 @@ from modules.features.automation2.datamodelFeatureAutomation2 import ( Automation2WorkflowRun, Automation2HumanTask, ) +from modules.features.automation2.entryPoints import normalize_invocations_list from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG @@ -49,6 +50,83 @@ def getAutomation2Interface( ) +def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: + """ + Get all active Automation2 workflows that have a schedule entry point (primary invocation). + Used by the scheduler to register cron jobs. Does not filter by mandate/instance. + """ + dbHost = APP_CONFIG.get("DB_HOST", "localhost") + dbDatabase = "poweron_automation2" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) + connector = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=None, + ) + if not connector._ensureTableExists(Automation2Workflow): + logger.warning("Automation2 schedule: table Automation2Workflow does not exist") + return [] + # Don't filter by active in SQL: existing workflows may have active=NULL. + # Treat NULL as active; skip only when active is explicitly False. + records = connector.getRecordset( + Automation2Workflow, + recordFilter=None, + ) + raw_count = len(records) if records else 0 + result = [] + for r in records or []: + if r.get("active") is False: + continue + wf = dict(r) + wf["invocations"] = normalize_invocations_list(wf.get("invocations")) + invocations = wf.get("invocations") or [] + primary = invocations[0] if invocations else {} + if not isinstance(primary, dict): + primary = {} + + # Cron comes from graph start node params (trigger.schedule) + graph = wf.get("graph") or {} + nodes = graph.get("nodes") or [] + cron = None + for n in nodes: + if n.get("type") == "trigger.schedule": + params = n.get("parameters") or {} + cron = params.get("cron") + if cron: + break + + if not cron or not isinstance(cron, str) or not cron.strip(): + continue + + # Prefer invocations; if graph has trigger.schedule but invocations say manual, still schedule + if primary.get("kind") == "schedule" and primary.get("enabled", True): + entry_point_id = primary.get("id") + elif invocations and isinstance(invocations[0], dict) and invocations[0].get("id"): + entry_point_id = invocations[0].get("id") + else: + entry_point_id = str(uuid.uuid4()) + + result.append({ + "workflowId": wf.get("id"), + "mandateId": wf.get("mandateId"), + "featureInstanceId": wf.get("featureInstanceId"), + "entryPointId": entry_point_id, + "cron": cron.strip(), + "workflow": wf, + }) + logger.info( + "Automation2 schedule: DB has %d workflow(s), %d active with trigger.schedule+cron", + raw_count, + len(result), + ) + return result + + class Automation2Objects: """Interface for Automation2 database operations.""" @@ -87,18 +165,26 @@ class Automation2Objects: # Workflow CRUD # ------------------------------------------------------------------------- - def getWorkflows(self) -> List[Dict[str, Any]]: - """Get all workflows for this mandate and feature instance.""" + def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]: + """Get all workflows for this mandate and feature instance. + Optional active filter: True=only active, False=only inactive, None=all. + """ if not self.db._ensureTableExists(Automation2Workflow): return [] + rf: Dict[str, Any] = { + "mandateId": self.mandateId, + "featureInstanceId": self.featureInstanceId, + } + if active is not None: + rf["active"] = active records = self.db.getRecordset( Automation2Workflow, - recordFilter={ - "mandateId": self.mandateId, - "featureInstanceId": self.featureInstanceId, - }, + recordFilter=rf, ) - return [dict(r) for r in records] if records else [] + rows = [dict(r) for r in records] if records else [] + for wf in rows: + wf["invocations"] = normalize_invocations_list(wf.get("invocations")) + return rows def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: """Get a single workflow by ID.""" @@ -114,7 +200,9 @@ class Automation2Objects: ) if not records: return None - return dict(records[0]) + wf = dict(records[0]) + wf["invocations"] = normalize_invocations_list(wf.get("invocations")) + return wf def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]: """Create a new workflow.""" @@ -122,8 +210,18 @@ class Automation2Objects: data["id"] = str(uuid.uuid4()) data["mandateId"] = self.mandateId data["featureInstanceId"] = self.featureInstanceId + if "active" not in data or data.get("active") is None: + data["active"] = True + data["invocations"] = normalize_invocations_list(data.get("invocations")) created = self.db.recordCreate(Automation2Workflow, data) - return dict(created) + out = dict(created) + out["invocations"] = normalize_invocations_list(out.get("invocations")) + try: + from modules.shared.callbackRegistry import callbackRegistry + callbackRegistry.trigger("automation2.workflow.changed") + except Exception: + pass + return out def updateWorkflow(self, workflowId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Update an existing workflow.""" @@ -133,8 +231,17 @@ class Automation2Objects: # Don't overwrite mandateId/featureInstanceId data.pop("mandateId", None) data.pop("featureInstanceId", None) + if "invocations" in data: + data["invocations"] = normalize_invocations_list(data.get("invocations")) updated = self.db.recordModify(Automation2Workflow, workflowId, data) - return dict(updated) + out = dict(updated) + out["invocations"] = normalize_invocations_list(out.get("invocations")) + try: + from modules.shared.callbackRegistry import callbackRegistry + callbackRegistry.trigger("automation2.workflow.changed") + except Exception: + pass + return out def deleteWorkflow(self, workflowId: str) -> bool: """Delete a workflow.""" @@ -142,6 +249,11 @@ class Automation2Objects: if not existing: return False self.db.recordDelete(Automation2Workflow, workflowId) + try: + from modules.shared.callbackRegistry import callbackRegistry + callbackRegistry.trigger("automation2.workflow.changed") + except Exception: + pass return True # ------------------------------------------------------------------------- @@ -209,6 +321,28 @@ class Automation2Objects: ) return [dict(r) for r in records] if records else [] + def getRecentCompletedRuns(self, limit: int = 20) -> List[Dict[str, Any]]: + """Get recently completed runs for workflows in this instance (for output display).""" + if not self.db._ensureTableExists(Automation2WorkflowRun): + return [] + workflows = self.getWorkflows() + wf_ids = [w["id"] for w in workflows if w.get("id")] + if not wf_ids: + return [] + records = self.db.getRecordset( + Automation2WorkflowRun, + recordFilter={"status": "completed"}, + ) + if not records: + return [] + runs = [dict(r) for r in records if r.get("workflowId") in wf_ids] + wf_by_id = {w["id"]: w for w in workflows} + for r in runs: + wf = wf_by_id.get(r.get("workflowId"), {}) + r["workflowLabel"] = wf.get("label") or r.get("workflowId", "") + runs.sort(key=lambda x: (x.get("_modifiedAt") or x.get("_createdAt") or 0), reverse=True) + return runs[:limit] + def getRunsWaitingForEmail(self) -> List[Dict[str, Any]]: """Get all paused runs waiting for a new email (for background poller).""" if not self.db._ensureTableExists(Automation2WorkflowRun): @@ -289,23 +423,38 @@ class Automation2Objects: status: str = None, assigneeId: str = None, ) -> List[Dict[str, Any]]: - """Get tasks with optional filters. AssigneeId filters to that user; None returns all.""" + """Get tasks with optional filters. + When assigneeId is set: returns tasks assigned to that user OR unassigned (so schedule tasks show up). + When assigneeId is None: returns all tasks. + """ if not self.db._ensureTableExists(Automation2HumanTask): return [] - rf = {} + base_rf: Dict[str, Any] = {} if workflowId: - rf["workflowId"] = workflowId + base_rf["workflowId"] = workflowId if runId: - rf["runId"] = runId + base_rf["runId"] = runId if status: - rf["status"] = status + base_rf["status"] = status if assigneeId: - rf["assigneeId"] = assigneeId - records = self.db.getRecordset( - Automation2HumanTask, - recordFilter=rf if rf else None, - ) - items = [dict(r) for r in records] if records else [] + rf_assigned = {**base_rf, "assigneeId": assigneeId} + rf_unassigned = {**base_rf, "assigneeId": None} + records1 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_assigned) + records2 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_unassigned) + seen = set() + items = [] + for r in (records1 or []) + (records2 or []): + rec = dict(r) + tid = rec.get("id") + if tid and tid not in seen: + seen.add(tid) + items.append(rec) + else: + records = self.db.getRecordset( + Automation2HumanTask, + recordFilter=base_rf if base_rf else None, + ) + items = [dict(r) for r in records] if records else [] workflows = {w["id"]: w for w in self.getWorkflows()} filtered = [t for t in items if t.get("workflowId") in workflows] return filtered diff --git a/modules/features/automation2/mainAutomation2.py b/modules/features/automation2/mainAutomation2.py index 9ec97eca..4f52e158 100644 --- a/modules/features/automation2/mainAutomation2.py +++ b/modules/features/automation2/mainAutomation2.py @@ -19,6 +19,8 @@ REQUIRED_SERVICES = [ {"serviceKey": "ai", "meta": {"usage": "AI nodes"}}, {"serviceKey": "extraction", "meta": {"usage": "Workflow method actions"}}, {"serviceKey": "sharepoint", "meta": {"usage": "SharePoint actions"}}, + {"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}}, + {"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, ] FEATURE_LABEL = {"en": "Automation 2", "de": "Automatisierung 2", "fr": "Automatisation 2"} FEATURE_ICON = "mdi-sitemap" @@ -157,6 +159,8 @@ class _Automation2ServiceHub: utils = None extraction = None sharepoint = None + clickup = None + generation = None async def onStart(eventUser) -> None: diff --git a/modules/features/automation2/nodeDefinitions/__init__.py b/modules/features/automation2/nodeDefinitions/__init__.py index 61eec51a..2f4920c8 100644 --- a/modules/features/automation2/nodeDefinitions/__init__.py +++ b/modules/features/automation2/nodeDefinitions/__init__.py @@ -3,18 +3,20 @@ from .triggers import TRIGGER_NODES from .flow import FLOW_NODES -from .data import DATA_NODES from .input import INPUT_NODES from .ai import AI_NODES from .email import EMAIL_NODES from .sharepoint import SHAREPOINT_NODES +from .clickup import CLICKUP_NODES +from .file import FILE_NODES STATIC_NODE_TYPES = ( TRIGGER_NODES + FLOW_NODES - + DATA_NODES + INPUT_NODES + AI_NODES + EMAIL_NODES + SHAREPOINT_NODES + + CLICKUP_NODES + + FILE_NODES ) diff --git a/modules/features/automation2/nodeDefinitions/ai.py b/modules/features/automation2/nodeDefinitions/ai.py index 4fdf0db9..bb85e809 100644 --- a/modules/features/automation2/nodeDefinitions/ai.py +++ b/modules/features/automation2/nodeDefinitions/ai.py @@ -9,7 +9,6 @@ AI_NODES = [ "description": {"en": "Enter a prompt and AI does something", "de": "Prompt eingeben und KI führt aus", "fr": "Entrer une invite et l'IA exécute"}, "parameters": [ {"name": "prompt", "type": "string", "required": True, "description": {"en": "AI prompt", "de": "KI-Prompt", "fr": "Invite IA"}}, - {"name": "resultType", "type": "string", "required": False, "description": {"en": "Output format (txt, json, md, etc.)", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "txt"}, ], "inputs": 1, "outputs": 1, @@ -85,7 +84,6 @@ AI_NODES = [ "description": {"en": "Generate document from prompt", "de": "Dokument aus Prompt generieren", "fr": "Générer un document"}, "parameters": [ {"name": "prompt", "type": "string", "required": True, "description": {"en": "Generation prompt", "de": "Generierungs-Prompt", "fr": "Invite de génération"}}, - {"name": "format", "type": "string", "required": False, "description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "docx"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/automation2/nodeDefinitions/clickup.py b/modules/features/automation2/nodeDefinitions/clickup.py new file mode 100644 index 00000000..4acb0db9 --- /dev/null +++ b/modules/features/automation2/nodeDefinitions/clickup.py @@ -0,0 +1,227 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp nodes — map to MethodClickup actions.""" + +CLICKUP_NODES = [ + { + "id": "clickup.searchTasks", + "category": "clickup", + "label": {"en": "Search tasks", "de": "Aufgaben suchen", "fr": "Rechercher tâches"}, + "description": { + "en": "Search tasks in a workspace (team)", + "de": "Aufgaben in einem Workspace suchen", + "fr": "Rechercher des tâches dans un espace", + }, + "parameters": [ + {"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + {"name": "teamId", "type": "string", "required": True, "description": {"en": "Workspace (team) ID", "de": "Team-/Workspace-ID", "fr": "ID équipe"}}, + {"name": "query", "type": "string", "required": True, "description": {"en": "Search query", "de": "Suchbegriff", "fr": "Requête"}}, + {"name": "page", "type": "number", "required": False, "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0}, + { + "name": "listId", + "type": "string", + "required": False, + "description": { + "en": "If set, search this list via list API (not team search).", + "de": "Wenn gesetzt: Suche in dieser Liste (Listen-API, nicht Team-Suche).", + "fr": "Si défini : recherche dans cette liste (API liste).", + }, + }, + { + "name": "includeClosed", + "type": "boolean", + "required": False, + "default": False, + "description": { + "en": "With listId: include closed tasks.", + "de": "Mit Liste: erledigte Aufgaben einbeziehen.", + "fr": "Avec liste : inclure les tâches terminées.", + }, + }, + { + "name": "fullTaskData", + "type": "boolean", + "required": False, + "default": False, + "description": { + "en": "Return full ClickUp API JSON per task (very large). Default: slim fields only.", + "de": "Vollständige ClickUp-Rohdaten pro Task (sehr groß). Standard: nur schlanke Felder.", + "fr": "Réponse brute complète (très volumineuse). Par défaut : champs réduits.", + }, + }, + { + "name": "matchNameOnly", + "type": "boolean", + "required": False, + "default": True, + "description": { + "en": "Keep only tasks whose title contains the search query (default: on).", + "de": "Nur Aufgaben, deren Titel den Suchbegriff enthält (Standard: an).", + "fr": "Ne garder que les tâches dont le titre contient la requête (défaut : oui).", + }, + }, + ], + "inputs": 1, + "outputs": 1, + "meta": {"icon": "mdi-magnify", "color": "#7B68EE"}, + "_method": "clickup", + "_action": "searchTasks", + "_paramMap": { + "connectionId": "connectionReference", + "teamId": "teamId", + "query": "query", + "page": "page", + "listId": "listId", + "fullTaskData": "fullTaskData", + "matchNameOnly": "matchNameOnly", + "includeClosed": "includeClosed", + }, + }, + { + "id": "clickup.listTasks", + "category": "clickup", + "label": {"en": "List tasks", "de": "Aufgaben auflisten", "fr": "Lister les tâches"}, + "description": { + "en": "List tasks in a list (pick list path from browse)", + "de": "Aufgaben einer Liste auflisten (Pfad aus Browse)", + "fr": "Lister les tâches d'une liste", + }, + "parameters": [ + {"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + {"name": "path", "type": "string", "required": True, "description": {"en": "Virtual path to list /team/.../list/...", "de": "Pfad zur Liste", "fr": "Chemin vers la liste"}}, + {"name": "page", "type": "number", "required": False, "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0}, + {"name": "includeClosed", "type": "boolean", "required": False, "description": {"en": "Include closed", "de": "Erledigte einbeziehen", "fr": "Inclure terminées"}, "default": False}, + ], + "inputs": 1, + "outputs": 1, + "meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE"}, + "_method": "clickup", + "_action": "listTasks", + "_paramMap": { + "connectionId": "connectionReference", + "path": "pathQuery", + "page": "page", + "includeClosed": "includeClosed", + }, + }, + { + "id": "clickup.getTask", + "category": "clickup", + "label": {"en": "Get task", "de": "Aufgabe abrufen", "fr": "Obtenir la tâche"}, + "description": {"en": "Get one task by ID or path", "de": "Eine Aufgabe abrufen", "fr": "Obtenir une tâche"}, + "parameters": [ + {"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + {"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}}, + {"name": "path", "type": "string", "required": False, "description": {"en": "Or path .../task/{id}", "de": "Oder Pfad .../task/{id}", "fr": "Ou chemin .../task/{id}"}}, + ], + "inputs": 1, + "outputs": 1, + "meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE"}, + "_method": "clickup", + "_action": "getTask", + "_paramMap": {"connectionId": "connectionReference", "taskId": "taskId", "path": "pathQuery"}, + }, + { + "id": "clickup.createTask", + "category": "clickup", + "label": {"en": "Create task", "de": "Aufgabe erstellen", "fr": "Créer une tâche"}, + "description": {"en": "Create a task in a list", "de": "Aufgabe in einer Liste erstellen", "fr": "Créer une tâche dans une liste"}, + "parameters": [ + {"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + {"name": "teamId", "type": "string", "required": False, "description": {"en": "Workspace (team) for list picker", "de": "Workspace für Listen-Auswahl", "fr": "Équipe"}}, + {"name": "path", "type": "string", "required": False, "description": {"en": "Optional path /team/.../list/...", "de": "Optional: Pfad zur Liste", "fr": "Chemin optionnel"}}, + {"name": "listId", "type": "string", "required": False, "description": {"en": "List ID", "de": "Listen-ID", "fr": "ID liste"}}, + {"name": "name", "type": "string", "required": True, "description": {"en": "Task name", "de": "Name", "fr": "Nom"}}, + {"name": "description", "type": "string", "required": False, "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}}, + {"name": "taskStatus", "type": "string", "required": False, "description": {"en": "Status (list status name)", "de": "Status (wie in der Liste)", "fr": "Statut"}}, + {"name": "taskPriority", "type": "string", "required": False, "description": {"en": "1–4 or empty", "de": "1–4 oder leer", "fr": "1–4"}}, + {"name": "taskDueDateMs", "type": "string", "required": False, "description": {"en": "Due date (Unix ms)", "de": "Fälligkeit (ms)", "fr": "Échéance (ms)"}}, + {"name": "taskAssigneeIds", "type": "object", "required": False, "description": {"en": "Assignee user ids", "de": "Zugewiesene (User-IDs)", "fr": "Assignés"}}, + {"name": "taskTimeEstimateMs", "type": "string", "required": False, "description": {"en": "Time estimate (ms)", "de": "Zeitschätzung (ms)", "fr": "Estimation (ms)"}}, + {"name": "taskTimeEstimateHours", "type": "string", "required": False, "description": {"en": "Time estimate (hours)", "de": "Zeitschätzung (Stunden)", "fr": "Heures"}}, + {"name": "customFieldValues", "type": "object", "required": False, "description": {"en": "Custom field id → value", "de": "Benutzerdefinierte Felder", "fr": "Champs personnalisés"}}, + {"name": "taskFields", "type": "string", "required": False, "description": {"en": "Extra JSON (advanced)", "de": "Zusätzliches JSON (fortgeschritten)", "fr": "JSON avancé"}}, + ], + "inputs": 1, + "outputs": 1, + "meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE"}, + "_method": "clickup", + "_action": "createTask", + "_paramMap": { + "connectionId": "connectionReference", + "teamId": "teamId", + "path": "pathQuery", + "listId": "listId", + "name": "name", + "description": "description", + "taskStatus": "taskStatus", + "taskPriority": "taskPriority", + "taskDueDateMs": "taskDueDateMs", + "taskAssigneeIds": "taskAssigneeIds", + "taskTimeEstimateMs": "taskTimeEstimateMs", + "taskTimeEstimateHours": "taskTimeEstimateHours", + "customFieldValues": "customFieldValues", + "taskFields": "taskFields", + }, + }, + { + "id": "clickup.updateTask", + "category": "clickup", + "label": {"en": "Update task", "de": "Aufgabe aktualisieren", "fr": "Mettre à jour la tâche"}, + "description": { + "en": "Update task fields (rows or JSON)", + "de": "Felder der Aufgabe ändern (Zeilen oder JSON)", + "fr": "Mettre à jour les champs (lignes ou JSON)", + }, + "parameters": [ + {"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + {"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}}, + {"name": "path", "type": "string", "required": False, "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}}, + { + "name": "taskUpdateEntries", + "type": "object", + "required": False, + "description": { + "en": "List of {fieldKey, value, customFieldId?}", + "de": "Liste der zu ändernden Felder (fieldKey, value, optional customFieldId)", + "fr": "Liste de champs à mettre à jour", + }, + }, + {"name": "taskUpdate", "type": "string", "required": False, "description": {"en": "JSON body for API (optional if rows set)", "de": "JSON für API (optional wenn Zeilen gesetzt)", "fr": "Corps JSON"}}, + ], + "inputs": 1, + "outputs": 1, + "meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE"}, + "_method": "clickup", + "_action": "updateTask", + "_paramMap": { + "connectionId": "connectionReference", + "taskId": "taskId", + "path": "path", + "taskUpdate": "taskUpdate", + }, + }, + { + "id": "clickup.uploadAttachment", + "category": "clickup", + "label": {"en": "Upload attachment", "de": "Anhang hochladen", "fr": "Téléverser pièce jointe"}, + "description": {"en": "Upload file to a task (upstream file)", "de": "Datei an Task anhängen", "fr": "Joindre un fichier à la tâche"}, + "parameters": [ + {"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + {"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}}, + {"name": "path", "type": "string", "required": False, "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}}, + {"name": "fileName", "type": "string", "required": False, "description": {"en": "File name", "de": "Dateiname", "fr": "Nom du fichier"}}, + ], + "inputs": 1, + "outputs": 1, + "meta": {"icon": "mdi-attachment", "color": "#7B68EE"}, + "_method": "clickup", + "_action": "uploadAttachment", + "_paramMap": { + "connectionId": "connectionReference", + "taskId": "taskId", + "path": "path", + "fileName": "fileName", + }, + }, +] diff --git a/modules/features/automation2/nodeDefinitions/data.py b/modules/features/automation2/nodeDefinitions/data.py deleted file mode 100644 index b44618d1..00000000 --- a/modules/features/automation2/nodeDefinitions/data.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# Data transformation node definitions. - -DATA_NODES = [ - { - "id": "data.setFields", - "category": "data", - "label": {"en": "Set Fields", "de": "Felder setzen", "fr": "Définir champs"}, - "description": {"en": "Set or override fields on payload", "de": "Felder setzen oder überschreiben", "fr": "Définir ou écraser des champs"}, - "parameters": [ - {"name": "fields", "type": "object", "required": True, "description": {"en": "Key-value pairs", "de": "Schlüssel-Wert-Paare", "fr": "Paires clé-valeur"}}, - ], - "inputs": 1, - "outputs": 1, - "executor": "data", - "meta": {"icon": "mdi-pencil", "color": "#673AB7"}, - }, - { - "id": "data.filter", - "category": "data", - "label": {"en": "Filter", "de": "Filtern", "fr": "Filtrer"}, - "description": {"en": "Filter array by condition", "de": "Array nach Bedingung filtern", "fr": "Filtrer tableau par condition"}, - "parameters": [ - {"name": "condition", "type": "string", "required": True, "description": {"en": "Expression (e.g. item.active == true)", "de": "Bedingung", "fr": "Condition"}}, - {"name": "itemsPath", "type": "string", "required": False, "description": {"en": "Path to array", "de": "Pfad zum Array", "fr": "Chemin vers le tableau"}}, - ], - "inputs": 1, - "outputs": 1, - "executor": "data", - "meta": {"icon": "mdi-filter", "color": "#673AB7"}, - }, - { - "id": "data.parseJson", - "category": "data", - "label": {"en": "Parse JSON", "de": "JSON parsen", "fr": "Parser JSON"}, - "description": {"en": "Parse JSON string to object", "de": "JSON-String in Objekt parsen", "fr": "Parser chaîne JSON en objet"}, - "parameters": [ - {"name": "jsonPath", "type": "string", "required": False, "description": {"en": "Path to JSON string (default: input)", "de": "Pfad zum JSON", "fr": "Chemin vers JSON"}}, - ], - "inputs": 1, - "outputs": 1, - "executor": "data", - "meta": {"icon": "mdi-code-json", "color": "#673AB7"}, - }, - { - "id": "data.template", - "category": "data", - "label": {"en": "Template / Interpolation", "de": "Vorlage / Interpolation", "fr": "Modèle / Interpolation"}, - "description": {"en": "Text with {{placeholder}} substitution", "de": "Text mit {{platzhalter}}-Ersetzung", "fr": "Texte avec substitution {{placeholder}}"}, - "parameters": [ - {"name": "template", "type": "string", "required": True, "description": {"en": "Template (use {{path}} for values)", "de": "Vorlage", "fr": "Modèle"}}, - ], - "inputs": 1, - "outputs": 1, - "executor": "data", - "meta": {"icon": "mdi-format-text", "color": "#673AB7"}, - }, -] diff --git a/modules/features/automation2/nodeDefinitions/file.py b/modules/features/automation2/nodeDefinitions/file.py new file mode 100644 index 00000000..bb168218 --- /dev/null +++ b/modules/features/automation2/nodeDefinitions/file.py @@ -0,0 +1,60 @@ +# Copyright (c) 2025 Patrick Motsch +# File node definitions - create files from context (e.g. from AI nodes). + +FILE_NODES = [ + { + "id": "file.create", + "category": "file", + "label": {"en": "Create File", "de": "Datei erstellen", "fr": "Créer fichier"}, + "description": { + "en": "Create a file from context (text/markdown from AI). Configurable format and style.", + "de": "Erstellt eine Datei aus Kontext (Text/Markdown von KI). Format und Stil konfigurierbar.", + "fr": "Crée un fichier à partir du contexte. Format et style configurables.", + }, + "parameters": [ + { + "name": "contentSources", + "type": "json", + "required": False, + "description": { + "en": "Array of context refs (e.g. AI, form). Concatenated in order. Empty = from connected node.", + "de": "Liste von Kontext-Quellen (z.B. KI, Formular). Werden nacheinander zusammengefügt. Leer = vom verbundenen Node.", + "fr": "Liste de sources de contexte. Concaténées dans l'ordre. Vide = du noeud connecté.", + }, + "default": [], + }, + { + "name": "outputFormat", + "type": "string", + "required": True, + "description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, + "default": "docx", + }, + { + "name": "title", + "type": "string", + "required": False, + "description": {"en": "Document title", "de": "Dokumenttitel", "fr": "Titre du document"}, + }, + { + "name": "templateName", + "type": "string", + "required": False, + "description": {"en": "Style preset: default, corporate, minimal", "de": "Stil-Vorlage", "fr": "Prését style"}, + }, + { + "name": "language", + "type": "string", + "required": False, + "description": {"en": "Language code (de, en, fr)", "de": "Sprachcode", "fr": "Code langue"}, + "default": "de", + }, + ], + "inputs": 1, + "outputs": 1, + "meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3"}, + "_method": "file", + "_action": "create", + "_paramMap": {}, + }, +] diff --git a/modules/features/automation2/nodeDefinitions/flow.py b/modules/features/automation2/nodeDefinitions/flow.py index 573a83ad..02e25764 100644 --- a/modules/features/automation2/nodeDefinitions/flow.py +++ b/modules/features/automation2/nodeDefinitions/flow.py @@ -12,6 +12,7 @@ FLOW_NODES = [ ], "inputs": 1, "outputs": 2, + "outputLabels": {"en": ["Yes", "No"], "de": ["Ja", "Nein"], "fr": ["Oui", "Non"]}, "executor": "flow", "meta": {"icon": "mdi-source-branch", "color": "#FF9800"}, }, @@ -29,19 +30,6 @@ FLOW_NODES = [ "executor": "flow", "meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800"}, }, - { - "id": "flow.merge", - "category": "flow", - "label": {"en": "Merge", "de": "Zusammenführen", "fr": "Fusionner"}, - "description": {"en": "Merge multiple inputs", "de": "Mehrere Eingaben zusammenführen", "fr": "Fusionner plusieurs entrées"}, - "parameters": [ - {"name": "mode", "type": "string", "required": False, "description": {"en": "append | combine", "de": "Modus", "fr": "Mode"}}, - ], - "inputs": 2, - "outputs": 1, - "executor": "flow", - "meta": {"icon": "mdi-merge", "color": "#FF9800"}, - }, { "id": "flow.loop", "category": "flow", @@ -55,28 +43,4 @@ FLOW_NODES = [ "executor": "flow", "meta": {"icon": "mdi-repeat", "color": "#FF9800"}, }, - { - "id": "flow.wait", - "category": "flow", - "label": {"en": "Wait / Delay", "de": "Warten / Verzögerung", "fr": "Attendre / Délai"}, - "description": {"en": "Pause for duration", "de": "Pause für Dauer", "fr": "Pause pour durée"}, - "parameters": [ - {"name": "seconds", "type": "number", "required": True, "description": {"en": "Seconds to wait", "de": "Sekunden", "fr": "Secondes"}}, - ], - "inputs": 1, - "outputs": 1, - "executor": "flow", - "meta": {"icon": "mdi-timer", "color": "#FF9800"}, - }, - { - "id": "flow.stop", - "category": "flow", - "label": {"en": "Stop / Terminate", "de": "Stopp / Beenden", "fr": "Arrêter / Terminer"}, - "description": {"en": "Stop workflow execution", "de": "Workflow-Ausführung beenden", "fr": "Arrêter l'exécution"}, - "parameters": [], - "inputs": 1, - "outputs": 0, - "executor": "flow", - "meta": {"icon": "mdi-stop", "color": "#F44336"}, - }, ] diff --git a/modules/features/automation2/nodeDefinitions/input.py b/modules/features/automation2/nodeDefinitions/input.py index 8eb43e63..d9c56c78 100644 --- a/modules/features/automation2/nodeDefinitions/input.py +++ b/modules/features/automation2/nodeDefinitions/input.py @@ -12,7 +12,11 @@ INPUT_NODES = [ "name": "fields", "type": "json", "required": True, - "description": {"en": "Form fields: [{name, type, label, required, options?}]", "de": "Formularfelder", "fr": "Champs du formulaire"}, + "description": { + "en": "Form fields: [{name, type, label, required, options?}]. type may include clickup_tasks with clickupConnectionId + clickupListId for a ClickUp task dropdown (value {add, rem}).", + "de": "Formularfelder. type: u. a. clickup_tasks mit clickupConnectionId und clickupListId für ClickUp-Aufgaben-Dropdown (Wert wie Relationship-Feld).", + "fr": "Champs du formulaire", + }, "default": [], }, ], @@ -42,7 +46,8 @@ INPUT_NODES = [ "label": {"en": "Upload", "de": "Upload", "fr": "Téléversement"}, "description": {"en": "User uploads file(s)", "de": "Benutzer lädt Datei(en) hoch", "fr": "L'utilisateur téléverse des fichiers"}, "parameters": [ - {"name": "accept", "type": "string", "required": False, "description": {"en": "MIME types (e.g. .pdf,image/*)", "de": "MIME-Typen", "fr": "Types MIME"}, "default": ""}, + {"name": "accept", "type": "string", "required": False, "description": {"en": "Accept string for file input (e.g. .pdf,image/*)", "de": "Accept-String für Dateiauswahl", "fr": "Chaîne accept"}, "default": ""}, + {"name": "allowedTypes", "type": "json", "required": False, "description": {"en": "Selected file types (from UI multi-select)", "de": "Ausgewählte Dateitypen", "fr": "Types sélectionnés"}, "default": []}, {"name": "maxSize", "type": "number", "required": False, "description": {"en": "Max file size in MB", "de": "Max. Dateigröße in MB", "fr": "Taille max en Mo"}, "default": 10}, {"name": "multiple", "type": "boolean", "required": False, "description": {"en": "Allow multiple files", "de": "Mehrere Dateien erlauben", "fr": "Autoriser plusieurs fichiers"}, "default": False}, ], diff --git a/modules/features/automation2/nodeDefinitions/triggers.py b/modules/features/automation2/nodeDefinitions/triggers.py index 0e206dc0..5071a762 100644 --- a/modules/features/automation2/nodeDefinitions/triggers.py +++ b/modules/features/automation2/nodeDefinitions/triggers.py @@ -1,12 +1,16 @@ # Copyright (c) 2025 Patrick Motsch -# Trigger node definitions - workflow entry points. +# Canvas start nodes — variant reflects workflow configuration (gear in editor). TRIGGER_NODES = [ { "id": "trigger.manual", "category": "trigger", - "label": {"en": "Manual Trigger", "de": "Manueller Trigger", "fr": "Déclencheur manuel"}, - "description": {"en": "Start workflow on button press", "de": "Startet den Workflow bei Knopfdruck", "fr": "Démarre le workflow sur clic"}, + "label": {"en": "Start", "de": "Start", "fr": "Départ"}, + "description": { + "en": "Manual, API, or background triggers (webhook, email, …).", + "de": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).", + "fr": "Manuel, API ou déclencheurs en arrière-plan.", + }, "parameters": [], "inputs": 0, "outputs": 1, @@ -14,29 +18,47 @@ TRIGGER_NODES = [ "meta": {"icon": "mdi-play", "color": "#4CAF50"}, }, { - "id": "trigger.schedule", + "id": "trigger.form", "category": "trigger", - "label": {"en": "Schedule", "de": "Zeitplan", "fr": "Planification"}, - "description": {"en": "Run on a cron schedule", "de": "Läuft nach Cron-Zeitplan", "fr": "S'exécute selon un cron"}, + "label": {"en": "Start (form)", "de": "Start (Formular)", "fr": "Départ (formulaire)"}, + "description": { + "en": "Form fields are filled at run time; configure fields on this node.", + "de": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.", + "fr": "Les champs sont remplis au démarrage.", + }, "parameters": [ - {"name": "cron", "type": "string", "required": True, "description": {"en": "Cron expression (e.g. 0 9 * * * for daily at 9)", "de": "Cron-Ausdruck", "fr": "Expression cron"}}, - ], - "inputs": 0, - "outputs": 1, - "executor": "trigger", - "meta": {"icon": "mdi-clock", "color": "#2196F3"}, - }, - { - "id": "trigger.formSubmit", - "category": "trigger", - "label": {"en": "Form Submit", "de": "Formular-Absendung", "fr": "Soumission formulaire"}, - "description": {"en": "Start when form is submitted", "de": "Startet bei Formular-Absendung", "fr": "Démarre à la soumission du formulaire"}, - "parameters": [ - {"name": "formId", "type": "string", "required": True, "description": {"en": "Form identifier", "de": "Formular-ID", "fr": "Identifiant du formulaire"}}, + { + "name": "formFields", + "type": "json", + "required": False, + "description": {"en": "Field definitions", "de": "Felddefinitionen", "fr": "Définitions"}, + }, ], "inputs": 0, "outputs": 1, "executor": "trigger", "meta": {"icon": "mdi-form-select", "color": "#9C27B0"}, }, + { + "id": "trigger.schedule", + "category": "trigger", + "label": {"en": "Start (schedule)", "de": "Start (Zeitplan)", "fr": "Départ (planification)"}, + "description": { + "en": "Cron expression for scheduled runs (configure on this node).", + "de": "Cron-Ausdruck für geplante Läufe.", + "fr": "Expression cron pour les exécutions planifiées.", + }, + "parameters": [ + { + "name": "cron", + "type": "string", + "required": False, + "description": {"en": "Cron expression", "de": "Cron-Ausdruck", "fr": "Expression cron"}, + }, + ], + "inputs": 0, + "outputs": 1, + "executor": "trigger", + "meta": {"icon": "mdi-clock", "color": "#2196F3"}, + }, ] diff --git a/modules/features/automation2/nodeRegistry.py b/modules/features/automation2/nodeRegistry.py index 39c3e2c9..4bcc9ba5 100644 --- a/modules/features/automation2/nodeRegistry.py +++ b/modules/features/automation2/nodeRegistry.py @@ -36,6 +36,11 @@ def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]: out["label"] = node["label"].get(lang, node["label"].get("en", str(node["label"]))) if isinstance(node.get("description"), dict): out["description"] = node["description"].get(lang, node["description"].get("en", str(node["description"]))) + ol = node.get("outputLabels") + if isinstance(ol, dict) and ol: + first = next(iter(ol.values()), None) + if isinstance(first, (list, tuple)): + out["outputLabels"] = ol.get(lang, ol.get("en", list(first))) params = [] for p in node.get("parameters", []): pc = dict(p) @@ -61,8 +66,10 @@ def getNodeTypesForApi( {"id": "flow", "label": {"en": "Flow", "de": "Ablauf", "fr": "Flux"}}, {"id": "data", "label": {"en": "Data", "de": "Daten", "fr": "Données"}}, {"id": "ai", "label": {"en": "AI", "de": "KI", "fr": "IA"}}, + {"id": "file", "label": {"en": "File", "de": "Datei", "fr": "Fichier"}}, {"id": "email", "label": {"en": "Email", "de": "E-Mail", "fr": "Email"}}, {"id": "sharepoint", "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}}, + {"id": "clickup", "label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}}, ] return {"nodeTypes": localized, "categories": categories} diff --git a/modules/features/automation2/routeFeatureAutomation2.py b/modules/features/automation2/routeFeatureAutomation2.py index 996c3cb6..eaa49370 100644 --- a/modules/features/automation2/routeFeatureAutomation2.py +++ b/modules/features/automation2/routeFeatureAutomation2.py @@ -5,6 +5,8 @@ Automation2 routes - node-types, execute, workflows, runs, tasks, connections, b """ import logging +from typing import Any, Dict, Optional + from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException from fastapi.responses import JSONResponse from modules.auth import limiter, getRequestContext, RequestContext @@ -13,9 +15,75 @@ from modules.features.automation2.mainAutomation2 import getAutomation2Services from modules.features.automation2.nodeRegistry import getNodeTypesForApi from modules.features.automation2.interfaceFeatureAutomation2 import getAutomation2Interface from modules.workflows.automation2.executionEngine import executeGraph +from modules.workflows.automation2.runEnvelope import ( + default_run_envelope, + merge_run_envelope, + normalize_run_envelope, +) +from modules.features.automation2.entryPoints import find_invocation logger = logging.getLogger(__name__) + +def _build_execute_run_envelope( + body: Dict[str, Any], + workflow: Optional[Dict[str, Any]], + user_id: Optional[str], +) -> Dict[str, Any]: + """Build normalized run envelope from POST /execute body.""" + if isinstance(body.get("runEnvelope"), dict): + env = normalize_run_envelope(body["runEnvelope"], user_id=user_id) + pl = body.get("payload") + if isinstance(pl, dict): + env = merge_run_envelope(env, {"payload": pl}) + return env + + entry_point_id = body.get("entryPointId") + if entry_point_id: + if not workflow: + raise HTTPException( + status_code=400, + detail="entryPointId requires a saved workflow (workflowId must refer to a stored workflow)", + ) + inv = find_invocation(workflow, entry_point_id) + if not inv: + raise HTTPException(status_code=400, detail="entryPointId not found on workflow") + if not inv.get("enabled", True): + raise HTTPException(status_code=400, detail="entry point is disabled") + kind = inv.get("kind", "manual") + trig_map = { + "manual": "manual", + "form": "form", + "schedule": "schedule", + "always_on": "event", + "email": "email", + "webhook": "webhook", + "api": "api", + "event": "event", + } + trig = trig_map.get(kind, "manual") + title = inv.get("title") or {} + label = "" + if isinstance(title, dict): + label = title.get("en") or title.get("de") or "" + elif isinstance(title, str): + label = title + base = default_run_envelope( + trig, + entry_point_id=inv.get("id"), + entry_point_label=label or None, + ) + pl = body.get("payload") + if isinstance(pl, dict): + base = merge_run_envelope(base, {"payload": pl}) + return normalize_run_envelope(base, user_id=user_id) + + env = normalize_run_envelope(None, user_id=user_id) + pl = body.get("payload") + if isinstance(pl, dict): + env = merge_run_envelope(env, {"payload": pl}) + return env + router = APIRouter( prefix="/api/automation2", tags=["Automation2"], @@ -55,6 +123,26 @@ def get_automation2_info( } +@router.post("/{instanceId}/schedule-sync") +@limiter.limit("10/minute") +def post_schedule_sync( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Manually trigger schedule sync (re-register cron jobs for all schedule workflows).""" + _validateInstanceAccess(instanceId, context) + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.workflows.automation2.subAutomation2Schedule import sync_automation2_schedule_events + + root = getRootInterface() + event_user = root.getUserByUsername("event") + if not event_user: + return {"success": False, "error": "Event user not available", "synced": 0} + result = sync_automation2_schedule_events(event_user) + return {"success": True, **result} + + @router.get("/{instanceId}/node-types") @limiter.limit("60/minute") def get_node_types( @@ -109,6 +197,10 @@ async def post_execute( graph = body.get("graph") or body workflowId = body.get("workflowId") req_nodes = graph.get("nodes") or [] + workflow_for_envelope: Optional[Dict[str, Any]] = None + if workflowId and not str(workflowId).startswith("transient-"): + a2_pre = getAutomation2Interface(context.user, mandateId, instanceId) + workflow_for_envelope = a2_pre.getWorkflow(workflowId) # When workflowId is set: prefer graph from request (current editor state) if it has nodes. # Only fall back to stored workflow graph when request graph is empty (e.g. resume from email). if workflowId and len(req_nodes) == 0: @@ -117,6 +209,7 @@ async def post_execute( if wf and wf.get("graph"): graph = wf["graph"] logger.info("automation2 execute: loaded graph from workflow %s", workflowId) + workflow_for_envelope = wf # Use transient workflowId when none provided (e.g. execute from editor without save) # Required for email.checkEmail pause/resume - run must be created if not workflowId: @@ -132,6 +225,8 @@ async def post_execute( workflowId, mandateId, ) + run_env = _build_execute_run_envelope(body, workflow_for_envelope, userId) + a2_interface = getAutomation2Interface(context.user, mandateId, instanceId) result = await executeGraph( graph=graph, @@ -141,6 +236,7 @@ async def post_execute( userId=userId, mandateId=mandateId, automation2_interface=a2_interface, + run_envelope=run_env, ) logger.info( "automation2 execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s", @@ -239,6 +335,7 @@ async def list_connection_services( services = provider.getAvailableServices() _serviceLabels = { "sharepoint": "SharePoint", + "clickup": "ClickUp", "outlook": "Outlook", "teams": "Teams", "onedrive": "OneDrive", @@ -248,6 +345,7 @@ async def list_connection_services( } _serviceIcons = { "sharepoint": "sharepoint", + "clickup": "folder", "outlook": "mail", "teams": "chat", "onedrive": "cloud", @@ -342,15 +440,17 @@ def _get_node_label_from_graph(graph: dict, nodeId: str) -> str: def get_workflows( request: Request, instanceId: str = Path(..., description="Feature instance ID"), + active: Optional[bool] = Query(None, description="Filter by active: true|false"), context: RequestContext = Depends(getRequestContext), ) -> dict: """List all workflows for this feature instance. Enriches each workflow with runCount, isRunning, stuckAtNodeId, stuckAtNodeLabel, createdAt, lastStartedAt. + Query param active: filter by active status (true|false). """ mandateId = _validateInstanceAccess(instanceId, context) a2 = getAutomation2Interface(context.user, mandateId, instanceId) - items = a2.getWorkflows() + items = a2.getWorkflows(active=active) enriched = [] for wf in items: wf_id = wf.get("id") @@ -447,11 +547,163 @@ def delete_workflow( return {"success": True} +@router.post("/{instanceId}/workflows/{workflowId}/webhooks/{entryPointId}") +@limiter.limit("60/minute") +async def post_workflow_webhook( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: str = Path(..., description="Workflow ID"), + entryPointId: str = Path(..., description="Entry point ID (kind must be webhook)"), + body: dict = Body(default_factory=dict), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """ + Invoke a workflow via a webhook entry point. Optional shared secret in + X-Automation2-Webhook-Secret or X-Webhook-Secret when config.webhookSecret is set. + """ + mandateId = _validateInstanceAccess(instanceId, context) + userId = str(context.user.id) if context.user else None + a2 = getAutomation2Interface(context.user, mandateId, instanceId) + wf = a2.getWorkflow(workflowId) + if not wf or not wf.get("graph"): + raise HTTPException(status_code=404, detail="Workflow not found") + inv = find_invocation(wf, entryPointId) + if not inv: + raise HTTPException(status_code=404, detail="Entry point not found") + if inv.get("kind") != "webhook": + raise HTTPException(status_code=400, detail="Entry point is not a webhook") + if not inv.get("enabled", True): + raise HTTPException(status_code=400, detail="Entry point is disabled") + cfg = inv.get("config") or {} + secret = cfg.get("webhookSecret") + if secret: + hdr = request.headers.get("X-Automation2-Webhook-Secret") or request.headers.get( + "X-Webhook-Secret" + ) + if hdr != str(secret): + raise HTTPException(status_code=403, detail="Invalid webhook secret") + + services = getAutomation2Services( + context.user, + mandateId=mandateId, + featureInstanceId=instanceId, + ) + from modules.workflows.processing.shared.methodDiscovery import discoverMethods + + discoverMethods(services) + + title = inv.get("title") or {} + label = "" + if isinstance(title, dict): + label = title.get("en") or title.get("de") or "" + elif isinstance(title, str): + label = title + pl = body if isinstance(body, dict) else {} + base = default_run_envelope( + "webhook", + entry_point_id=inv.get("id"), + entry_point_label=label or None, + payload=pl, + raw={"httpBody": body}, + ) + run_env = normalize_run_envelope(base, user_id=userId) + + result = await executeGraph( + graph=wf["graph"], + services=services, + workflowId=workflowId, + instanceId=instanceId, + userId=userId, + mandateId=mandateId, + automation2_interface=a2, + run_envelope=run_env, + ) + return result + + +@router.post("/{instanceId}/workflows/{workflowId}/forms/{entryPointId}/submit") +@limiter.limit("60/minute") +async def post_workflow_form_submit( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + workflowId: str = Path(..., description="Workflow ID"), + entryPointId: str = Path(..., description="Entry point ID (kind must be form)"), + body: dict = Body(default_factory=dict), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Form-style submit: same as execute with trigger.type form and payload from body.""" + mandateId = _validateInstanceAccess(instanceId, context) + userId = str(context.user.id) if context.user else None + a2 = getAutomation2Interface(context.user, mandateId, instanceId) + wf = a2.getWorkflow(workflowId) + if not wf or not wf.get("graph"): + raise HTTPException(status_code=404, detail="Workflow not found") + inv = find_invocation(wf, entryPointId) + if not inv: + raise HTTPException(status_code=404, detail="Entry point not found") + if inv.get("kind") != "form": + raise HTTPException(status_code=400, detail="Entry point is not a form") + if not inv.get("enabled", True): + raise HTTPException(status_code=400, detail="Entry point is disabled") + + services = getAutomation2Services( + context.user, + mandateId=mandateId, + featureInstanceId=instanceId, + ) + from modules.workflows.processing.shared.methodDiscovery import discoverMethods + + discoverMethods(services) + + title = inv.get("title") or {} + label = "" + if isinstance(title, dict): + label = title.get("en") or title.get("de") or "" + elif isinstance(title, str): + label = title + pl = body if isinstance(body, dict) else {} + base = default_run_envelope( + "form", + entry_point_id=inv.get("id"), + entry_point_label=label or None, + payload=pl, + raw={"formBody": body}, + ) + run_env = normalize_run_envelope(base, user_id=userId) + + result = await executeGraph( + graph=wf["graph"], + services=services, + workflowId=workflowId, + instanceId=instanceId, + userId=userId, + mandateId=mandateId, + automation2_interface=a2, + run_envelope=run_env, + ) + return result + + # ------------------------------------------------------------------------- # Runs and Resume # ------------------------------------------------------------------------- +@router.get("/{instanceId}/runs/completed") +@limiter.limit("60/minute") +def get_completed_runs( + request: Request, + instanceId: str = Path(..., description="Feature instance ID"), + limit: int = Query(20, ge=1, le=50), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Get recently completed runs with output (for Tasks page output section).""" + mandateId = _validateInstanceAccess(instanceId, context) + a2 = getAutomation2Interface(context.user, mandateId, instanceId) + runs = a2.getRecentCompletedRuns(limit=limit) + return {"runs": runs} + + @router.get("/{instanceId}/workflows/{workflowId}/runs") @limiter.limit("60/minute") def get_workflow_runs( diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 6b8c529b..6a3500ce 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -162,6 +162,7 @@ _SOURCE_TYPE_TO_SERVICE = { "googleDriveFolder": "drive", "gmailFolder": "gmail", "ftpFolder": "files", + "clickupList": "clickup", } diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py new file mode 100644 index 00000000..1603fa23 --- /dev/null +++ b/modules/routes/routeClickup.py @@ -0,0 +1,288 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp API routes — teams, hierarchy, lists, tasks (connection-scoped).""" + +import logging +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status +from pydantic import BaseModel + +from modules.auth import getCurrentUser, limiter +from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection +from modules.interfaces.interfaceDbApp import getInterface +from modules.serviceHub import getInterface as getServices + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/api/clickup", + tags=["ClickUp"], + responses={ + 404: {"description": "Not found"}, + 400: {"description": "Bad request"}, + 401: {"description": "Unauthorized"}, + 500: {"description": "Internal server error"}, + }, +) + + +def _getUserConnection(interface, connection_id: str, user_id: str) -> Optional[UserConnection]: + try: + connections = interface.getUserConnections(user_id) + for conn in connections: + if conn.id == connection_id: + return conn + return None + except Exception as e: + logger.error(f"Error getting user connection: {e}") + return None + + +def _clickup_connection_or_404(interface, connection_id: str, user_id: str) -> UserConnection: + connection = _getUserConnection(interface, connection_id, user_id) + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found") + authority = connection.authority.value if hasattr(connection.authority, "value") else str(connection.authority) + if authority.lower() != AuthAuthority.CLICKUP.value: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Connection is not a ClickUp connection", + ) + return connection + + +def _svc_for_connection(current_user: User, connection: UserConnection): + services = getServices(current_user, None) + if not services.clickup.setAccessTokenFromConnection(connection): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Failed to set ClickUp access token", + ) + return services.clickup + + +# --- Routes (prefix is /api/clickup; OAuth lives under /api/clickup/auth/* in routeSecurityClickup) --- + + +@router.get("/{connectionId}/teams", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def get_teams( + request: Request, + connectionId: str = Path(..., description="ClickUp UserConnection id"), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getAuthorizedTeams() + + +@router.get("/{connectionId}/teams/{teamId}", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_team( + request: Request, + connectionId: str = Path(...), + teamId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + """Workspace/team details including members (for assignee pickers).""" + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getTeam(teamId) + + +@router.get("/{connectionId}/teams/{teamId}/spaces", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_spaces( + request: Request, + connectionId: str = Path(...), + teamId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getSpaces(teamId) + + +@router.get("/{connectionId}/spaces/{spaceId}/folders", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_folders( + request: Request, + connectionId: str = Path(...), + spaceId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getFolders(spaceId) + + +@router.get("/{connectionId}/spaces/{spaceId}/lists", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_folderless_lists( + request: Request, + connectionId: str = Path(...), + spaceId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getFolderlessLists(spaceId) + + +@router.get("/{connectionId}/folders/{folderId}/lists", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_lists_in_folder( + request: Request, + connectionId: str = Path(...), + folderId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getListsInFolder(folderId) + + +@router.get("/{connectionId}/lists/{listId}", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_list( + request: Request, + connectionId: str = Path(...), + listId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getList(listId) + + +@router.get("/{connectionId}/lists/{listId}/fields", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_list_fields( + request: Request, + connectionId: str = Path(...), + listId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getListFields(listId) + + +@router.get("/{connectionId}/lists/{listId}/tasks", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_list_tasks( + request: Request, + connectionId: str = Path(...), + listId: str = Path(...), + page: int = Query(0), + include_closed: bool = Query(False), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getTasksInList(listId, page=page, include_closed=include_closed) + + +class TaskCreateBody(BaseModel): + body: Dict[str, Any] + + +@router.post("/{connectionId}/lists/{listId}/tasks", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def create_list_task( + request: Request, + payload: TaskCreateBody, + connectionId: str = Path(...), + listId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.createTask(listId, payload.body) + + +class TaskUpdateBody(BaseModel): + body: Dict[str, Any] + + +@router.get("/{connectionId}/tasks/{taskId}", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def get_task( + request: Request, + connectionId: str = Path(...), + taskId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getTask(taskId) + + +@router.put("/{connectionId}/tasks/{taskId}", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def update_task( + request: Request, + payload: TaskUpdateBody, + connectionId: str = Path(...), + taskId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.updateTask(taskId, payload.body) + + +@router.delete("/{connectionId}/tasks/{taskId}", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def delete_task( + request: Request, + connectionId: str = Path(...), + taskId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.deleteTask(taskId) + + +@router.get("/{connectionId}/teams/{teamId}/tasks/search", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def search_team_tasks( + request: Request, + connectionId: str = Path(...), + teamId: str = Path(...), + query: str = Query(..., description="Search query"), + page: int = Query(0), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.searchTeamTasks(teamId, query=query, page=page) + + +@router.get("/{connectionId}/user", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def get_authorized_user( + request: Request, + connectionId: str = Path(...), + currentUser: User = Depends(getCurrentUser), +) -> Dict[str, Any]: + interface = getInterface(currentUser) + conn = _clickup_connection_or_404(interface, connectionId, currentUser.id) + cu = _svc_for_connection(currentUser, conn) + return await cu.getAuthorizedUser() diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 17ef0115..d01992c5 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -112,7 +112,8 @@ def get_auth_authority_options( authorityLabels = { "local": "Local", "google": "Google", - "msft": "Microsoft" + "msft": "Microsoft", + "clickup": "ClickUp", } return [ {"value": auth.value, "label": authorityLabels.get(auth.value, auth.value)} @@ -347,7 +348,8 @@ def create_connection( # Map type to authority authority_map = { 'msft': AuthAuthority.MSFT, - 'google': AuthAuthority.GOOGLE + 'google': AuthAuthority.GOOGLE, + 'clickup': AuthAuthority.CLICKUP, } authority = authority_map.get(connection_data.get('type')) @@ -493,6 +495,8 @@ def connect_service( auth_url = f"/api/msft/auth/connect?connectionId={quote(connectionId, safe='')}" elif connection.authority == AuthAuthority.GOOGLE: auth_url = f"/api/google/auth/connect?connectionId={quote(connectionId, safe='')}" + elif connection.authority == AuthAuthority.CLICKUP: + auth_url = f"/api/clickup/auth/connect?connectionId={quote(connectionId, safe='')}" else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/modules/routes/routeSecurityClickup.py b/modules/routes/routeSecurityClickup.py new file mode 100644 index 00000000..3d1aeed5 --- /dev/null +++ b/modules/routes/routeSecurityClickup.py @@ -0,0 +1,280 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp OAuth for data connections (UserConnection + Token).""" + +from fastapi import APIRouter, HTTPException, Request, status, Depends, Query +from fastapi.responses import HTMLResponse, RedirectResponse +import logging +import json +import time +from typing import Dict, Any +from urllib.parse import urlencode +import httpx +from jose import jwt as jose_jwt +from jose import JWTError + +from modules.shared.configuration import APP_CONFIG +from modules.interfaces.interfaceDbApp import getInterface, getRootInterface +from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatus, UserConnection +from modules.datamodels.datamodelSecurity import Token, TokenPurpose +from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM +from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp + +logger = logging.getLogger(__name__) + +_FLOW_CONNECT = "clickup_connect" + +CLICKUP_AUTH_BASE = "https://app.clickup.com/api" +CLICKUP_API_BASE = "https://api.clickup.com/api/v2" + +CLIENT_ID = APP_CONFIG.get("Service_CLICKUP_CLIENT_ID") +CLIENT_SECRET = APP_CONFIG.get("Service_CLICKUP_CLIENT_SECRET") +REDIRECT_URI = APP_CONFIG.get("Service_CLICKUP_OAUTH_REDIRECT_URI") + +# ClickUp states OAuth access tokens do not expire today; store a long horizon for DB status. +_CLICKUP_TOKEN_EXPIRES_IN_SEC = 10 * 365 * 24 * 3600 + + +def _issue_oauth_state(claims: Dict[str, Any]) -> str: + body = {**claims, "exp": int(time.time()) + 600} + return jose_jwt.encode(body, SECRET_KEY, algorithm=ALGORITHM) + + +def _parse_oauth_state(state: str) -> Dict[str, Any]: + try: + return jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM]) + except JWTError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid OAuth state: {e}" + ) from e + + +def _require_clickup_config(): + if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="ClickUp OAuth is not configured (Service_CLICKUP_CLIENT_ID, Service_CLICKUP_CLIENT_SECRET, Service_CLICKUP_OAUTH_REDIRECT_URI)", + ) + + +router = APIRouter( + prefix="/api/clickup", + tags=["Security ClickUp"], + responses={ + 404: {"description": "Not found"}, + 400: {"description": "Bad request"}, + 401: {"description": "Unauthorized"}, + 500: {"description": "Internal server error"}, + }, +) + + +@router.get("/auth/connect") +@limiter.limit("5/minute") +def auth_connect( + request: Request, + connectionId: str = Query(..., description="UserConnection id"), + currentUser: User = Depends(getCurrentUser), +) -> RedirectResponse: + """Start ClickUp OAuth for an existing connection (requires gateway session).""" + try: + _require_clickup_config() + interface = getInterface(currentUser) + connections = interface.getUserConnections(currentUser.id) + connection = None + for conn in connections: + if conn.id == connectionId and conn.authority == AuthAuthority.CLICKUP: + connection = conn + break + if not connection: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ClickUp connection not found") + + state_jwt = _issue_oauth_state( + { + "flow": _FLOW_CONNECT, + "connectionId": connectionId, + "userId": str(currentUser.id), + } + ) + query = urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": REDIRECT_URI, + "state": state_jwt, + } + ) + auth_url = f"{CLICKUP_AUTH_BASE}?{query}" + return RedirectResponse(auth_url) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error initiating ClickUp connect: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to initiate ClickUp connect: {str(e)}", + ) + + +@router.get("/auth/connect/callback") +async def auth_connect_callback( + code: str = Query(...), + state: str = Query(...), +) -> HTMLResponse: + """OAuth callback for ClickUp data connection.""" + state_data = _parse_oauth_state(state) + if state_data.get("flow") != _FLOW_CONNECT: + raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + connection_id = state_data.get("connectionId") + user_id = state_data.get("userId") + if not connection_id or not user_id: + raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state") + + _require_clickup_config() + + async with httpx.AsyncClient() as client: + token_resp = await client.post( + f"{CLICKUP_API_BASE}/oauth/token", + json={ + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "code": code, + }, + headers={"Content-Type": "application/json"}, + timeout=30.0, + ) + if token_resp.status_code != 200: + logger.error(f"ClickUp token exchange failed: {token_resp.status_code} {token_resp.text}") + return HTMLResponse( + content=f"

Connection Failed

{token_resp.text}

", + status_code=400, + ) + token_json = token_resp.json() + access_token = token_json.get("access_token") + if not access_token: + return HTMLResponse( + content="

Connection Failed

No access token.

", + status_code=400, + ) + + async with httpx.AsyncClient() as client: + user_resp = await client.get( + f"{CLICKUP_API_BASE}/user", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + timeout=30.0, + ) + if user_resp.status_code != 200: + logger.error(f"ClickUp user failed: {user_resp.status_code} {user_resp.text}") + return HTMLResponse( + content="

Connection Failed

Could not load ClickUp user.

", + status_code=400, + ) + user_payload = user_resp.json() + cu_user = user_payload.get("user") or {} + + rootInterface = getRootInterface() + user = rootInterface.getUser(user_id) + if not user: + return HTMLResponse( + content=""" + + """, + status_code=404, + ) + + interface = getInterface(user) + connections = interface.getUserConnections(user_id) + connection = None + for conn in connections: + if conn.id == connection_id: + connection = conn + break + if not connection: + return HTMLResponse( + content=""" + + """, + status_code=404, + ) + + ext_id = str(cu_user.get("id", "")) if cu_user.get("id") is not None else "" + username = cu_user.get("username") or cu_user.get("email") or ext_id + email = cu_user.get("email") + + expires_at = createExpirationTimestamp(_CLICKUP_TOKEN_EXPIRES_IN_SEC) + + try: + connection.status = ConnectionStatus.ACTIVE + connection.lastChecked = getUtcTimestamp() + connection.expiresAt = expires_at + connection.externalId = ext_id + connection.externalUsername = username + if email: + connection.externalEmail = email + connection.grantedScopes = None + rootInterface.db.recordModify(UserConnection, connection_id, connection.model_dump()) + + token = Token( + userId=user.id, + authority=AuthAuthority.CLICKUP, + connectionId=connection_id, + tokenPurpose=TokenPurpose.DATA_CONNECTION, + tokenAccess=access_token, + tokenRefresh=None, + tokenType="bearer", + expiresAt=expires_at, + createdAt=getUtcTimestamp(), + ) + interface.saveConnectionToken(token) + + return HTMLResponse( + content=f""" + + Connection Successful + + + + + """ + ) + except Exception as e: + logger.error(f"Error updating ClickUp connection: {str(e)}", exc_info=True) + return HTMLResponse( + content=f""" + + """, + status_code=500, + ) diff --git a/modules/serviceCenter/registry.py b/modules/serviceCenter/registry.py index cdf57304..851e4894 100644 --- a/modules/serviceCenter/registry.py +++ b/modules/serviceCenter/registry.py @@ -63,6 +63,13 @@ IMPORTABLE_SERVICES: Dict[str, Dict[str, Any]] = { "objectKey": "service.sharepoint", "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}, }, + "clickup": { + "module": "modules.serviceCenter.services.serviceClickup.mainServiceClickup", + "class": "ClickupService", + "dependencies": ["security"], + "objectKey": "service.clickup", + "label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}, + }, "chat": { "module": "modules.serviceCenter.services.serviceChat.mainServiceChat", "class": "ChatService", diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 78c69ff3..d65009e6 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -1552,6 +1552,7 @@ def _registerCoreTools(registry: ToolRegistry, services): "googleDriveFolder": "drive", "gmailFolder": "gmail", "ftpFolder": "files", + "clickupList": "clickup", } async def _resolveDataSource(dsId: str): diff --git a/modules/serviceCenter/services/serviceClickup/__init__.py b/modules/serviceCenter/services/serviceClickup/__init__.py new file mode 100644 index 00000000..6b3bb1f3 --- /dev/null +++ b/modules/serviceCenter/services/serviceClickup/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp service.""" + +from .mainServiceClickup import ClickupService, clickup_authorization_header + +__all__ = ["ClickupService", "clickup_authorization_header"] diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py new file mode 100644 index 00000000..6093e1bd --- /dev/null +++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py @@ -0,0 +1,223 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp API service (OAuth or personal token via UserConnection).""" + +import json +import logging +import asyncio +from typing import Any, Callable, Dict, List, Optional, Union + +import aiohttp + +logger = logging.getLogger(__name__) + +_CLICKUP_API_BASE = "https://api.clickup.com/api/v2" + + +def clickup_authorization_header(token: str) -> str: + """ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer.""" + t = (token or "").strip() + if t.startswith("pk_"): + return t + return f"Bearer {t}" + + +class ClickupService: + """ClickUp REST API v2 — teams, hierarchy, lists as tables (tasks + custom fields).""" + + def __init__(self, context, get_service: Callable[[str], Any]): + self._context = context + self._get_service = get_service + self.accessToken: Optional[str] = None + + def setAccessTokenFromConnection(self, userConnection) -> bool: + """Load OAuth/personal token from SecurityService for this UserConnection.""" + try: + if not userConnection: + logger.error("UserConnection is required to set access token") + return False + if isinstance(userConnection, dict): + connection_id = userConnection.get("id") + else: + connection_id = getattr(userConnection, "id", None) + if not connection_id: + logger.error("UserConnection must have an 'id' field") + return False + security = self._get_service("security") + if not security: + logger.error("Security service not available for token access") + return False + token = security.getFreshToken(connection_id) + if not token: + logger.error(f"No token found for connection {connection_id}") + return False + self.accessToken = token.tokenAccess + return True + except Exception as e: + logger.error(f"Error setting ClickUp access token: {e}") + return False + + def setAccessToken(self, token: str) -> None: + """Set token directly (e.g. connector adapter).""" + self.accessToken = token + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + data: Optional[aiohttp.FormData] = None, + ) -> Union[Dict[str, Any], List[Any], bytes, None]: + if not self.accessToken: + return {"error": "Access token is not set. Call setAccessTokenFromConnection first."} + url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}" + headers: Dict[str, str] = { + "Authorization": clickup_authorization_header(self.accessToken), + } + if json_body is not None: + headers["Content-Type"] = "application/json" + + timeout = aiohttp.ClientTimeout(total=60) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + kwargs: Dict[str, Any] = {"headers": headers, "params": params} + if json_body is not None: + kwargs["json"] = json_body + if data is not None: + kwargs["data"] = data + + async with session.request(method.upper(), url, **kwargs) as resp: + if resp.status == 204: + return {} + text = await resp.text() + if resp.status >= 400: + # 404 on GET is common (wrong id / preview) — avoid ERROR noise in logs + log = logger.warning if resp.status == 404 else logger.error + log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}") + return {"error": f"HTTP {resp.status}", "body": text} + if not text: + return {} + try: + return json.loads(text) + except Exception: + return {"raw": text} + except asyncio.TimeoutError: + return {"error": f"ClickUp API timeout: {path}"} + except Exception as e: + logger.error(f"ClickUp API error: {e}") + return {"error": str(e)} + + async def requestRaw( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Union[Dict[str, Any], List[Any], None]: + """Escape hatch: call any v2 path under /api/v2 (path without leading /api/v2).""" + return await self._request(method, path, params=params, json_body=json_body) + + # --- Teams / user --- + + async def getAuthorizedUser(self) -> Dict[str, Any]: + return await self._request("GET", "/user") + + async def getAuthorizedTeams(self) -> Dict[str, Any]: + return await self._request("GET", "/team") + + async def getTeam(self, team_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/team/{team_id}") + + # --- Hierarchy --- + + async def getSpaces(self, team_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/team/{team_id}/space") + + async def getSpace(self, space_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/space/{space_id}") + + async def getFolders(self, space_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/space/{space_id}/folder") + + async def getFolder(self, folder_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/folder/{folder_id}") + + async def getListsInFolder(self, folder_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/folder/{folder_id}/list") + + async def getFolderlessLists(self, space_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/space/{space_id}/list") + + async def getList(self, list_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/list/{list_id}") + + async def getListFields(self, list_id: str) -> Dict[str, Any]: + return await self._request("GET", f"/list/{list_id}/field") + + # --- Tasks (rows) --- + + async def getTasksInList( + self, + list_id: str, + *, + page: int = 0, + include_closed: bool = False, + subtasks: bool = True, + ) -> Dict[str, Any]: + params: Dict[str, Any] = { + "page": page, + "subtasks": str(subtasks).lower(), + "include_closed": str(include_closed).lower(), + } + return await self._request("GET", f"/list/{list_id}/task", params=params) + + async def getTask(self, task_id: str, *, include_subtasks: bool = True) -> Dict[str, Any]: + params = {"include_subtasks": str(include_subtasks).lower()} + return await self._request("GET", f"/task/{task_id}", params=params) + + async def createTask(self, list_id: str, body: Dict[str, Any]) -> Dict[str, Any]: + return await self._request("POST", f"/list/{list_id}/task", json_body=body) + + async def updateTask(self, task_id: str, body: Dict[str, Any]) -> Dict[str, Any]: + return await self._request("PUT", f"/task/{task_id}", json_body=body) + + async def deleteTask(self, task_id: str) -> Dict[str, Any]: + return await self._request("DELETE", f"/task/{task_id}") + + async def searchTeamTasks( + self, + team_id: str, + *, + query: str, + page: int = 0, + ) -> Dict[str, Any]: + """Search tasks in a workspace (team).""" + params = {"query": query, "page": page} + return await self._request("GET", f"/team/{team_id}/task", params=params) + + async def uploadTaskAttachment(self, task_id: str, file_bytes: bytes, file_name: str) -> Dict[str, Any]: + """Upload a file attachment to a task (multipart).""" + if not self.accessToken: + return {"error": "Access token is not set."} + url = f"{_CLICKUP_API_BASE}/task/{task_id}/attachment" + headers = {"Authorization": clickup_authorization_header(self.accessToken)} + data = aiohttp.FormData() + data.add_field( + "attachment", + file_bytes, + filename=file_name, + content_type="application/octet-stream", + ) + timeout = aiohttp.ClientTimeout(total=120) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, data=data) as resp: + text = await resp.text() + if resp.status >= 400: + return {"error": f"HTTP {resp.status}", "body": text} + return json.loads(text) if text else {} + except Exception as e: + return {"error": str(e)} diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py index 733b9ade..7a1277ca 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py @@ -125,9 +125,12 @@ class RendererDocx(BaseRenderer): self.logger.debug(f"_generateDocxFromJson: Document created in {time.time() - start_time:.2f}s") # Get style set: use styles from metadata if available, otherwise enhance with AI + template_from_metadata = None + if json_content and isinstance(json_content.get("metadata"), dict): + template_from_metadata = json_content["metadata"].get("templateName") style_start = time.time() self.logger.debug("_generateDocxFromJson: About to get style set") - styleSet = await self._getStyleSet(json_content, userPrompt, aiService) + styleSet = await self._getStyleSet(json_content, userPrompt, aiService, templateName=template_from_metadata) self.logger.debug(f"_generateDocxFromJson: Style set retrieved in {time.time() - style_start:.2f}s") # Setup basic document styles and create all styles from style set diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py index 2d0cc8d2..15a7161c 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py @@ -4,6 +4,8 @@ Text renderer for report generation. """ +import re + from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument from typing import Dict, Any, List, Optional @@ -93,9 +95,13 @@ class RendererText(BaseRenderer): metadata = extractedContent.get("metadata", {}) if extractedContent else {} documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + # UTF-8 BOM helps editors/browsers recognize encoding (fixes grössten → grössten) + text_bytes = textContent.encode('utf-8') + if not text_bytes.startswith(b'\xef\xbb\xbf'): + text_bytes = b'\xef\xbb\xbf' + text_bytes return [ RenderedDocument( - documentData=textContent.encode('utf-8'), + documentData=text_bytes, mimeType="text/plain", filename=filename, documentType=documentType, @@ -276,7 +282,7 @@ class RendererText(BaseRenderer): return "" def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str: - """Render a JSON bullet list to text.""" + """Render a JSON bullet list to text. Strips markdown from item text.""" try: # Extract from nested content structure: element.content.{items} content = listData.get("content", {}) @@ -290,9 +296,9 @@ class RendererText(BaseRenderer): textParts = [] for item in items: if isinstance(item, str): - textParts.append(f"- {item}") + textParts.append(f"- {self._stripMarkdownForPlainText(item)}") elif isinstance(item, dict) and "text" in item: - textParts.append(f"- {item['text']}") + textParts.append(f"- {self._stripMarkdownForPlainText(item['text'])}") return '\n'.join(textParts) @@ -301,13 +307,13 @@ class RendererText(BaseRenderer): return "" def _renderJsonHeading(self, headingData: Dict[str, Any]) -> str: - """Render a JSON heading to text.""" + """Render a JSON heading to text. Strips markdown from heading text.""" try: # Extract from nested content structure: element.content.{text, level} content = headingData.get("content", {}) if not isinstance(content, dict): return "" - text = content.get("text", "") + text = self._stripMarkdownForPlainText(content.get("text", "")) level = content.get("level", 1) if text: @@ -325,8 +331,22 @@ class RendererText(BaseRenderer): self.logger.warning(f"Error rendering heading: {str(e)}") return "" + def _stripMarkdownForPlainText(self, text: str) -> str: + """Strip markdown formatting for plain text output (**bold** -> bold, *italic* -> italic).""" + if not text: + return "" + # **bold** and __bold__ -> plain + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + # *italic* and _italic_ -> plain + text = re.sub(r'(? plain + text = re.sub(r'`([^`]+)`', r'\1', text) + return text.strip() + def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str: - """Render a JSON paragraph to text.""" + """Render a JSON paragraph to text. Strips markdown for plain text output.""" try: # Extract from nested content structure content = paragraphData.get("content", {}) @@ -336,7 +356,7 @@ class RendererText(BaseRenderer): text = content else: text = "" - return text if text else "" + return self._stripMarkdownForPlainText(text) if text else "" except Exception as e: self.logger.warning(f"Error rendering paragraph: {str(e)}") diff --git a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py index 329f09f6..8a3e7cea 100644 --- a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py +++ b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py @@ -3,10 +3,155 @@ import json import logging import os +import re from typing import Any, Dict logger = logging.getLogger(__name__) + +def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> Dict[str, Any]: + """ + Convert markdown content to the standard document JSON format expected by renderReport. + Supports headings, code blocks, tables, lists, images (file: refs), paragraphs. + For plain text: wraps entire content in a single paragraph section. + """ + if not isinstance(markdown, str): + markdown = str(markdown) if markdown else "" + + sections = [] + order = 0 + lines = markdown.split("\n") + i = 0 + + def _nextId(): + nonlocal order + order += 1 + return f"s_{order}" + + while i < len(lines): + line = lines[i] + + # Headings + headingMatch = re.match(r"^(#{1,6})\s+(.+)", line) + if headingMatch: + level = len(headingMatch.group(1)) + text = headingMatch.group(2).strip() + sections.append({ + "id": _nextId(), "content_type": "heading", "order": order, + "elements": [{"content": {"text": text, "level": level}}], + }) + i += 1 + continue + + # Fenced code blocks + codeMatch = re.match(r"^```(\w*)", line) + if codeMatch: + lang = codeMatch.group(1) or "text" + codeLines = [] + i += 1 + while i < len(lines) and not lines[i].startswith("```"): + codeLines.append(lines[i]) + i += 1 + i += 1 + sections.append({ + "id": _nextId(), "content_type": "code_block", "order": order, + "elements": [{"content": {"code": "\n".join(codeLines), "language": lang}}], + }) + continue + + # Tables + tableMatch = re.match(r"^\|(.+)\|$", line) + if tableMatch and (i + 1) < len(lines) and re.match(r"^\|[\s\-:|]+\|$", lines[i + 1]): + headerCells = [c.strip() for c in tableMatch.group(1).split("|")] + i += 2 + rows = [] + while i < len(lines) and re.match(r"^\|(.+)\|$", lines[i]): + rowCells = [c.strip() for c in lines[i][1:-1].split("|")] + rows.append(rowCells) + i += 1 + sections.append({ + "id": _nextId(), "content_type": "table", "order": order, + "elements": [{"content": {"headers": headerCells, "rows": rows}}], + }) + continue + + # Bullet / numbered lists + listMatch = re.match(r"^(\s*)([-*+]|\d+[.)]) (.+)", line) + if listMatch: + isNumbered = bool(re.match(r"\d+[.)]", listMatch.group(2))) + items = [] + while i < len(lines) and re.match(r"^(\s*)([-*+]|\d+[.)]) (.+)", lines[i]): + m = re.match(r"^(\s*)([-*+]|\d+[.)]) (.+)", lines[i]) + items.append({"text": m.group(3).strip()}) + i += 1 + sections.append({ + "id": _nextId(), "content_type": "bullet_list", "order": order, + "elements": [{"content": {"items": items, "list_type": "numbered" if isNumbered else "bullet"}}], + }) + continue + + # Empty lines + if not line.strip(): + i += 1 + continue + + # Images (simplified: store as paragraph with ref for now - full resolution needs Knowledge Store) + imgMatch = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)", line) + if imgMatch: + altText = imgMatch.group(1).strip() or "Image" + src = imgMatch.group(2).strip() + fileId = src[5:] if src.startswith("file:") else "" + sections.append({ + "id": _nextId(), "content_type": "image", "order": order, + "elements": [{ + "content": { + "altText": altText, + "base64Data": "", + "_fileRef": fileId, + "_srcUrl": src if not fileId else "", + } + }], + }) + i += 1 + continue + + # Paragraph + paraLines = [] + while i < len(lines) and lines[i].strip() and not re.match( + r"^(#{1,6}\s|```|\|.+\||!\[|(\s*)([-*+]|\d+[.)]) )", lines[i] + ): + paraLines.append(lines[i]) + i += 1 + if paraLines: + sections.append({ + "id": _nextId(), "content_type": "paragraph", "order": order, + "elements": [{"content": {"text": " ".join(paraLines)}}], + }) + continue + + i += 1 + + if not sections: + sections.append({ + "id": _nextId(), "content_type": "paragraph", "order": order, + "elements": [{"content": {"text": markdown.strip() or "(empty)"}}], + }) + + return { + "metadata": { + "split_strategy": "single_document", + "source_documents": [], + "extraction_method": "file_create_rendering", + "title": title, + "language": language, + }, + "documents": [{ + "id": "doc_1", + "title": title, + "sections": sections, + }], + } + def getFileExtension(fileName: str) -> str: """Extract file extension from fileName (without dot, lowercased).""" if '.' in fileName: diff --git a/modules/shared/eventManagement.py b/modules/shared/eventManagement.py index 3bb45af8..ebbf2131 100644 --- a/modules/shared/eventManagement.py +++ b/modules/shared/eventManagement.py @@ -1,5 +1,6 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. +import asyncio import logging from typing import Callable, Optional, Dict, Any @@ -25,14 +26,28 @@ class EventManagement: def __init__(self, timezone: str = "Europe/Zurich"): self._timezone = ZoneInfo(timezone) self._scheduler: Optional[AsyncIOScheduler] = None + self._event_loop: Optional[asyncio.AbstractEventLoop] = None + + def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Set the event loop for the scheduler (call from lifespan before start).""" + self._event_loop = loop @property def scheduler(self) -> AsyncIOScheduler: if self._scheduler is None: - self._scheduler = AsyncIOScheduler(timezone=self._timezone) + kwargs = {"timezone": self._timezone} + if self._event_loop is not None: + kwargs["event_loop"] = self._event_loop + self._scheduler = AsyncIOScheduler(**kwargs) return self._scheduler def start(self) -> None: + if self._event_loop is None: + try: + self._event_loop = asyncio.get_running_loop() + logger.debug("EventManagement: using get_running_loop()") + except RuntimeError: + pass if not self.scheduler.running: self.scheduler.start() logger.info("EventManagement scheduler started") @@ -90,10 +105,18 @@ class EventManagement: ) -> None: """ Register a job using IntervalTrigger. + Only passes non-None interval components (IntervalTrigger fails on None). """ - trigger = IntervalTrigger( - seconds=seconds, minutes=minutes, hours=hours, timezone=self._timezone - ) + trigger_kwargs: Dict[str, Any] = {"timezone": self._timezone} + if seconds is not None: + trigger_kwargs["seconds"] = seconds + if minutes is not None: + trigger_kwargs["minutes"] = minutes + if hours is not None: + trigger_kwargs["hours"] = hours + if len(trigger_kwargs) <= 1: + raise ValueError("At least one of seconds, minutes, hours must be provided") + trigger = IntervalTrigger(**trigger_kwargs) self.scheduler.add_job( func, trigger, diff --git a/modules/workflows/automation2/clickupTaskUpdateMerge.py b/modules/workflows/automation2/clickupTaskUpdateMerge.py new file mode 100644 index 00000000..a74cdaef --- /dev/null +++ b/modules/workflows/automation2/clickupTaskUpdateMerge.py @@ -0,0 +1,174 @@ +# Copyright (c) 2025 Patrick Motsch +# Merge clickup.updateTask node parameter taskUpdateEntries into taskUpdate JSON. + +import json +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +def _unwrap_value(v: Any) -> Any: + if isinstance(v, dict) and v.get("type") == "value" and "value" in v: + return v.get("value") + return v + + +def _unwrap_dynamic(v: Any) -> Any: + return _unwrap_value(v) + + +def _parse_int_list(val: Any) -> List[int]: + if val is None: + return [] + val = _unwrap_value(val) + if isinstance(val, str) and val.strip(): + try: + parsed = json.loads(val) + if isinstance(parsed, list): + return [int(x) for x in parsed if x is not None and str(x).strip() != ""] + except (json.JSONDecodeError, ValueError, TypeError): + return [] + if isinstance(val, list): + out: List[int] = [] + for x in val: + if x is None or (isinstance(x, str) and not x.strip()): + continue + try: + out.append(int(x)) + except (ValueError, TypeError): + continue + return out + return [] + + +def _parse_due_date_ms(v: Any) -> Optional[int]: + v = _unwrap_value(v) + if v is None or v == "": + return None + if isinstance(v, str) and len(v) >= 10 and v[4] == "-" and v[7] == "-": + try: + dt = datetime.strptime(v[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except ValueError: + pass + try: + i = int(float(v)) + return i if i > 0 else None + except (ValueError, TypeError): + return None + + +def _parse_time_estimate_hours_to_ms(v: Any) -> Optional[int]: + v = _unwrap_value(v) + if v is None or v == "": + return None + try: + h = float(v) + if h < 0: + return None + return int(round(h * 3600 * 1000)) + except (ValueError, TypeError): + return None + + +def merge_clickup_task_update_entries(resolved_params: Dict[str, Any]) -> None: + """ + Pop taskUpdateEntries from resolved_params and merge into taskUpdate (dict or JSON string). + Existing taskUpdate (advanced JSON) is the base; entry rows override by key. + """ + entries = resolved_params.pop("taskUpdateEntries", None) + json_raw = resolved_params.get("taskUpdate") + base: Dict[str, Any] = {} + if isinstance(json_raw, str) and json_raw.strip(): + try: + parsed = json.loads(json_raw) + if isinstance(parsed, dict): + base = dict(parsed) + except json.JSONDecodeError: + logger.warning("clickup.updateTask: taskUpdate is not valid JSON, ignoring base") + elif isinstance(json_raw, dict): + base = dict(json_raw) + + if not isinstance(entries, list) or not entries: + if not base and json_raw not in (None, "", {}): + resolved_params["taskUpdate"] = json_raw + elif base: + resolved_params["taskUpdate"] = json.dumps(base, ensure_ascii=False) + return + + overlay: Dict[str, Any] = {} + custom_rows: List[Dict[str, Any]] = [] + + for row in entries: + if not isinstance(row, dict): + continue + fk = row.get("fieldKey") or row.get("field") + if fk is None: + continue + fk = str(fk).strip() + val = _unwrap_dynamic(row.get("value")) + + if fk == "custom_field": + cfid = _unwrap_dynamic(row.get("customFieldId")) + if not cfid or not str(cfid).strip(): + continue + if val is None or val == "": + continue + custom_rows.append({"id": str(cfid).strip(), "value": val}) + continue + + if fk == "name" and val is not None and str(val).strip(): + overlay["name"] = str(val).strip() + elif fk == "description": + overlay["description"] = "" if val is None else str(val) + elif fk == "status" and val is not None and str(val).strip(): + overlay["status"] = str(val).strip() + elif fk == "priority": + if val is None or val == "": + continue + try: + pi = int(float(val)) + if 1 <= pi <= 4: + overlay["priority"] = pi + except (ValueError, TypeError): + pass + elif fk == "due_date": + dms = _parse_due_date_ms(val) + if dms is not None: + overlay["due_date"] = dms + elif fk == "time_estimate_h": + tms = _parse_time_estimate_hours_to_ms(val) + if tms is not None: + overlay["time_estimate"] = tms + elif fk == "time_estimate_ms": + if val is None or val == "": + continue + try: + tms = int(float(val)) + if tms > 0: + overlay["time_estimate"] = tms + except (ValueError, TypeError): + pass + elif fk == "assignees": + ids = _parse_int_list(val) + if ids: + overlay["assignees"] = ids + else: + logger.debug("clickup.updateTask: unknown fieldKey %s", fk) + + merged = {**base, **overlay} + + if custom_rows: + by_id: Dict[str, Dict[str, Any]] = {} + existing = merged.get("custom_fields") + if isinstance(existing, list): + for x in existing: + if isinstance(x, dict) and x.get("id") is not None: + by_id[str(x["id"])] = x + for x in custom_rows: + by_id[str(x["id"])] = x + merged["custom_fields"] = list(by_id.values()) + + resolved_params["taskUpdate"] = json.dumps(merged, ensure_ascii=False) if merged else "" diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py index 2e799707..3ab08992 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflows/automation2/executionEngine.py @@ -11,18 +11,19 @@ from modules.workflows.automation2.graphUtils import ( validateGraph, topoSort, getInputSources, + getLoopBodyNodeIds, ) from modules.workflows.automation2.executors import ( TriggerExecutor, FlowExecutor, - DataExecutor, ActionNodeExecutor, InputExecutor, PauseForHumanTaskError, PauseForEmailWaitError, ) from modules.features.automation2.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflows.automation2.runEnvelope import normalize_run_envelope logger = logging.getLogger(__name__) @@ -32,6 +33,38 @@ def _getNodeTypeIds(services: Any = None) -> Set[str]: return {n["id"] for n in STATIC_NODE_TYPES} +def _is_node_on_active_path( + nodeId: str, + connectionMap: Dict[str, List], + nodeOutputs: Dict[str, Any], +) -> bool: + """ + Return True if this node receives input only from active branches. + - flow.ifElse: only one output (0=yes, 1=no) is active; uses "branch". + - flow.switch: only one output (0, 1, 2, ...) is active; uses "match". + Nodes connected to inactive outputs must be skipped. + Also skip when a predecessor was skipped (not in nodeOutputs). + """ + for src, source_output, _ in connectionMap.get(nodeId, []): + out = nodeOutputs.get(src) + if out is None: + return False + if not isinstance(out, dict): + continue + branch = out.get("branch") + match = out.get("match") + active_output = None + if branch is not None: + active_output = branch + elif match is not None: + if match < 0: + return False # switch: no case matched, skip all downstream + active_output = match + if active_output is not None and source_output != active_output: + return False + return True + + def _getExecutor( nodeType: str, services: Any, @@ -42,9 +75,7 @@ def _getExecutor( return TriggerExecutor() if nodeType.startswith("flow."): return FlowExecutor() - if nodeType.startswith("data."): - return DataExecutor() - if nodeType.startswith("ai.") or nodeType.startswith("email.") or nodeType.startswith("sharepoint."): + if nodeType.startswith("ai.") or nodeType.startswith("email.") or nodeType.startswith("sharepoint.") or nodeType.startswith("clickup.") or nodeType.startswith("file."): return ActionNodeExecutor(services) if nodeType.startswith("input.") and automation2_interface: return InputExecutor(automation2_interface) @@ -62,12 +93,14 @@ async def executeGraph( initialNodeOutputs: Optional[Dict[str, Any]] = None, startAfterNodeId: Optional[str] = None, runId: Optional[str] = None, + run_envelope: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Execute automation2 graph. Returns { success, nodeOutputs, error?, stopped? }. When an input node is reached and automation2_interface is provided, creates a task, pauses the run, and returns { success: False, paused: True, taskId, runId }. For resume: pass initialNodeOutputs (with result for the human node) and startAfterNodeId. + For fresh runs: pass run_envelope (unified start payload for the start node); normalized with userId into context.runEnvelope. """ logger.info( "executeGraph start: instanceId=%s workflowId=%s userId=%s mandateId=%s resume=%s", @@ -122,6 +155,8 @@ async def executeGraph( runId = run.get("id") if run else None logger.info("executeGraph created run %s", runId) + env_for_run = normalize_run_envelope(run_envelope, user_id=userId) + context = { "workflowId": workflowId, "instanceId": instanceId, @@ -133,19 +168,76 @@ async def executeGraph( "services": services, "_runId": runId, "_orderedNodes": ordered, + "runEnvelope": env_for_run, } skip_until_passed = bool(startAfterNodeId) + processed_in_loop: Set[str] = set() + + # Check for loop resume: run was paused inside a loop, we're resuming for next iteration + run = automation2_interface.getRun(runId) if (runId and automation2_interface) else None + loop_resume_state = (run.get("context") or {}).get("_loopState") if run else None + if loop_resume_state and startAfterNodeId: + loop_node_id = loop_resume_state.get("loopNodeId") + next_index = loop_resume_state.get("currentIndex", -1) + 1 + items = loop_resume_state.get("items") or [] + body_ids = getLoopBodyNodeIds(loop_node_id, connectionMap) if loop_node_id else set() + body_ordered = [n for n in ordered if n.get("id") in body_ids] + processed_in_loop = set(body_ids) | {loop_node_id} if loop_node_id else set() + while next_index < len(items) and loop_node_id: + nodeOutputs[loop_node_id] = { + "items": items, + "count": len(items), + "currentItem": items[next_index], + "currentIndex": next_index, + } + context["_loopState"] = {"loopNodeId": loop_node_id, "currentIndex": next_index, "items": items} + for body_node in body_ordered: + bnid = body_node.get("id") + if not bnid or context.get("_stopped"): + break + if not _is_node_on_active_path(bnid, connectionMap, nodeOutputs): + continue + executor = _getExecutor(body_node.get("type", ""), services, automation2_interface) + if not executor: + nodeOutputs[bnid] = None + continue + try: + result = await executor.execute(body_node, context) + nodeOutputs[bnid] = result + logger.info("executeGraph loop resume body node %s done (iter %d)", bnid, next_index) + except PauseForHumanTaskError as e: + if automation2_interface: + run_ctx = dict(run.get("context") or {}) + run_ctx["_loopState"] = {"loopNodeId": loop_node_id, "currentIndex": next_index, "items": items} + automation2_interface.updateRun(e.runId, status="paused", nodeOutputs=dict(nodeOutputs), currentNodeId=e.nodeId, context=run_ctx) + return {"success": False, "paused": True, "taskId": e.taskId, "runId": e.runId, "nodeId": e.nodeId, "nodeOutputs": dict(nodeOutputs)} + except Exception as ex: + logger.exception("executeGraph loop body node %s FAILED: %s", bnid, ex) + nodeOutputs[bnid] = {"error": str(ex), "success": False} + if runId and automation2_interface: + automation2_interface.updateRun(runId, status="failed", nodeOutputs=nodeOutputs) + return {"success": False, "error": str(ex), "nodeOutputs": nodeOutputs, "failedNode": bnid} + next_index += 1 + if loop_node_id: + nodeOutputs[loop_node_id] = {"items": items, "count": len(items)} + processed_in_loop = set(body_ids) | {loop_node_id} + for i, node in enumerate(ordered): if skip_until_passed: if node.get("id") == startAfterNodeId: skip_until_passed = False continue + if node.get("id") in processed_in_loop: + continue if context.get("_stopped"): - logger.info("executeGraph stopped early (flow.stop) at step %d", i) + logger.info("executeGraph stopped early at step %d", i) break nodeId = node.get("id") nodeType = node.get("type", "") + if not _is_node_on_active_path(nodeId, connectionMap, nodeOutputs): + logger.info("executeGraph step %d/%d: nodeId=%s SKIP (inactive branch)", i + 1, len(ordered), nodeId) + continue executor = _getExecutor(nodeType, services, automation2_interface) logger.info( "executeGraph step %d/%d: nodeId=%s nodeType=%s executor=%s", @@ -160,14 +252,54 @@ async def executeGraph( logger.debug("executeGraph node %s: no executor, output=None", nodeId) continue try: - result = await executor.execute(node, context) - nodeOutputs[nodeId] = result - logger.info( - "executeGraph node %s done: result_type=%s result_keys=%s", - nodeId, - type(result).__name__, - list(result.keys()) if isinstance(result, dict) else "n/a", - ) + if nodeType == "flow.loop": + result = await executor.execute(node, context) + items = result.get("items") or [] + body_ids = getLoopBodyNodeIds(nodeId, connectionMap) + body_ordered = [n for n in ordered if n.get("id") in body_ids] + processed_in_loop.update(body_ids) + processed_in_loop.add(nodeId) + for idx, item in enumerate(items): + nodeOutputs[nodeId] = {"items": items, "count": len(items), "currentItem": item, "currentIndex": idx} + context["_loopState"] = {"loopNodeId": nodeId, "currentIndex": idx, "items": items} + for body_node in body_ordered: + bnid = body_node.get("id") + if not bnid or context.get("_stopped"): + break + if not _is_node_on_active_path(bnid, connectionMap, nodeOutputs): + continue + bexec = _getExecutor(body_node.get("type", ""), services, automation2_interface) + if not bexec: + nodeOutputs[bnid] = None + continue + try: + bres = await bexec.execute(body_node, context) + nodeOutputs[bnid] = bres + logger.info("executeGraph loop body node %s done (iter %d)", bnid, idx) + except PauseForHumanTaskError as e: + if runId and automation2_interface: + run = automation2_interface.getRun(runId) or {} + run_ctx = dict(run.get("context") or {}) + run_ctx["_loopState"] = {"loopNodeId": nodeId, "currentIndex": idx, "items": items} + automation2_interface.updateRun(e.runId, status="paused", nodeOutputs=dict(nodeOutputs), currentNodeId=e.nodeId, context=run_ctx) + return {"success": False, "paused": True, "taskId": e.taskId, "runId": e.runId, "nodeId": e.nodeId, "nodeOutputs": dict(nodeOutputs)} + except Exception as ex: + logger.exception("executeGraph loop body node %s FAILED: %s", bnid, ex) + nodeOutputs[bnid] = {"error": str(ex), "success": False} + if runId and automation2_interface: + automation2_interface.updateRun(runId, status="failed", nodeOutputs=nodeOutputs) + return {"success": False, "error": str(ex), "nodeOutputs": nodeOutputs, "failedNode": bnid} + nodeOutputs[nodeId] = {"items": items, "count": len(items)} + logger.info("executeGraph flow.loop done: %d iterations", len(items)) + else: + result = await executor.execute(node, context) + nodeOutputs[nodeId] = result + logger.info( + "executeGraph node %s done: result_type=%s result_keys=%s", + nodeId, + type(result).__name__, + list(result.keys()) if isinstance(result, dict) else "n/a", + ) except PauseForHumanTaskError as e: logger.info("executeGraph paused for human task %s", e.taskId) return { diff --git a/modules/workflows/automation2/executors/__init__.py b/modules/workflows/automation2/executors/__init__.py index c147a0d0..2b6768df 100644 --- a/modules/workflows/automation2/executors/__init__.py +++ b/modules/workflows/automation2/executors/__init__.py @@ -3,14 +3,12 @@ from .triggerExecutor import TriggerExecutor from .flowExecutor import FlowExecutor -from .dataExecutor import DataExecutor from .actionNodeExecutor import ActionNodeExecutor from .inputExecutor import InputExecutor, PauseForHumanTaskError, PauseForEmailWaitError __all__ = [ "TriggerExecutor", "FlowExecutor", - "DataExecutor", "ActionNodeExecutor", "InputExecutor", "PauseForHumanTaskError", diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 504fb34e..ab19964d 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -1,11 +1,31 @@ # Copyright (c) 2025 Patrick Motsch -# Action node executor - maps ai.*, email.*, sharepoint.* to method actions via ActionExecutor. +# 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.""" @@ -63,11 +83,12 @@ 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 = inp.get("documents", inp.get("documentList", [])) if isinstance(inp, dict) else [] + docs = _getDocumentsFromUpstream(inp) if not docs: return None doc = docs[0] if isinstance(docs, list) else docs @@ -92,15 +113,12 @@ 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 = 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)): + docs = _getDocumentsFromUpstream(inp) + if not docs: return None doc = docs[0] if docs else None if not doc: @@ -114,6 +132,63 @@ def _extractContextFromUpstream(inp: Any) -> Optional[str]: 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]], @@ -140,7 +215,7 @@ def _gatherAttachmentDocumentsFromUpstream( if srcType in ("sharepoint.downloadFile", "sharepoint.readFile"): if isinstance(out, dict): - for d in out.get("documents") or out.get("documentList") or []: + 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"): @@ -152,6 +227,62 @@ def _gatherAttachmentDocumentsFromUpstream( 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]], @@ -189,12 +320,14 @@ def _getIncomingEmailFromUpstream( def _formatEmailOutputAsContext(out: Any) -> Optional[tuple]: - """Format email node output as (context, documentList, reply_to) for composeAndDraftEmail. + """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 = out.get("documents", out.get("documentList", [])) if isinstance(out, dict) else [] + docs = _getDocumentsFromUpstream(out) if not docs: return None doc = docs[0] if isinstance(docs, list) else docs @@ -217,6 +350,7 @@ def _formatEmailOutputAsContext(out: Any) -> Optional[tuple]: 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): @@ -227,6 +361,8 @@ def _formatEmailOutputAsContext(out: Any) -> Optional[tuple]: 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] + "..." @@ -238,7 +374,7 @@ def _formatEmailOutputAsContext(out: Any) -> Optional[tuple]: parts.insert(2, f"Recipient (reply to this address): {reply_to}") parts.insert(3, "") context = "\n".join(parts).strip() - return (context, docs, reply_to) + return (context, docs, reply_to, first_subject) def _buildSearchQuery( @@ -349,6 +485,10 @@ def _buildActionParams( 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) @@ -384,7 +524,7 @@ def _buildActionParams( class ActionNodeExecutor: - """Execute ai.*, email.*, sharepoint.* nodes by mapping to method actions.""" + """Execute ai.*, email.*, sharepoint.*, clickup.* nodes by mapping to method actions.""" def __init__(self, services: Any): self.services = services @@ -414,16 +554,57 @@ class ActionNodeExecutor: 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 input from connected nodes (documentList, etc.) + 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) - if isinstance(inp, dict): - resolvedParams.setdefault("documentList", inp.get("documents", inp.get("documentList", []))) + 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) @@ -434,17 +615,20 @@ class ActionNodeExecutor: 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( + incoming = _unpackIncomingEmail(_getIncomingEmailFromUpstream( nodeId, context.get("inputSources", {}), context.get("nodeOutputs", {}), orderedNodes, - ) + )) if incoming: - ctx, _doc_list, _reply_to = incoming + ctx, _doc_list, _reply_to, _ = incoming if ctx and ctx.strip(): - base_prompt = (resolvedParams.get("aiPrompt") or "").strip() - resolvedParams["aiPrompt"] = ( + # 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}" @@ -454,6 +638,11 @@ class ActionNodeExecutor: 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") @@ -492,9 +681,26 @@ class ActionNodeExecutor: 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: - actionParams["emailContent"] = email_content - actionParams["context"] = email_content.get("body", "") or "(from connected AI node)" + # 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 @@ -514,27 +720,44 @@ class ActionNodeExecutor: 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) + # 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": actionParams.get("subject", "Draft"), + "subject": reply_subject or actionParams.get("subject", "Draft"), "body": ctx, - "to": actionParams.get("to"), + "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 (if flow is email->AI->draft) - incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes) + # Fallback: incoming email from upstream (AI returned nothing usable) + incoming = _unpackIncomingEmail(_getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)) if incoming: - ctx, doc_list, reply_to = incoming - actionParams["context"] = ctx + 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(inp.get("documents", [])) if isinstance(inp, dict) else 0 + 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.", @@ -545,7 +768,7 @@ class ActionNodeExecutor: # 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 [] + docs = _getDocumentsFromUpstream(inp) doc = docs[0] if docs else None name = None if isinstance(doc, dict): @@ -563,32 +786,71 @@ class ActionNodeExecutor: else: # Direct connection to email.checkEmail/searchEmail: use incoming email as context if not actionParams.get("context"): - incoming = _getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes) + incoming = _unpackIncomingEmail(_getIncomingEmailFromUpstream(nodeId, inputSources, nodeOutputs, orderedNodes)) if incoming: - ctx, doc_list, reply_to = incoming - actionParams["context"] = ctx + 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 consume content from context + # 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": [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])], + "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, diff --git a/modules/workflows/automation2/executors/dataExecutor.py b/modules/workflows/automation2/executors/dataExecutor.py deleted file mode 100644 index 386c8abd..00000000 --- a/modules/workflows/automation2/executors/dataExecutor.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# Data transformation node executor (setFields, filter, parseJson, template). - -import json -import logging -import re -from typing import Dict, Any, List - -logger = logging.getLogger(__name__) - - -def _get_nested(obj: Any, path: str) -> Any: - """Get nested key from obj, e.g. 'data.items'.""" - for k in path.split("."): - if not k: - continue - if isinstance(obj, dict) and k in obj: - obj = obj[k] - elif isinstance(obj, (list, tuple)) and k.isdigit(): - obj = obj[int(k)] - else: - return None - return obj - - -class DataExecutor: - """Execute data transformation nodes.""" - - async def execute( - self, - node: Dict[str, Any], - context: Dict[str, Any], - ) -> Any: - nodeType = node.get("type", "") - nodeOutputs = context.get("nodeOutputs", {}) - nodeId = node.get("id", "") - inputSources = context.get("inputSources", {}).get(nodeId, {}) - params = node.get("parameters") or {} - logger.info( - "DataExecutor node %s type=%s inputSources=%s params=%s", - nodeId, - nodeType, - inputSources, - params, - ) - - inp = None - if 0 in inputSources: - srcId, _ = inputSources[0] - inp = nodeOutputs.get(srcId) - - from modules.workflows.automation2.graphUtils import resolveParameterReferences - resolvedParams = {k: resolveParameterReferences(v, nodeOutputs) for k, v in params.items()} - - if nodeType == "data.setFields": - out = self._setFields(inp, resolvedParams) - logger.info("DataExecutor node %s setFields inp=%s -> %s", nodeId, type(inp).__name__, out) - return out - if nodeType == "data.filter": - out = self._filter(inp, resolvedParams) - logger.info("DataExecutor node %s filter inp=%s -> len=%d", nodeId, type(inp).__name__, len(out) if isinstance(out, list) else -1) - return out - if nodeType == "data.parseJson": - out = self._parseJson(inp, resolvedParams) - logger.info("DataExecutor node %s parseJson -> %s", nodeId, type(out).__name__) - return out - if nodeType == "data.template": - out = self._template(inp, resolvedParams, nodeOutputs) - logger.info("DataExecutor node %s template -> %s", nodeId, out) - return out - - logger.debug("DataExecutor node %s unhandled type %s -> passThrough", nodeId, nodeType) - return inp - - def _setFields(self, inp: Any, params: Dict) -> Any: - fields = params.get("fields", {}) - if not isinstance(fields, dict): - return inp - base = dict(inp) if isinstance(inp, dict) else {} - base.update(fields) - return base - - def _filter(self, inp: Any, params: Dict) -> Any: - itemsPath = (params.get("itemsPath") or "").strip() - condition = params.get("condition", "True") - items = inp - if itemsPath: - items = _get_nested(inp, itemsPath) - if not isinstance(items, list): - items = [inp] if inp is not None else [] - out = [] - for i, item in enumerate(items): - try: - local = {"item": item, "index": i, "input": inp} - ok = bool(eval(condition, {"__builtins__": {}}, local)) - if ok: - out.append(item) - except Exception: - pass - return out - - def _parseJson(self, inp: Any, params: Dict) -> Any: - jsonPath = (params.get("jsonPath") or "").strip() - raw = inp - if jsonPath: - raw = _get_nested(inp, jsonPath) if isinstance(inp, dict) else inp - if isinstance(raw, dict): - return raw - if isinstance(raw, str): - try: - return json.loads(raw) - except json.JSONDecodeError: - return {"error": "Invalid JSON", "raw": raw[:200]} - return inp - - def _template(self, inp: Any, params: Dict, nodeOutputs: Dict) -> Any: - tpl = params.get("template", "") - from modules.workflows.automation2.graphUtils import resolveParameterReferences - result = resolveParameterReferences(tpl, nodeOutputs) - return {"text": result, "template": tpl} diff --git a/modules/workflows/automation2/executors/flowExecutor.py b/modules/workflows/automation2/executors/flowExecutor.py index de5789e5..0df17335 100644 --- a/modules/workflows/automation2/executors/flowExecutor.py +++ b/modules/workflows/automation2/executors/flowExecutor.py @@ -1,9 +1,8 @@ # Copyright (c) 2025 Patrick Motsch -# Flow control node executor (ifElse, merge, wait, stop). +# Flow control node executor (ifElse, switch, loop). -import asyncio import logging -from typing import Dict, Any +from typing import Any, Dict logger = logging.getLogger(__name__) @@ -33,18 +32,6 @@ class FlowExecutor: out = await self._ifElse(node, nodeOutputs, nodeId, inputSources) logger.info("FlowExecutor node %s ifElse -> %s", nodeId, out) return out - if nodeType == "flow.merge": - out = await self._merge(node, nodeOutputs, nodeId, inputSources) - logger.info("FlowExecutor node %s merge -> %s", nodeId, out) - return out - if nodeType == "flow.wait": - out = await self._wait(node, nodeOutputs, nodeId, inputSources) - logger.info("FlowExecutor node %s wait -> %s", nodeId, out) - return out - if nodeType == "flow.stop": - context["_stopped"] = True - logger.info("FlowExecutor node %s -> STOP", nodeId) - return {"stopped": True} if nodeType == "flow.switch": out = await self._switch(node, nodeOutputs, nodeId, inputSources) logger.info("FlowExecutor node %s switch -> %s", nodeId, out) @@ -72,60 +59,142 @@ class FlowExecutor: nodeId: str, inputSources: Dict, ) -> Any: - condExpr = (node.get("parameters") or {}).get("condition", "") + condParam = (node.get("parameters") or {}).get("condition") inp = self._getInputData(nodeId, {nodeId: inputSources}, nodeOutputs) - # Simple eval - in production use safe evaluation - try: - # Replace {{nodeId}} refs with actual values - from modules.workflows.automation2.graphUtils import resolveParameterReferences - resolved = resolveParameterReferences(condExpr, nodeOutputs) - # Minimal eval for simple comparisons (e.g. "True", "1 > 0") - ok = bool(eval(resolved)) if resolved else False - except Exception: - ok = False + ok = self._evalConditionParam(condParam, nodeOutputs) return {"branch": 0 if ok else 1, "conditionResult": ok, "input": inp} - async def _merge(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any: - mode = (node.get("parameters") or {}).get("mode", "append") - sources = inputSources - items = [] - for inpIdx in sorted(sources.keys()): - srcId, _ = sources[inpIdx] - data = nodeOutputs.get(srcId) - if data is not None: - if isinstance(data, list): - items.extend(data) + def _evalConditionParam(self, condParam: Any, nodeOutputs: Dict) -> bool: + """Evaluate condition: structured {type,ref,operator,value} or legacy string/ref.""" + if condParam is None: + return False + if isinstance(condParam, dict) and condParam.get("type") == "condition": + return self._evalStructuredCondition(condParam, nodeOutputs) + from modules.workflows.automation2.graphUtils import resolveParameterReferences + resolved = resolveParameterReferences(condParam, nodeOutputs) + return self._evalCondition(resolved) + + def _get_by_path(self, data: Any, path: list) -> Any: + """Traverse data by path (strings and ints).""" + current = data + for seg in path: + if current is None: + return None + if isinstance(current, dict) and isinstance(seg, str) and seg in current: + current = current[seg] + elif isinstance(current, (list, tuple)) and isinstance(seg, (int, str)): + idx = int(seg) if isinstance(seg, str) and str(seg).isdigit() else seg + if isinstance(idx, int) and 0 <= idx < len(current): + current = current[idx] else: - items.append(data) - if mode == "combine" and len(items) == 2: - if isinstance(items[0], dict) and isinstance(items[1], dict): - return {**items[0], **items[1]} - return items + return None + else: + return None + return current - async def _wait(self, node: Dict, nodeOutputs: Dict) -> Any: - secs = (node.get("parameters") or {}).get("seconds", 0) - if secs > 0: - await asyncio.sleep(min(float(secs), 300)) - nodeId = node.get("id") - from modules.workflows.automation2.graphUtils import getInputSources - # Input comes from context - inp = context.get("_inputData") if "context" in dir() else None - return nodeOutputs.get(nodeId, {}) + def _evalStructuredCondition(self, cond: Dict, nodeOutputs: Dict) -> bool: + """Evaluate structured {ref, operator, value} condition.""" + ref = cond.get("ref") + if not ref or ref.get("type") != "ref": + return False + node_id = ref.get("nodeId") + path = ref.get("path") or [] + left = self._get_by_path(nodeOutputs.get(node_id), list(path)) + operator = cond.get("operator", "eq") + right = cond.get("value") - async def _wait( - self, - node: Dict, - nodeOutputs: Dict, - nodeId: str, - inputSources: Dict, - ) -> Any: - secs = (node.get("parameters") or {}).get("seconds", 0) - if secs > 0: - await asyncio.sleep(min(float(secs), 300)) - if 0 in inputSources: - srcId, _ = inputSources[0] - return nodeOutputs.get(srcId) - return None + if operator == "eq": + return left == right + if operator == "neq": + return left != right + if operator in ("lt", "lte", "gt", "gte"): + try: + l, r = float(left) if left is not None else 0, float(right) if right is not None else 0 + if operator == "lt": + return l < r + if operator == "lte": + return l <= r + if operator == "gt": + return l > r + if operator == "gte": + return l >= r + except (TypeError, ValueError): + return False + if operator == "contains": + return right is not None and str(right) in str(left or "") + if operator == "not_contains": + return right is None or str(right) not in str(left or "") + if operator == "empty": + return left is None or left == "" or (isinstance(left, (list, dict)) and len(left) == 0) + if operator == "not_empty": + return left is not None and left != "" and (not isinstance(left, (list, dict)) or len(left) > 0) + if operator == "is_true": + return bool(left) + if operator == "is_false": + return not bool(left) + if operator == "before": + return self._compare_dates(left, right, lambda a, b: a < b) + if operator == "after": + return self._compare_dates(left, right, lambda a, b: a > b) + if operator == "exists": + return self._file_exists(left) + if operator == "not_exists": + return not self._file_exists(left) + return False + + def _compare_dates(self, left: Any, right: Any, op) -> bool: + """Compare left/right as dates; op(a,b) is the comparison.""" + + def parse(v): + if v is None: + return None + if hasattr(v, "timestamp"): + return v + s = str(v).strip() + if not s: + return None + from datetime import datetime + + for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(s, fmt) + except ValueError: + continue + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")) + except ValueError: + return None + + try: + a, b = parse(left), parse(right) + if a is None or b is None: + return False + return op(a, b) + except Exception: + return False + + def _file_exists(self, val: Any) -> bool: + """Check if value represents an existing file (object with url or non-empty string).""" + if val is None: + return False + if isinstance(val, dict): + return bool(val.get("url") or val.get("name")) + if isinstance(val, str): + return len(val.strip()) > 0 + return bool(val) + + def _evalCondition(self, resolved: Any) -> bool: + """Evaluate condition: ref resolves to value → use truthiness; string → try eval.""" + if resolved is None: + return False + if isinstance(resolved, (bool, int, float)): + return bool(resolved) + if isinstance(resolved, str): + try: + return bool(eval(resolved)) + except Exception: + return bool(resolved) + return bool(resolved) async def _switch(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any: valueExpr = (node.get("parameters") or {}).get("value", "") @@ -133,14 +202,71 @@ class FlowExecutor: value = resolveParameterReferences(valueExpr, nodeOutputs) cases = (node.get("parameters") or {}).get("cases", []) for i, c in enumerate(cases): - if c == value: + if self._evalSwitchCase(value, c): return {"match": i, "value": value} return {"match": -1, "value": value} + def _evalSwitchCase(self, left: Any, case: Any) -> bool: + """ + Evaluate a switch case. Case can be: + - dict: {operator, value} - use operator to compare left vs value + - plain value: legacy format - exact equality (eq) + """ + if isinstance(case, dict): + operator = case.get("operator", "eq") + right = case.get("value") + else: + operator = "eq" + right = case + # Same logic as _evalStructuredCondition but with explicit left/right + if operator == "eq": + return left == right + if operator == "neq": + return left != right + if operator in ("lt", "lte", "gt", "gte"): + try: + l, r = float(left) if left is not None else 0, float(right) if right is not None else 0 + if operator == "lt": + return l < r + if operator == "lte": + return l <= r + if operator == "gt": + return l > r + if operator == "gte": + return l >= r + except (TypeError, ValueError): + return False + if operator == "contains": + return right is not None and str(right) in str(left or "") + if operator == "not_contains": + return right is None or str(right) not in str(left or "") + if operator == "empty": + return left is None or left == "" or (isinstance(left, (list, dict)) and len(left) == 0) + if operator == "not_empty": + return left is not None and left != "" and (not isinstance(left, (list, dict)) or len(left) > 0) + if operator == "is_true": + return bool(left) + if operator == "is_false": + return not bool(left) + if operator == "before": + return self._compare_dates(left, right, lambda a, b: a < b) + if operator == "after": + return self._compare_dates(left, right, lambda a, b: a > b) + if operator == "exists": + return self._file_exists(left) + if operator == "not_exists": + return not self._file_exists(left) + return False + async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any: itemsPath = (node.get("parameters") or {}).get("items", "[]") from modules.workflows.automation2.graphUtils import resolveParameterReferences items = resolveParameterReferences(itemsPath, nodeOutputs) - if not isinstance(items, list): + if isinstance(items, list): + pass + elif isinstance(items, dict): + # Convert form payload / object to list of {name, value} for "for each field" + items = [{"name": k, "value": v} for k, v in items.items()] + else: items = [items] if items is not None else [] return {"items": items, "count": len(items)} diff --git a/modules/workflows/automation2/executors/ioExecutor.py b/modules/workflows/automation2/executors/ioExecutor.py index eb006c7e..38e2570c 100644 --- a/modules/workflows/automation2/executors/ioExecutor.py +++ b/modules/workflows/automation2/executors/ioExecutor.py @@ -45,18 +45,22 @@ class IOExecutor: if 0 in inputSources: srcId, _ = inputSources[0] inp = nodeOutputs.get(srcId) - if isinstance(inp, dict): - resolvedParams.setdefault("documentList", inp.get("documents", inp.get("documentList", []))) + from modules.workflows.automation2.executors.actionNodeExecutor import _getDocumentsFromUpstream + docs = _getDocumentsFromUpstream(inp) if isinstance(inp, dict) else [] + if docs: + resolvedParams.setdefault("documentList", docs) elif inp is not None: resolvedParams.setdefault("input", inp) executor = ActionExecutor(self.services) logger.info("IOExecutor node %s calling executeAction(%s, %s)", nodeId, methodName, actionName) result = await executor.executeAction(methodName, actionName, resolvedParams) + docs_list = [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])] out = { "success": result.success, "error": result.error, - "documents": [d.model_dump() if hasattr(d, "model_dump") else d for d in (result.documents or [])], + "documents": docs_list, + "documentList": docs_list, "data": result.model_dump() if hasattr(result, "model_dump") else {"success": result.success, "error": result.error}, } logger.info( diff --git a/modules/workflows/automation2/executors/triggerExecutor.py b/modules/workflows/automation2/executors/triggerExecutor.py index 87ac359e..6fd32b80 100644 --- a/modules/workflows/automation2/executors/triggerExecutor.py +++ b/modules/workflows/automation2/executors/triggerExecutor.py @@ -1,37 +1,34 @@ # Copyright (c) 2025 Patrick Motsch -# Trigger node executor. +# Start node executor (node type trigger.manual) — outputs the unified run envelope from context. import logging -from typing import Dict, Any +from typing import Any, Dict + +from modules.workflows.automation2.runEnvelope import normalize_run_envelope logger = logging.getLogger(__name__) class TriggerExecutor: - """Execute trigger nodes (manual, schedule, formSubmit).""" + """ + Single start node on the canvas. Output is always context['runEnvelope'], normalized. + Invocation mode (manual, form, webhook, …) is configured as workflow entry points, not here. + """ async def execute( self, node: Dict[str, Any], context: Dict[str, Any], ) -> Any: - nodeType = node.get("type", "") - nodeId = node.get("id", "") - logger.info("TriggerExecutor node %s type=%s parameters=%s", nodeId, nodeType, node.get("parameters")) - if nodeType == "trigger.manual": - out = {"triggered": True, "source": "manual"} - logger.info("TriggerExecutor node %s -> manual trigger: %s", nodeId, out) - return out - if nodeType == "trigger.schedule": - out = {"triggered": True, "source": "schedule"} - logger.info("TriggerExecutor node %s -> schedule trigger: %s", nodeId, out) - return out - if nodeType == "trigger.formSubmit": - params = node.get("parameters") or {} - formId = params.get("formId", "") - out = {"triggered": True, "source": "formSubmit", "formId": formId} - logger.info("TriggerExecutor node %s -> formSubmit: %s", nodeId, out) - return out - out = {"triggered": True, "source": "unknown"} - logger.info("TriggerExecutor node %s -> unknown: %s", nodeId, out) + node_id = node.get("id", "") + base = context.get("runEnvelope") + if not isinstance(base, dict): + out = normalize_run_envelope(None, user_id=context.get("userId")) + else: + out = normalize_run_envelope(base, user_id=context.get("userId")) + logger.info( + "TriggerExecutor node %s trigger.type=%s", + node_id, + (out.get("trigger") or {}).get("type"), + ) return out diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflows/automation2/graphUtils.py index ad58c69c..0f79b882 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflows/automation2/graphUtils.py @@ -47,6 +47,27 @@ def buildConnectionMap(connections: List[Dict]) -> Dict[str, List[Tuple[str, int return out +def getLoopBodyNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]: + """Nodes reachable from loop's output (BFS forward). Body = downstream nodes that receive from loop.""" + from collections import deque + body = set() + # connectionMap: target -> [(source, sourceOutput, targetInput)] + rev: Dict[str, List[str]] = {} # source -> [targets] + for tgt, pairs in connectionMap.items(): + for src, _, _ in pairs: + if src not in rev: + rev[src] = [] + rev[src].append(tgt) + q = deque([loopNodeId]) + while q: + nid = q.popleft() + for tgt in rev.get(nid, []): + if tgt not in body: + body.add(tgt) + q.append(tgt) + return body + + def getInputSources(nodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Dict[int, Tuple[str, int]]: """ For a node, return targetInput -> (sourceNodeId, sourceOutput). @@ -142,12 +163,59 @@ def topoSort(nodes: List[Dict], connectionMap: Dict[str, List[Tuple[str, int, in return order +def _get_by_path(data: Any, path: List[Any]) -> Any: + """Traverse data by path (strings and ints); return None if not found.""" + current = data + for seg in path: + if current is None: + return None + if isinstance(current, dict) and isinstance(seg, str) and seg in current: + current = current[seg] + elif isinstance(current, (list, tuple)) and isinstance(seg, (int, str)): + idx = int(seg) if isinstance(seg, str) and seg.isdigit() else seg + if isinstance(idx, int) and 0 <= idx < len(current): + current = current[idx] + else: + return None + else: + return None + return current + + def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any: """ - Resolve {{nodeId.output}} or {{nodeId.output.path}} in strings/structures. + Resolve parameter references: + - {{nodeId.output}} or {{nodeId.output.path}} in strings (legacy) + - { "type": "ref", "nodeId": "...", "path": ["field", "nested"] } -> resolved value + - { "type": "value", "value": ... } -> value (then recursively resolve) """ import json import re + + if isinstance(value, dict): + if value.get("type") == "ref": + node_id = value.get("nodeId") + path = value.get("path") + if node_id is not None and isinstance(path, (list, tuple)): + data = nodeOutputs.get(node_id) + plist = list(path) + resolved = _get_by_path(data, plist) + # input.form historically stored flat field dict; refs use payload. + if ( + resolved is None + and isinstance(data, dict) + and plist + and plist[0] == "payload" + and len(plist) > 1 + ): + resolved = _get_by_path(data, plist[1:]) + return resolveParameterReferences(resolved, nodeOutputs) + return value + if value.get("type") == "value": + inner = value.get("value") + return resolveParameterReferences(inner, nodeOutputs) + return {k: resolveParameterReferences(v, nodeOutputs) for k, v in value.items()} + if isinstance(value, str): def repl(m): ref = m.group(1).strip() @@ -170,8 +238,6 @@ def resolveParameterReferences(value: Any, nodeOutputs: Dict[str, Any]) -> Any: return m.group(0) return str(data) if data is not None else m.group(0) return re.sub(r"\{\{\s*([^}]+)\s*\}\}", repl, value) - if isinstance(value, dict): - return {k: resolveParameterReferences(v, nodeOutputs) for k, v in value.items()} if isinstance(value, list): return [resolveParameterReferences(v, nodeOutputs) for v in value] return value diff --git a/modules/workflows/automation2/runEnvelope.py b/modules/workflows/automation2/runEnvelope.py new file mode 100644 index 00000000..44da2fb5 --- /dev/null +++ b/modules/workflows/automation2/runEnvelope.py @@ -0,0 +1,109 @@ +# Copyright (c) 2025 Patrick Motsch +""" +Unified run envelope for Automation2 start/trigger nodes. + +Downstream nodes always see the same structure regardless of entry point +(manual, form, schedule, webhook, email, api, event). +""" + +from copy import deepcopy +from typing import Any, Dict, List, Optional + +# trigger.type values +TRIGGER_TYPES = frozenset( + { + "manual", + "form", + "schedule", + "email", + "webhook", + "api", + "event", + } +) + + +def default_run_envelope( + trigger_type: str = "manual", + *, + entry_point_id: Optional[str] = None, + entry_point_label: Optional[str] = None, + payload: Optional[Dict[str, Any]] = None, + context: Optional[Dict[str, Any]] = None, + files: Optional[List[Any]] = None, + user: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + raw: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Build a normalized run envelope dict.""" + tt = trigger_type if trigger_type in TRIGGER_TYPES else "manual" + trig: Dict[str, Any] = {"type": tt} + if entry_point_id: + trig["entryPointId"] = entry_point_id + if entry_point_label: + trig["label"] = entry_point_label + return { + "trigger": trig, + "payload": dict(payload or {}), + "context": dict(context or {}), + "files": list(files or []), + "user": dict(user or {}), + "metadata": dict(metadata or {}), + "raw": dict(raw or {}), + } + + +def merge_run_envelope(base: Dict[str, Any], overrides: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Deep-merge overrides into a copy of base (shallow merge per top-level key except nested dicts).""" + out = deepcopy(base) + if not overrides: + return out + for key in ("payload", "context", "user", "metadata", "raw"): + if key in overrides and isinstance(overrides[key], dict): + merged = dict(out.get(key) or {}) + merged.update(overrides[key]) + out[key] = merged + if "files" in overrides and overrides["files"] is not None: + out["files"] = list(overrides["files"]) + trig = dict(out.get("trigger") or {}) + ot = overrides.get("trigger") + if isinstance(ot, dict): + trig.update(ot) + if trig.get("type") and trig["type"] not in TRIGGER_TYPES: + trig["type"] = "manual" + out["trigger"] = trig + return out + + +def normalize_run_envelope( + incoming: Optional[Dict[str, Any]], + *, + user_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Normalize partial or missing envelope from API/scheduler. + Ensures all top-level keys exist. + """ + if not incoming or not isinstance(incoming, dict): + env = default_run_envelope("manual") + else: + trig = incoming.get("trigger") if isinstance(incoming.get("trigger"), dict) else {} + ttype = trig.get("type") or "manual" + if ttype not in TRIGGER_TYPES: + ttype = "manual" + env = default_run_envelope( + ttype, + entry_point_id=trig.get("entryPointId"), + entry_point_label=trig.get("label"), + payload=incoming.get("payload"), + context=incoming.get("context"), + files=incoming.get("files"), + user=incoming.get("user"), + metadata=incoming.get("metadata"), + raw=incoming.get("raw"), + ) + if user_id and not env.get("user"): + env["user"] = {"id": user_id} + elif user_id and isinstance(env.get("user"), dict) and "id" not in env["user"]: + env["user"] = {**env["user"], "id": user_id} + return env diff --git a/modules/workflows/automation2/scheduleCron.py b/modules/workflows/automation2/scheduleCron.py new file mode 100644 index 00000000..4a0cfa43 --- /dev/null +++ b/modules/workflows/automation2/scheduleCron.py @@ -0,0 +1,34 @@ +# Copyright (c) 2025 Patrick Motsch +""" +Parse cron strings (5-field or 6-field) to APScheduler CronTrigger kwargs. +Frontend produces: "minute hour day month dow" (5-field) or "sec min hour day month dow" (6-field). +""" + +import re +from typing import Any, Dict + + +def parse_cron_to_kwargs(cron: str) -> Dict[str, Any]: + """ + Parse cron string to kwargs for APScheduler CronTrigger. + Supports 5-field (minute hour day month day_of_week) and 6-field (sec min hour day month day_of_week). + Returns dict with: second, minute, hour, day, month, day_of_week. + """ + if not cron or not isinstance(cron, str): + raise ValueError("Invalid cron: empty or not string") + parts = cron.strip().split() + if len(parts) == 5: + minute, hour, day, month, day_of_week = parts + second = "0" + elif len(parts) == 6: + second, minute, hour, day, month, day_of_week = parts + else: + raise ValueError(f"Invalid cron format: expected 5 or 6 fields, got {len(parts)}") + return { + "second": second, + "minute": minute, + "hour": hour, + "day": day, + "month": month, + "day_of_week": day_of_week, + } diff --git a/modules/workflows/automation2/subAutomation2Schedule.py b/modules/workflows/automation2/subAutomation2Schedule.py new file mode 100644 index 00000000..d0fb3cd8 --- /dev/null +++ b/modules/workflows/automation2/subAutomation2Schedule.py @@ -0,0 +1,304 @@ +# Copyright (c) 2025 Patrick Motsch +""" +Automation2 schedule scheduler. +Starts/stops cron jobs for workflows with schedule entry points. +""" + +import asyncio +import logging +from typing import Any, Dict + +from modules.shared.eventManagement import eventManager + +# Main loop reference for scheduling async work from job executor (may run in thread) +_main_loop = None + + +def set_main_loop(loop) -> None: + global _main_loop + _main_loop = loop +from modules.features.automation2.interfaceFeatureAutomation2 import ( + getAutomation2Interface, + getAllWorkflowsForScheduling, +) +from modules.features.automation2.mainAutomation2 import getAutomation2Services +from modules.features.automation2.entryPoints import find_invocation +from modules.workflows.automation2.scheduleCron import parse_cron_to_kwargs + + +def _cron_to_interval_seconds(cron: str): + """ + If cron represents a simple interval, return seconds. Otherwise None. + E.g. "* * * * *" -> 60, "*/15 * * * *" -> 900, "*/30 * * * * *" -> 30. + """ + if not cron or not isinstance(cron, str): + return None + parts = cron.strip().split() + if len(parts) == 5: + minute, hour, day, month, dow = parts + second = "0" + elif len(parts) == 6: + second, minute, hour, day, month, dow = parts + else: + return None + # Interval minutes: */N * * * * + if minute.startswith("*/") and hour == "*" and day == "*" and month == "*" and dow == "*": + n = int(minute[2:]) if minute[2:].isdigit() else 0 + if n > 0: + return n * 60 + # Every minute: * * * * * + if minute == "*" and hour == "*" and day == "*" and month == "*" and dow == "*" and second == "0": + return 60 + # Interval hours: 0 */N * * * + if minute == "0" and hour.startswith("*/") and day == "*" and month == "*" and dow == "*": + n = int(hour[2:]) if hour[2:].isdigit() else 0 + if n > 0: + return n * 3600 + # Interval seconds: */N * * * * * (6-field) + if len(parts) == 6 and second.startswith("*/") and minute == "*" and hour == "*" and day == "*" and month == "*" and dow in ("*", "?"): + n = int(second[2:]) if second[2:].isdigit() else 0 + if n > 0: + return n + return None +from modules.workflows.automation2.executionEngine import executeGraph +from modules.workflows.automation2.runEnvelope import default_run_envelope, normalize_run_envelope + +logger = logging.getLogger(__name__) + +JOB_ID_PREFIX = "automation2." + + +def _remove_all_automation2_schedule_jobs() -> None: + """Remove all registered Automation2 schedule jobs from the scheduler.""" + if not eventManager.scheduler: + return + for job in list(eventManager.scheduler.get_jobs()): + jid = job.id if hasattr(job, "id") else str(job) + if jid.startswith(JOB_ID_PREFIX): + try: + eventManager.remove(jid) + except Exception as e: + logger.debug("Could not remove job %s: %s", jid, e) + + +def sync_automation2_schedule_events(event_user) -> Dict[str, Any]: + """ + Sync scheduler with all active Automation2 workflows that have schedule entry points. + Registers cron jobs for each; removes jobs for workflows no longer in the list. + """ + if not event_user: + logger.warning("Automation2 schedule: No event user, skipping sync") + return {"synced": 0, "events": {}} + + _remove_all_automation2_schedule_jobs() + + items = getAllWorkflowsForScheduling() + registered = {} + logger.info( + "Automation2 schedule: found %d workflow(s) with trigger.schedule and cron", + len(items), + ) + + for item in items: + workflow_id = item.get("workflowId") + mandate_id = item.get("mandateId") + instance_id = item.get("featureInstanceId") + entry_point_id = item.get("entryPointId") + cron = item.get("cron") + workflow = item.get("workflow") + + if not workflow_id or not instance_id or not cron: + continue + + job_id = f"{JOB_ID_PREFIX}{workflow_id}" + async_handler = _create_schedule_handler( + workflow_id=workflow_id, + mandate_id=mandate_id, + instance_id=instance_id, + entry_point_id=entry_point_id, + workflow=workflow, + event_user=event_user, + ) + + # Sync wrapper: schedule async handler on main loop (job may run in executor thread) + def sync_wrapper(): + loop = _main_loop + if loop and loop.is_running(): + loop.call_soon_threadsafe( + lambda: asyncio.ensure_future(async_handler(), loop=loop) + ) + else: + # Fallback: run inline if no loop (shouldn't happen) + try: + asyncio.run(async_handler()) + except RuntimeError: + logger.warning("Automation2 schedule: could not run handler, no event loop") + + # Use IntervalTrigger for "every N minutes" - more reliable than CronTrigger + interval_seconds = _cron_to_interval_seconds(cron) + if interval_seconds is not None: + eventManager.registerInterval( + jobId=job_id, + func=sync_wrapper, + seconds=interval_seconds, + replaceExisting=True, + ) + else: + try: + cron_kwargs = parse_cron_to_kwargs(cron) + eventManager.registerCron( + jobId=job_id, + func=sync_wrapper, + cronKwargs=cron_kwargs, + replaceExisting=True, + ) + except ValueError as e: + logger.warning("Workflow %s: invalid cron %r: %s", workflow_id, cron, e) + continue + registered[workflow_id] = job_id + mode = "interval" if interval_seconds is not None else "cron" + logger.info( + "Automation2 schedule: registered %s for workflow %s (%s=%s)", + job_id, + workflow_id, + mode, + interval_seconds if interval_seconds is not None else cron, + ) + + if not registered and items: + logger.warning("Automation2 schedule: workflows found but none registered (check cron format)") + elif not items: + logger.info("Automation2 schedule: no workflows with trigger.schedule+cron (save workflow after selecting Zeitplan)") + return {"synced": len(registered), "workflowsFound": len(items), "events": registered} + + +def _create_schedule_handler( + workflow_id: str, + mandate_id: str, + instance_id: str, + entry_point_id: str, + workflow: Dict[str, Any], + event_user, +): + """Create async handler for scheduled workflow execution.""" + + async def handler(): + logger.info("Automation2 schedule: CRON FIRED for workflow %s", workflow_id) + try: + if not event_user: + logger.error("Automation2 schedule: event user not available") + return + + a2 = getAutomation2Interface(event_user, mandate_id, instance_id) + wf = a2.getWorkflow(workflow_id) + if not wf or not wf.get("graph"): + logger.warning("Automation2 schedule: workflow %s not found or no graph", workflow_id) + return + if not wf.get("active", True): + logger.info("Automation2 schedule: workflow %s inactive, skipping", workflow_id) + return + + inv = find_invocation(wf, entry_point_id) + if inv and (inv.get("kind") != "schedule" or not inv.get("enabled", True)): + logger.info("Automation2 schedule: entry point %s disabled for workflow %s", entry_point_id, workflow_id) + return + # If inv not found but graph has trigger.schedule, proceed (invocations may not be synced) + + services = getAutomation2Services( + event_user, + mandateId=mandate_id, + featureInstanceId=instance_id, + ) + from modules.workflows.processing.shared.methodDiscovery import discoverMethods + discoverMethods(services) + + title = (inv or {}).get("title") or {} + label = "" + if isinstance(title, dict): + label = title.get("en") or title.get("de") or "" + elif isinstance(title, str): + label = title + + run_env = default_run_envelope( + "schedule", + entry_point_id=entry_point_id, + entry_point_label=label or None, + ) + run_env = normalize_run_envelope(run_env, user_id=str(event_user.id) if event_user else None) + + # userId=None so tasks are created unassigned and visible to all instance users + result = await executeGraph( + graph=wf["graph"], + services=services, + workflowId=workflow_id, + instanceId=instance_id, + userId=None, + mandateId=mandate_id, + automation2_interface=a2, + run_envelope=run_env, + ) + logger.info( + "Automation2 schedule: executed workflow %s success=%s paused=%s", + workflow_id, + result.get("success"), + result.get("paused"), + ) + except Exception as e: + logger.exception("Automation2 schedule: failed to execute workflow %s: %s", workflow_id, e) + + return handler + + +def start(event_user) -> bool: + """ + Start Automation2 schedule scheduler and sync scheduled workflows. + Registers callback so schedule is re-synced when workflows are created/updated/deleted. + """ + if not event_user: + logger.warning("Automation2 schedule: No event user provided, skipping") + return True + + try: + eventManager.start() + sync_automation2_schedule_events(event_user) + logger.info("Automation2 schedule: sync complete") + + # Delayed sync (5s) in case DB was not ready at startup + def do_delayed_sync(): + import threading + def _run(): + import time + time.sleep(5) + try: + sync_automation2_schedule_events(event_user) + logger.info("Automation2 schedule: delayed sync done") + except Exception as e: + logger.warning("Automation2 schedule: delayed sync failed: %s", e) + t = threading.Thread(target=_run, daemon=True) + t.start() + do_delayed_sync() + + def on_workflow_changed(_context=None): + try: + sync_automation2_schedule_events(event_user) + logger.debug("Automation2 schedule: re-synced after workflow change") + except Exception as e: + logger.warning("Automation2 schedule: re-sync failed: %s", e) + + from modules.shared.callbackRegistry import callbackRegistry + callbackRegistry.register("automation2.workflow.changed", on_workflow_changed) + except Exception as e: + logger.error("Automation2 schedule: Failed to start: %s", e) + return False + + return True + + +def stop(event_user) -> bool: + """Stop Automation2 schedule scheduler (remove all schedule jobs).""" + try: + _remove_all_automation2_schedule_jobs() + logger.info("Automation2 schedule: all jobs removed") + except Exception as e: + logger.warning("Automation2 schedule: error during stop: %s", e) + return True diff --git a/modules/workflows/methods/methodClickup/__init__.py b/modules/workflows/methods/methodClickup/__init__.py new file mode 100644 index 00000000..9e0362c4 --- /dev/null +++ b/modules/workflows/methods/methodClickup/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +from .methodClickup import MethodClickup + +__all__ = ["MethodClickup"] diff --git a/modules/workflows/methods/methodClickup/actions/__init__.py b/modules/workflows/methods/methodClickup/actions/__init__.py new file mode 100644 index 00000000..5c54c5df --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp workflow actions.""" diff --git a/modules/workflows/methods/methodClickup/actions/create_task.py b/modules/workflows/methods/methodClickup/actions/create_task.py new file mode 100644 index 00000000..d010c234 --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/create_task.py @@ -0,0 +1,213 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import json +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from modules.datamodels.datamodelChat import ActionDocument, ActionResult +from ..helpers.pathparse import parse_team_and_list + +logger = logging.getLogger(__name__) + + +def _as_str(v: Any) -> str: + if v is None: + return "" + return str(v).strip() + + +def _parse_custom_field_values(parameters: Dict[str, Any]) -> List[Dict[str, Any]]: + """Build ClickUp custom_fields array from customFieldValues map (field id -> value).""" + raw = parameters.get("customFieldValues") + if raw is None: + return [] + if isinstance(raw, str) and raw.strip(): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, dict): + return [] + out: List[Dict[str, Any]] = [] + for fid, val in raw.items(): + if val is None or val == "": + continue + if isinstance(val, dict) and val.get("type") in ("ref", "value"): + continue + out.append({"id": str(fid), "value": val}) + return out + + +def _unwrap_value(v: Any) -> Any: + if isinstance(v, dict) and v.get("type") == "value" and "value" in v: + return v.get("value") + return v + + +def _parse_int_list(val: Any) -> List[int]: + if val is None: + return [] + val = _unwrap_value(val) + if isinstance(val, str) and val.strip(): + try: + parsed = json.loads(val) + if isinstance(parsed, list): + return [int(x) for x in parsed if x is not None and str(x).strip() != ""] + except (json.JSONDecodeError, ValueError, TypeError): + return [] + if isinstance(val, list): + out: List[int] = [] + for x in val: + if x is None or (isinstance(x, str) and not x.strip()): + continue + try: + out.append(int(x)) + except (ValueError, TypeError): + continue + return out + return [] + + +def _optional_positive_int(v: Any) -> Optional[int]: + v = _unwrap_value(v) + if v is None or v == "": + return None + try: + i = int(float(v)) + return i if i > 0 else None + except (ValueError, TypeError): + return None + + +def _parse_due_date_ms(v: Any) -> Optional[int]: + """Accept Unix ms or ISO date string (YYYY-MM-DD) from form payload.""" + v = _unwrap_value(v) + if v is None or v == "": + return None + if isinstance(v, str) and len(v) >= 10 and v[4] == "-" and v[7] == "-": + try: + dt = datetime.strptime(v[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except ValueError: + pass + try: + i = int(float(v)) + return i if i > 0 else None + except (ValueError, TypeError): + return None + + +def _parse_time_estimate_hours_to_ms(v: Any) -> Optional[int]: + v = _unwrap_value(v) + if v is None or v == "": + return None + try: + h = float(v) + if h < 0: + return None + return int(round(h * 3600 * 1000)) + except (ValueError, TypeError): + return None + + +def _apply_standard_task_fields(body: Dict[str, Any], parameters: Dict[str, Any]) -> None: + """Map first-class node params to ClickUp POST /list/{{id}}/task body (before taskFields merge).""" + ts = _as_str(parameters.get("taskStatus") or parameters.get("clickupStatus")) + if ts: + body["status"] = ts + pr = parameters.get("taskPriority") + pr = _unwrap_value(pr) + if pr is not None and pr != "": + try: + pi = int(float(pr)) + if 1 <= pi <= 4: + body["priority"] = pi + except (ValueError, TypeError): + pass + dd = parameters.get("taskDueDateMs") + dms = _parse_due_date_ms(dd) + if dms is not None: + body["due_date"] = dms + assignees = _parse_int_list(parameters.get("taskAssigneeIds")) + if assignees: + body["assignees"] = assignees + teh = parameters.get("taskTimeEstimateHours") + tem_h = _parse_time_estimate_hours_to_ms(teh) + if tem_h is not None: + body["time_estimate"] = tem_h + else: + te = parameters.get("taskTimeEstimateMs") + tem = _optional_positive_int(te) + if tem is not None: + body["time_estimate"] = tem + + +def _merge_custom_fields(body: Dict[str, Any], items: List[Dict[str, Any]]) -> None: + if not items: + return + existing = body.get("custom_fields") + if isinstance(existing, list) and existing: + by_id: Dict[str, Dict[str, Any]] = {} + for x in existing: + if isinstance(x, dict) and x.get("id") is not None: + by_id[str(x["id"])] = x + for item in items: + by_id[str(item["id"])] = item + body["custom_fields"] = list(by_id.values()) + else: + body["custom_fields"] = items + + +async def create_task(self, parameters: Dict[str, Any]) -> ActionResult: + connection_reference = parameters.get("connectionReference") + list_id = (parameters.get("listId") or "").strip() + path_query = (parameters.get("pathQuery") or parameters.get("path") or "").strip() + name = _as_str(parameters.get("name")) + description = _as_str(parameters.get("description")) + + if not connection_reference: + return ActionResult.isFailure(error="connectionReference is required") + if not list_id and path_query: + _t, lid = parse_team_and_list(path_query) + list_id = lid or list_id + if not list_id: + return ActionResult.isFailure(error="listId or path /team/{teamId}/list/{listId} is required") + if not name: + return ActionResult.isFailure(error="name is required") + + conn = self.connection.get_clickup_connection(connection_reference) + if not conn: + return ActionResult.isFailure(error="No valid ClickUp connection") + + body: Dict[str, Any] = {"name": name} + if description: + body["description"] = description + _apply_standard_task_fields(body, parameters) + extra = parameters.get("taskFields") + if isinstance(extra, str) and extra.strip(): + try: + parsed = json.loads(extra) + if isinstance(parsed, dict): + body.update(parsed) + except json.JSONDecodeError: + return ActionResult.isFailure(error="taskFields must be valid JSON object") + elif isinstance(extra, dict): + body.update(extra) + + cf_items = _parse_custom_field_values(parameters) + if cf_items: + _merge_custom_fields(body, cf_items) + + data = await self.services.clickup.createTask(list_id, body) + if isinstance(data, dict) and data.get("error"): + return ActionResult.isFailure(error=str(data.get("error")) + (data.get("body") or "")) + + doc = ActionDocument( + documentName="clickup_create_task.json", + documentData=json.dumps(data, ensure_ascii=False, indent=2), + mimeType="application/json", + validationMetadata={"actionType": "clickup.createTask", "listId": list_id}, + ) + return ActionResult.isSuccess(documents=[doc]) diff --git a/modules/workflows/methods/methodClickup/actions/get_task.py b/modules/workflows/methods/methodClickup/actions/get_task.py new file mode 100644 index 00000000..1e3eecad --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/get_task.py @@ -0,0 +1,40 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import json +import logging +from typing import Any, Dict + +from modules.datamodels.datamodelChat import ActionDocument, ActionResult +from ..helpers.pathparse import parse_task_id + +logger = logging.getLogger(__name__) + + +async def get_task(self, parameters: Dict[str, Any]) -> ActionResult: + connection_reference = parameters.get("connectionReference") + task_id = (parameters.get("taskId") or "").strip() + path_hint = (parameters.get("path") or parameters.get("pathQuery") or "").strip() + if not connection_reference: + return ActionResult.isFailure(error="connectionReference is required") + + if not task_id and path_hint: + task_id = parse_task_id(path_hint) or "" + if not task_id: + return ActionResult.isFailure(error="taskId is required (or path ending in .../task/{id})") + + conn = self.connection.get_clickup_connection(connection_reference) + if not conn: + return ActionResult.isFailure(error="No valid ClickUp connection") + + data = await self.services.clickup.getTask(task_id) + if isinstance(data, dict) and data.get("error"): + return ActionResult.isFailure(error=str(data.get("error")) + (data.get("body") or "")) + + doc = ActionDocument( + documentName=f"clickup_task_{task_id}.json", + documentData=json.dumps(data, ensure_ascii=False, indent=2), + mimeType="application/json", + validationMetadata={"actionType": "clickup.getTask", "taskId": task_id}, + ) + return ActionResult.isSuccess(documents=[doc]) diff --git a/modules/workflows/methods/methodClickup/actions/list_tasks.py b/modules/workflows/methods/methodClickup/actions/list_tasks.py new file mode 100644 index 00000000..4caf9e31 --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/list_tasks.py @@ -0,0 +1,51 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import json +import logging +from typing import Any, Dict + +from modules.datamodels.datamodelChat import ActionDocument, ActionResult +from ..helpers.pathparse import parse_team_and_list + +logger = logging.getLogger(__name__) + + +async def list_tasks(self, parameters: Dict[str, Any]) -> ActionResult: + connection_reference = parameters.get("connectionReference") + path_query = (parameters.get("pathQuery") or parameters.get("path") or "").strip() + if not connection_reference: + return ActionResult.isFailure(error="connectionReference is required") + if not path_query: + return ActionResult.isFailure(error="path (virtual path to a list) is required, e.g. /team/{teamId}/list/{listId}") + + conn = self.connection.get_clickup_connection(connection_reference) + if not conn: + return ActionResult.isFailure(error="No valid ClickUp connection") + + team_id, list_id = parse_team_and_list(path_query) + if not list_id: + return ActionResult.isFailure( + error="path must be /team/{teamId}/list/{listId} (browse to a list in the ClickUp picker)" + ) + + page = int(parameters.get("page") or 0) + include_closed = bool(parameters.get("includeClosed", False)) + data = await self.services.clickup.getTasksInList( + list_id, page=page, include_closed=include_closed, subtasks=True + ) + if isinstance(data, dict) and data.get("error"): + return ActionResult.isFailure(error=str(data.get("error")) + (data.get("body") or "")) + + doc = ActionDocument( + documentName="clickup_list_tasks.json", + documentData=json.dumps(data, ensure_ascii=False, indent=2), + mimeType="application/json", + validationMetadata={ + "actionType": "clickup.listTasks", + "teamId": team_id, + "listId": list_id, + "path": path_query, + }, + ) + return ActionResult.isSuccess(documents=[doc]) diff --git a/modules/workflows/methods/methodClickup/actions/search_tasks.py b/modules/workflows/methods/methodClickup/actions/search_tasks.py new file mode 100644 index 00000000..b173020c --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/search_tasks.py @@ -0,0 +1,221 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import json +import logging +import re +from typing import Any, Dict, List, Optional + +from modules.datamodels.datamodelChat import ActionDocument, ActionResult + +logger = logging.getLogger(__name__) + +_DESC_MAX = 4000 +_MAX_LIST_PAGES = 50 + +_DASHES = re.compile(r"[\u2010-\u2015\-]") + + +def _norm_title(s: str) -> str: + """Lowercase, unify hyphens/dashes, collapse spaces (helps full-title matches).""" + t = (s or "").strip().lower() + t = _DASHES.sub("-", t) + t = re.sub(r"\s+", " ", t) + return t + + +def _title_contains_query(name: str, query: str) -> bool: + if not query: + return True + n = _norm_title(name) + q = _norm_title(query) + if q in n: + return True + return query.lower() in (name or "").lower() + + +def _task_text_for_broad(t: Dict[str, Any]) -> str: + parts: List[str] = [] + if t.get("name"): + parts.append(str(t["name"])) + d = t.get("description") or t.get("text_content") or t.get("textcontent") or "" + if d: + parts.append(str(d)) + return " ".join(parts).lower() + + +def _task_matches_query(t: Dict[str, Any], query: str, *, match_name_only: bool) -> bool: + if not query: + return True + if match_name_only: + return _title_contains_query((t.get("name") or ""), query) + return query.lower() in _task_text_for_broad(t) + + +def _pick(d: Dict[str, Any], *keys: str, default: Any = None) -> Any: + for k in keys: + if k in d and d[k] is not None: + return d[k] + return default + + +def _slim_custom_field(cf: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Only include custom fields that have a value (omit null noise).""" + val = cf.get("value") + if val is None or val == "": + return None + return { + "id": cf.get("id"), + "name": cf.get("name"), + "type": cf.get("type"), + "value": val, + } + + +def _slim_clickup_task(t: Dict[str, Any]) -> Dict[str, Any]: + """Usable automation payload — not the full ClickUp API mirror (no nested typeconfig).""" + status = t.get("status") + if not isinstance(status, dict): + status = {} + li = t.get("list") + if not isinstance(li, dict): + li = {} + + desc = _pick(t, "description", "Description", default="") or "" + if len(desc) > _DESC_MAX: + desc = desc[:_DESC_MAX] + "…(truncated)" + + assignees: List[Dict[str, Any]] = [] + for a in t.get("assignees") or []: + if isinstance(a, dict): + assignees.append( + { + "id": a.get("id"), + "username": a.get("username"), + "email": a.get("email"), + } + ) + + cfs = t.get("custom_fields") or t.get("customfields") or [] + slim_cf: List[Dict[str, Any]] = [] + for cf in cfs: + if isinstance(cf, dict): + row = _slim_custom_field(cf) + if row is not None: + slim_cf.append(row) + + out: Dict[str, Any] = { + "id": t.get("id"), + "name": t.get("name"), + "text_content": _pick(t, "text_content", "textcontent"), + "description": desc, + "status": status.get("status"), + "url": t.get("url"), + "list": {"id": li.get("id"), "name": li.get("name")} if li else None, + "date_created": _pick(t, "date_created", "datecreated"), + "date_updated": _pick(t, "date_updated", "dateupdated"), + "due_date": _pick(t, "due_date", "duedate"), + } + if assignees: + out["assignees"] = assignees + if slim_cf: + out["custom_fields"] = slim_cf + pr = t.get("priority") + if pr is not None: + out["priority"] = pr + return out + + +def _slim_search_payload(data: Dict[str, Any]) -> Dict[str, Any]: + tasks = data.get("tasks") or [] + slim_tasks = [_slim_clickup_task(t) if isinstance(t, dict) else t for t in tasks] + out: Dict[str, Any] = {k: v for k, v in data.items() if k != "tasks"} + out["tasks"] = slim_tasks + out["_nyla"] = { + "slim": True, + "hint": "Set fullTaskData=true for raw ClickUp API objects.", + } + return out + + +async def search_tasks(self, parameters: Dict[str, Any]) -> ActionResult: + connection_reference = parameters.get("connectionReference") + team_id = (parameters.get("teamId") or "").strip() + query = (parameters.get("query") or parameters.get("searchQuery") or "").strip() + list_id_filter = (parameters.get("listId") or "").strip() + if not connection_reference: + return ActionResult.isFailure(error="connectionReference is required") + if not team_id: + return ActionResult.isFailure(error="teamId is required (workspace id from ClickUp)") + if not query: + return ActionResult.isFailure(error="query is required") + + conn = self.connection.get_clickup_connection(connection_reference) + if not conn: + return ActionResult.isFailure(error="No valid ClickUp connection") + + full_task_data = bool(parameters.get("fullTaskData") or parameters.get("fullPayload")) + if "matchNameOnly" in parameters: + match_name_only = bool(parameters.get("matchNameOnly")) + elif "matchTitle" in parameters: + match_name_only = bool(parameters.get("matchTitle")) + else: + match_name_only = True + + page = int(parameters.get("page") or 0) + include_closed = bool(parameters.get("includeClosed", False)) + + if list_id_filter: + # List API: scan pages in this list and match locally (team search does not scope to one table). + filtered_tasks: List[Dict[str, Any]] = [] + p = page + while p < page + _MAX_LIST_PAGES: + batch = await self.services.clickup.getTasksInList( + list_id_filter, page=p, include_closed=include_closed, subtasks=True + ) + if isinstance(batch, dict) and batch.get("error"): + return ActionResult.isFailure(error=str(batch.get("error")) + (batch.get("body") or "")) + tasks = batch.get("tasks") or [] + last = bool(batch.get("last_page") or batch.get("lastpage")) + for t in tasks: + if isinstance(t, dict) and _task_matches_query(t, query, match_name_only=match_name_only): + filtered_tasks.append(t) + if last or not tasks: + break + p += 1 + data: Dict[str, Any] = {"tasks": filtered_tasks, "lastpage": True} + search_mode = "list" + else: + data = await self.services.clickup.searchTeamTasks(team_id, query=query, page=page) + if isinstance(data, dict) and data.get("error"): + return ActionResult.isFailure(error=str(data.get("error")) + (data.get("body") or "")) + + if match_name_only and isinstance(data, dict): + tasks = data.get("tasks") or [] + filtered = [ + t + for t in tasks + if isinstance(t, dict) and _title_contains_query((t.get("name") or ""), query) + ] + data = {**data, "tasks": filtered} + search_mode = "team" + + if isinstance(data, dict) and not full_task_data: + data = _slim_search_payload(data) + + doc = ActionDocument( + documentName="clickup_search_tasks.json", + documentData=json.dumps(data, ensure_ascii=False, indent=2), + mimeType="application/json", + validationMetadata={ + "actionType": "clickup.searchTasks", + "teamId": team_id, + "query": query, + "slim": not full_task_data, + "matchNameOnly": match_name_only, + "searchMode": search_mode, + "listId": list_id_filter or None, + "includeClosed": include_closed if list_id_filter else None, + }, + ) + return ActionResult.isSuccess(documents=[doc]) diff --git a/modules/workflows/methods/methodClickup/actions/update_task.py b/modules/workflows/methods/methodClickup/actions/update_task.py new file mode 100644 index 00000000..6282ec78 --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/update_task.py @@ -0,0 +1,57 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import json +import logging +from typing import Any, Dict + +from modules.datamodels.datamodelChat import ActionDocument, ActionResult +from ..helpers.pathparse import parse_task_id + +logger = logging.getLogger(__name__) + + +async def update_task(self, parameters: Dict[str, Any]) -> ActionResult: + connection_reference = parameters.get("connectionReference") + task_id = (parameters.get("taskId") or "").strip() + path_hint = (parameters.get("path") or "").strip() + if not connection_reference: + return ActionResult.isFailure(error="connectionReference is required") + if not task_id and path_hint: + task_id = parse_task_id(path_hint) or "" + if not task_id: + return ActionResult.isFailure(error="taskId is required") + + raw_update = parameters.get("taskUpdate") or parameters.get("taskJson") or parameters.get("body") + if raw_update is None or raw_update == "": + return ActionResult.isFailure(error="taskUpdate (JSON object) is required — add update fields or advanced JSON") + if isinstance(raw_update, str): + try: + body = json.loads(raw_update) + except json.JSONDecodeError as e: + return ActionResult.isFailure(error=f"taskUpdate must be valid JSON: {e}") + elif isinstance(raw_update, dict): + body = raw_update + else: + return ActionResult.isFailure(error="taskUpdate must be a JSON string or object") + + if not isinstance(body, dict): + return ActionResult.isFailure(error="taskUpdate JSON must be an object") + if not body: + return ActionResult.isFailure(error="taskUpdate is empty — set at least one field to update") + + conn = self.connection.get_clickup_connection(connection_reference) + if not conn: + return ActionResult.isFailure(error="No valid ClickUp connection") + + data = await self.services.clickup.updateTask(task_id, body) + if isinstance(data, dict) and data.get("error"): + return ActionResult.isFailure(error=str(data.get("error")) + (data.get("body") or "")) + + doc = ActionDocument( + documentName=f"clickup_task_{task_id}_updated.json", + documentData=json.dumps(data, ensure_ascii=False, indent=2), + mimeType="application/json", + validationMetadata={"actionType": "clickup.updateTask", "taskId": task_id}, + ) + return ActionResult.isSuccess(documents=[doc]) diff --git a/modules/workflows/methods/methodClickup/actions/upload_attachment.py b/modules/workflows/methods/methodClickup/actions/upload_attachment.py new file mode 100644 index 00000000..8cd1de4d --- /dev/null +++ b/modules/workflows/methods/methodClickup/actions/upload_attachment.py @@ -0,0 +1,88 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import base64 +import json +import logging +from typing import Any, Dict + +from modules.datamodels.datamodelChat import ActionDocument, ActionResult +from ..helpers.pathparse import parse_task_id + +logger = logging.getLogger(__name__) + + +async def upload_attachment(self, parameters: Dict[str, Any]) -> ActionResult: + connection_reference = parameters.get("connectionReference") + task_id = (parameters.get("taskId") or "").strip() + path_hint = (parameters.get("path") or "").strip() + if not connection_reference: + return ActionResult.isFailure(error="connectionReference is required") + if not task_id and path_hint: + task_id = parse_task_id(path_hint) or "" + if not task_id: + return ActionResult.isFailure(error="taskId is required") + + conn = self.connection.get_clickup_connection(connection_reference) + if not conn: + return ActionResult.isFailure(error="No valid ClickUp connection") + + content_param = parameters.get("content") + if not content_param: + return ActionResult.isFailure(error="content is required (connect a file node upstream)") + + content = content_param[0] if isinstance(content_param, (list, tuple)) and content_param else content_param + file_name = parameters.get("fileName") + file_bytes = None + + if isinstance(content, dict): + file_name = file_name or content.get("documentName") or content.get("fileName") or "attachment" + raw_data = content.get("documentData") + if (content.get("validationMetadata") or {}).get("fileId") and not raw_data: + fid = content["validationMetadata"]["fileId"] + try: + raw = self.services.chat.getFileData(fid) + file_bytes = raw if isinstance(raw, bytes) else str(raw).encode("utf-8") + except Exception as e: + return ActionResult.isFailure(error=f"Could not load file {fid}: {e}") + elif raw_data is not None: + if isinstance(raw_data, bytes): + file_bytes = raw_data + elif isinstance(raw_data, str): + try: + file_bytes = base64.b64decode(raw_data) + except Exception: + file_bytes = raw_data.encode("utf-8") + else: + return ActionResult.isFailure(error="Unsupported documentData type") + else: + return ActionResult.isFailure(error="Could not read file bytes from content") + elif hasattr(content, "documentData"): + file_name = file_name or getattr(content, "documentName", None) or getattr(content, "fileName", None) or "attachment" + raw_data = content.documentData + if isinstance(raw_data, bytes): + file_bytes = raw_data + elif isinstance(raw_data, str): + try: + file_bytes = base64.b64decode(raw_data) + except Exception: + file_bytes = raw_data.encode("utf-8") + else: + return ActionResult.isFailure(error="Unsupported documentData on ActionDocument") + else: + return ActionResult.isFailure(error="Unsupported content format") + + if not file_bytes: + return ActionResult.isFailure(error="Empty file content") + + data = await self.services.clickup.uploadTaskAttachment(task_id, file_bytes, file_name or "file") + if isinstance(data, dict) and data.get("error"): + return ActionResult.isFailure(error=str(data.get("error")) + (data.get("body") or "")) + + doc = ActionDocument( + documentName="clickup_upload_attachment.json", + documentData=json.dumps(data, ensure_ascii=False, indent=2), + mimeType="application/json", + validationMetadata={"actionType": "clickup.uploadAttachment", "taskId": task_id}, + ) + return ActionResult.isSuccess(documents=[doc]) diff --git a/modules/workflows/methods/methodClickup/helpers/__init__.py b/modules/workflows/methods/methodClickup/helpers/__init__.py new file mode 100644 index 00000000..fdcc4f0e --- /dev/null +++ b/modules/workflows/methods/methodClickup/helpers/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. diff --git a/modules/workflows/methods/methodClickup/helpers/connection.py b/modules/workflows/methods/methodClickup/helpers/connection.py new file mode 100644 index 00000000..d9b6d4d7 --- /dev/null +++ b/modules/workflows/methods/methodClickup/helpers/connection.py @@ -0,0 +1,50 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Resolve ClickUp UserConnection and configure ClickupService.""" + +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +class ClickupConnectionHelper: + def __init__(self, method_instance: Any): + self.method = method_instance + self.services = method_instance.services + + def get_clickup_connection(self, connection_reference: str) -> Optional[Dict[str, Any]]: + try: + ref = (connection_reference or "").split(" [")[0].strip() + if not ref: + return None + user_connection = None + if ref.startswith("connection:"): + user_connection = self.services.chat.getUserConnectionFromConnectionReference(ref) + else: + app = getattr(self.services, "interfaceDbApp", None) + if app and hasattr(app, "getUserConnectionById"): + user_connection = app.getUserConnectionById(ref) + if not user_connection: + logger.warning("No user connection for reference/id %s", connection_reference) + return None + authority = getattr(user_connection.authority, "value", None) or str( + user_connection.authority + ) + if authority != "clickup": + logger.warning("Connection %s is not ClickUp (authority=%s)", user_connection.id, authority) + return None + status = getattr(user_connection.status, "value", None) or str(user_connection.status) + if status not in ("active", "pending"): + logger.warning("Connection %s status not active: %s", user_connection.id, status) + + cu = getattr(self.services, "clickup", None) + if not cu: + return None + if not cu.setAccessTokenFromConnection(user_connection): + logger.warning("Failed to set ClickUp token for connection %s", user_connection.id) + return None + return {"id": user_connection.id, "userConnection": user_connection} + except Exception as e: + logger.error("get_clickup_connection error: %s", e) + return None diff --git a/modules/workflows/methods/methodClickup/helpers/pathparse.py b/modules/workflows/methods/methodClickup/helpers/pathparse.py new file mode 100644 index 00000000..c97b69b2 --- /dev/null +++ b/modules/workflows/methods/methodClickup/helpers/pathparse.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Parse virtual ClickUp paths used by the connector.""" + +import re +from typing import Optional, Tuple + + +def parse_team_and_list(path: str) -> Tuple[Optional[str], Optional[str]]: + p = (path or "").strip() + m = re.match(r"^/team/([^/]+)/list/([^/]+)$", p) + if m: + return m.group(1), m.group(2) + return None, None + + +def parse_task_id(path_or_id: str) -> Optional[str]: + s = (path_or_id or "").strip() + if not s: + return None + m = re.match(r"^.*/task/([^/]+)$", s) + if m: + return m.group(1) + if re.match(r"^[a-zA-Z0-9_-]+$", s) and len(s) > 4: + return s + return None diff --git a/modules/workflows/methods/methodClickup/methodClickup.py b/modules/workflows/methods/methodClickup/methodClickup.py new file mode 100644 index 00000000..00c658a5 --- /dev/null +++ b/modules/workflows/methods/methodClickup/methodClickup.py @@ -0,0 +1,349 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""ClickUp workflow method — list/search/get/create/update tasks and upload attachments.""" + +import logging + +from modules.datamodels.datamodelWorkflowActions import WorkflowActionDefinition, WorkflowActionParameter +from modules.shared.frontendTypes import FrontendType +from modules.workflows.methods.methodBase import MethodBase + +from .helpers.connection import ClickupConnectionHelper +from .actions.list_tasks import list_tasks +from .actions.search_tasks import search_tasks +from .actions.get_task import get_task +from .actions.create_task import create_task +from .actions.update_task import update_task +from .actions.upload_attachment import upload_attachment + +logger = logging.getLogger(__name__) + + +class MethodClickup(MethodBase): + """ClickUp API actions for automation2 (lists as tables).""" + + def __init__(self, services): + super().__init__(services) + self.name = "clickup" + self.description = "ClickUp task and list operations" + self.connection = ClickupConnectionHelper(self) + + self._actions = { + "listTasks": WorkflowActionDefinition( + actionId="clickup.listTasks", + description="List tasks in a ClickUp list (virtual path /team/{id}/list/{id})", + dynamicMode=True, + parameters={ + "connectionReference": WorkflowActionParameter( + name="connectionReference", + type="str", + frontendType=FrontendType.USER_CONNECTION, + required=True, + description="ClickUp connection", + ), + "pathQuery": WorkflowActionParameter( + name="pathQuery", + type="str", + frontendType=FrontendType.TEXT, + required=True, + description="Virtual path to list: /team/{teamId}/list/{listId}", + ), + "page": WorkflowActionParameter( + name="page", + type="int", + frontendType=FrontendType.NUMBER, + required=False, + default=0, + description="Page index", + ), + "includeClosed": WorkflowActionParameter( + name="includeClosed", + type="bool", + frontendType=FrontendType.CHECKBOX, + required=False, + default=False, + description="Include closed tasks", + ), + }, + execute=list_tasks.__get__(self, self.__class__), + ), + "searchTasks": WorkflowActionDefinition( + actionId="clickup.searchTasks", + description="Search tasks in a ClickUp workspace (team)", + dynamicMode=True, + parameters={ + "connectionReference": WorkflowActionParameter( + name="connectionReference", + type="str", + frontendType=FrontendType.USER_CONNECTION, + required=True, + description="ClickUp connection", + ), + "teamId": WorkflowActionParameter( + name="teamId", + type="str", + frontendType=FrontendType.TEXT, + required=True, + description="Workspace (team) ID", + ), + "query": WorkflowActionParameter( + name="query", + type="str", + frontendType=FrontendType.TEXT, + required=True, + description="Search query", + ), + "page": WorkflowActionParameter( + name="page", + type="int", + frontendType=FrontendType.NUMBER, + required=False, + default=0, + description="Page index", + ), + "listId": WorkflowActionParameter( + name="listId", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description=( + "If set, tasks are loaded from this list via the list API (not team search). " + "Use this to search the selected table." + ), + ), + "includeClosed": WorkflowActionParameter( + name="includeClosed", + type="bool", + frontendType=FrontendType.CHECKBOX, + required=False, + default=False, + description="When listId is set, include closed tasks in list pages.", + ), + "fullTaskData": WorkflowActionParameter( + name="fullTaskData", + type="bool", + frontendType=FrontendType.CHECKBOX, + required=False, + default=False, + description="If true, return raw ClickUp API task objects (large). Default is a slim payload.", + ), + "matchNameOnly": WorkflowActionParameter( + name="matchNameOnly", + type="bool", + frontendType=FrontendType.CHECKBOX, + required=False, + default=True, + description="If true, keep only tasks whose title contains the search query (default: true).", + ), + }, + execute=search_tasks.__get__(self, self.__class__), + ), + "getTask": WorkflowActionDefinition( + actionId="clickup.getTask", + description="Get a single task by ID", + dynamicMode=True, + parameters={ + "connectionReference": WorkflowActionParameter( + name="connectionReference", + type="str", + frontendType=FrontendType.USER_CONNECTION, + required=True, + description="ClickUp connection", + ), + "taskId": WorkflowActionParameter( + name="taskId", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Task ID", + ), + "pathQuery": WorkflowActionParameter( + name="pathQuery", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Optional virtual path ending in /task/{taskId}", + ), + }, + execute=get_task.__get__(self, self.__class__), + ), + "createTask": WorkflowActionDefinition( + actionId="clickup.createTask", + description="Create a task in a list", + dynamicMode=True, + parameters={ + "connectionReference": WorkflowActionParameter( + name="connectionReference", + type="str", + frontendType=FrontendType.USER_CONNECTION, + required=True, + description="ClickUp connection", + ), + "listId": WorkflowActionParameter( + name="listId", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="List ID (if not using path)", + ), + "pathQuery": WorkflowActionParameter( + name="pathQuery", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Virtual path to list /team/{teamId}/list/{listId}", + ), + "name": WorkflowActionParameter( + name="name", + type="str", + frontendType=FrontendType.TEXT, + required=True, + description="Task name", + ), + "description": WorkflowActionParameter( + name="description", + type="str", + frontendType=FrontendType.TEXTAREA, + required=False, + description="Task description", + ), + "customFieldValues": WorkflowActionParameter( + name="customFieldValues", + type="str", + frontendType=FrontendType.TEXTAREA, + required=False, + description="Map of ClickUp custom field id to value (merged into custom_fields).", + ), + "taskFields": WorkflowActionParameter( + name="taskFields", + type="str", + frontendType=FrontendType.TEXTAREA, + required=False, + description="Optional extra JSON object merged into create payload (overrides standard fields)", + ), + "taskStatus": WorkflowActionParameter( + name="taskStatus", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="ClickUp status name for this list", + ), + "taskPriority": WorkflowActionParameter( + name="taskPriority", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Priority 1 (urgent)–4 (low), empty to omit", + ), + "taskDueDateMs": WorkflowActionParameter( + name="taskDueDateMs", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Due date as Unix ms timestamp", + ), + "taskAssigneeIds": WorkflowActionParameter( + name="taskAssigneeIds", + type="str", + frontendType=FrontendType.TEXTAREA, + required=False, + description="JSON array of ClickUp user ids, e.g. [123,456]", + ), + "taskTimeEstimateMs": WorkflowActionParameter( + name="taskTimeEstimateMs", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Time estimate in milliseconds", + ), + "taskTimeEstimateHours": WorkflowActionParameter( + name="taskTimeEstimateHours", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Time estimate in hours (converted to ms; preferred over taskTimeEstimateMs)", + ), + }, + execute=create_task.__get__(self, self.__class__), + ), + "updateTask": WorkflowActionDefinition( + actionId="clickup.updateTask", + description="Update a task (JSON body per ClickUp API)", + dynamicMode=True, + parameters={ + "connectionReference": WorkflowActionParameter( + name="connectionReference", + type="str", + frontendType=FrontendType.USER_CONNECTION, + required=True, + description="ClickUp connection", + ), + "taskId": WorkflowActionParameter( + name="taskId", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Task ID", + ), + "path": WorkflowActionParameter( + name="path", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Optional path ending in /task/{taskId}", + ), + "taskUpdate": WorkflowActionParameter( + name="taskUpdate", + type="str", + frontendType=FrontendType.TEXTAREA, + required=False, + description="JSON object for PUT /task/{id} (e.g. {\"name\":\"...\",\"status\":\"...\"}); built from editor rows if empty", + ), + }, + execute=update_task.__get__(self, self.__class__), + ), + "uploadAttachment": WorkflowActionDefinition( + actionId="clickup.uploadAttachment", + description="Upload a file attachment to a task", + dynamicMode=True, + parameters={ + "connectionReference": WorkflowActionParameter( + name="connectionReference", + type="str", + frontendType=FrontendType.USER_CONNECTION, + required=True, + description="ClickUp connection", + ), + "taskId": WorkflowActionParameter( + name="taskId", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Task ID", + ), + "path": WorkflowActionParameter( + name="path", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Optional path ending in /task/{taskId}", + ), + "fileName": WorkflowActionParameter( + name="fileName", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Attachment file name", + ), + "content": WorkflowActionParameter( + name="content", + type="Any", + frontendType=FrontendType.DOCUMENT_REFERENCE, + required=True, + description="File from upstream node", + ), + }, + execute=upload_attachment.__get__(self, self.__class__), + ), + } + self._validateActions() diff --git a/modules/workflows/methods/methodFile/__init__.py b/modules/workflows/methods/methodFile/__init__.py new file mode 100644 index 00000000..b8c41e0f --- /dev/null +++ b/modules/workflows/methods/methodFile/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +from .methodFile import MethodFile + +__all__ = ["MethodFile"] diff --git a/modules/workflows/methods/methodFile/actions/__init__.py b/modules/workflows/methods/methodFile/actions/__init__.py new file mode 100644 index 00000000..9aef4028 --- /dev/null +++ b/modules/workflows/methods/methodFile/actions/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +from .create import create + +__all__ = ["create"] diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py new file mode 100644 index 00000000..73816da0 --- /dev/null +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -0,0 +1,147 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import base64 +import logging +from typing import Dict, Any + +from modules.datamodels.datamodelChat import ActionResult, ActionDocument +from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson + +logger = logging.getLogger(__name__) + + +def _persistDocumentsToUserFiles( + action_documents: list, + services, +) -> None: + """Persist file.create output documents to user's file storage (like upload). + Adds fileId to each document's validationMetadata for download links in UI.""" + mgmt = getattr(services, "interfaceDbComponent", None) + if not mgmt: + try: + import modules.interfaces.interfaceDbManagement as iface + user = getattr(services, "user", None) + if not user: + return + mgmt = iface.getInterface( + user, + mandateId=getattr(services, "mandateId", None) or "", + featureInstanceId=getattr(services, "featureInstanceId", None) or "", + ) + except Exception as e: + logger.warning("file.create: could not get management interface for persistence: %s", e) + return + if not mgmt: + return + for doc in action_documents: + try: + doc_data = doc.documentData if hasattr(doc, "documentData") else doc.get("documentData") + if not doc_data: + continue + if isinstance(doc_data, str): + content = base64.b64decode(doc_data) + else: + content = doc_data + doc_name = ( + getattr(doc, "documentName", None) + or doc.get("documentName") + or "output.pdf" + ) + mime = ( + getattr(doc, "mimeType", None) + or doc.get("mimeType") + or "application/octet-stream" + ) + file_item = mgmt.createFile(doc_name, mime, content) + mgmt.createFileData(file_item.id, content) + meta = getattr(doc, "validationMetadata", None) or doc.get("validationMetadata") or {} + if isinstance(meta, dict): + meta["fileId"] = file_item.id + if hasattr(doc, "validationMetadata"): + doc.validationMetadata = meta + elif isinstance(doc, dict): + doc["validationMetadata"] = meta + logger.info("file.create: persisted %s to user files (id=%s)", doc_name, file_item.id) + except Exception as e: + dname = getattr(doc, "documentName", None) or doc.get("documentName", "?") + logger.warning("file.create: failed to persist document %s: %s", dname, e) + + +async def create(self, parameters: Dict[str, Any]) -> ActionResult: + """ + Create a file from context (text/markdown from upstream AI node). + Uses GenerationService.renderReport to produce docx, pdf, txt, md, html, xlsx, etc. + """ + context = parameters.get("context", "") or "" + if not isinstance(context, str): + context = str(context) if context else "" + context = context.strip() + + if not context: + return ActionResult.isFailure(error="context is required (connect an AI node or provide text)") + + outputFormat = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".") + title = (parameters.get("title") or "Document").strip() + templateName = parameters.get("templateName") + language = (parameters.get("language") or "de").strip()[:2] + + try: + structured_content = markdownToDocumentJson(context, title, language) + if templateName: + structured_content.setdefault("metadata", {})["templateName"] = templateName + + generation = getattr(self.services, "generation", None) + if not generation: + return ActionResult.isFailure(error="Generation service not available") + + ai_service = getattr(self.services, "ai", None) + rendered_docs = await generation.renderReport( + extractedContent=structured_content, + outputFormat=outputFormat, + language=language, + title=title, + userPrompt=None, + aiService=ai_service, + parentOperationId=parameters.get("parentOperationId"), + ) + + if not rendered_docs: + return ActionResult.isFailure(error="Rendering produced no output") + + action_documents = [] + mime_map = { + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "pdf": "application/pdf", + "txt": "text/plain", + "md": "text/markdown", + "html": "text/html", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "csv": "text/csv", + "json": "application/json", + } + for rd in rendered_docs: + doc_data = rd.documentData if hasattr(rd, "documentData") else getattr(rd, "document_data", None) + doc_name = getattr(rd, "filename", None) or getattr(rd, "documentName", None) or getattr(rd, "document_name", f"output.{outputFormat}") + mime = getattr(rd, "mimeType", None) or getattr(rd, "mime_type", None) or mime_map.get(outputFormat, "application/octet-stream") + + if isinstance(doc_data, bytes): + doc_data = base64.b64encode(doc_data).decode("ascii") + + action_documents.append(ActionDocument( + documentName=doc_name, + documentData=doc_data, + mimeType=mime, + validationMetadata={ + "actionType": "file.create", + "outputFormat": outputFormat, + "templateName": templateName, + }, + )) + + _persistDocumentsToUserFiles(action_documents, self.services) + return ActionResult.isSuccess(documents=action_documents) + + except Exception as e: + logger.error(f"file.create failed: {e}", exc_info=True) + return ActionResult.isFailure(error=str(e)) diff --git a/modules/workflows/methods/methodFile/methodFile.py b/modules/workflows/methods/methodFile/methodFile.py new file mode 100644 index 00000000..072ca598 --- /dev/null +++ b/modules/workflows/methods/methodFile/methodFile.py @@ -0,0 +1,81 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. + +import logging +from modules.workflows.methods.methodBase import MethodBase +from modules.datamodels.datamodelWorkflowActions import WorkflowActionDefinition, WorkflowActionParameter +from modules.shared.frontendTypes import FrontendType + +from .actions.create import create + +logger = logging.getLogger(__name__) + + +class MethodFile(MethodBase): + """File creation methods - create documents from context (e.g. from AI nodes).""" + + def __init__(self, services): + super().__init__(services) + self.name = "file" + self.description = "File creation from context" + + self._actions = { + "create": WorkflowActionDefinition( + actionId="file.create", + description="Create a file from context (text/markdown from AI). Configurable format and style preset.", + dynamicMode=True, + parameters={ + "contentSources": WorkflowActionParameter( + name="contentSources", + type="list", + frontendType=FrontendType.HIDDEN, + required=False, + description="Array of context refs. Resolved and concatenated. Empty = from connected node.", + ), + "context": WorkflowActionParameter( + name="context", + type="str", + frontendType=FrontendType.HIDDEN, + required=False, + description="Injected from contentSource or upstream connection", + ), + "outputFormat": WorkflowActionParameter( + name="outputFormat", + type="str", + frontendType=FrontendType.SELECT, + frontendOptions=["docx", "pdf", "txt", "md", "html", "xlsx", "csv", "json"], + required=True, + default="docx", + description="Output file format", + ), + "title": WorkflowActionParameter( + name="title", + type="str", + frontendType=FrontendType.TEXT, + required=False, + description="Document title", + ), + "templateName": WorkflowActionParameter( + name="templateName", + type="str", + frontendType=FrontendType.SELECT, + frontendOptions=["default", "corporate", "minimal"], + required=False, + description="Style preset", + ), + "language": WorkflowActionParameter( + name="language", + type="str", + frontendType=FrontendType.SELECT, + frontendOptions=["de", "en", "fr", "it", "es"], + required=False, + default="de", + description="Language code", + ), + }, + execute=create.__get__(self, self.__class__), + ), + } + + self._validateActions() + self.create = create.__get__(self, self.__class__) diff --git a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py index 09cdd1dd..5c15173e 100644 --- a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py +++ b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py @@ -16,6 +16,7 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A to = parameters.get("to") or [] # Optional for drafts - can save draft without recipients context = parameters.get("context") documentList = parameters.get("documentList") or [] + replySourceDocuments = parameters.get("replySourceDocuments") or [] # Original email(s) for reply attachment cc = parameters.get("cc") or [] bcc = parameters.get("bcc") or [] emailStyle = parameters.get("emailStyle") or "business" @@ -34,6 +35,7 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A if isinstance(to, str): to = [to] ai_attachments = [] + normalized_ai_attachments = [] # Jump to create-email section (see below) else: direct_subject = parameters.get("subject") @@ -44,6 +46,7 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A if isinstance(to, str): to = [to] ai_attachments = [] + normalized_ai_attachments = [] else: subject = None body = None @@ -51,6 +54,12 @@ async def composeAndDraftEmailWithContext(self, parameters: Dict[str, Any]) -> A use_direct_content = bool(subject and body) + # Ensure subject/body are strings (not bytes) for JSON serialization + if subject and isinstance(subject, bytes): + subject = subject.decode("utf-8", errors="replace") + if body and isinstance(body, bytes): + body = body.decode("utf-8", errors="replace") + if not use_direct_content: # Original path: require connectionReference and context if not connectionReference or not context: @@ -263,9 +272,12 @@ Return JSON: # Add documents as attachments if provided # Supports: 1) inline ActionDocuments (dict with documentData from e.g. sharepoint.downloadFile) # 2) docItem:... references (chat workflow documents) - if documentList: + # 3) replySourceDocuments: original email(s) for reply – attach when use_direct_content + # When use_direct_content: upstream AI doc IS the email body – do not attach it, BUT attach reply sources + attachments_doc_list = (replySourceDocuments or []) if use_direct_content else (documentList or []) + if attachments_doc_list: message["attachments"] = [] - for attachment_ref in documentList: + for attachment_ref in attachments_doc_list: base64_content = None attach_name = "attachment" attach_mime = "application/octet-stream" @@ -276,10 +288,57 @@ Return JSON: is_inline = bool(getattr(attachment_ref, "documentData", None)) if is_inline: doc = attachment_ref - base64_content = doc.get("documentData") if isinstance(doc, dict) else getattr(doc, "documentData", None) - attach_name = (doc.get("documentName") or doc.get("fileName")) if isinstance(doc, dict) else (getattr(doc, "documentName", None) or getattr(doc, "fileName", "attachment")) - attach_mime = (doc.get("mimeType") or attach_mime) if isinstance(doc, dict) else (getattr(doc, "mimeType", None) or attach_mime) + raw_data = doc.get("documentData") if isinstance(doc, dict) else getattr(doc, "documentData", None) + vm = doc.get("validationMetadata") or {} if isinstance(doc, dict) else (getattr(doc, "validationMetadata") or {}) + action_type = vm.get("actionType", "") if isinstance(vm, dict) else "" + # Reply source: email search/read result – convert first email to .eml for proper reply attachment + if "outlook" in action_type.lower() and "email" in action_type.lower() and raw_data: + try: + data = json.loads(raw_data) if isinstance(raw_data, str) else raw_data + emails_list = [] + if isinstance(data, dict): + sr = data.get("searchResults") or {} + emails_list = sr.get("results", []) if isinstance(sr, dict) else [] + if not emails_list: + ed = data.get("emails") or {} + emails_list = ed.get("emails", []) if isinstance(ed, dict) else [] + if not emails_list and isinstance(data.get("emails"), list): + emails_list = data["emails"] + if emails_list and isinstance(emails_list[0], dict): + em = emails_list[0] + fr = em.get("from", em.get("sender", {})) + addr = fr.get("emailAddress", {}) if isinstance(fr, dict) else {} + from_addr = addr.get("address", "") or addr.get("name", "") + subj = em.get("subject", "") + body_obj = em.get("body") or {} + body_content = body_obj.get("content", "") if isinstance(body_obj, dict) else str(body_obj) + eml_lines = [ + f"From: {from_addr}", + f"Subject: {subj}", + "MIME-Version: 1.0", + "Content-Type: text/html; charset=utf-8", + "", + body_content or "(no content)" + ] + eml_bytes = "\n".join(eml_lines).encode("utf-8") + base64_content = base64.b64encode(eml_bytes).decode("utf-8") + attach_name = f"original_message_{subj[:30].replace(' ', '_') if subj else 'email'}.eml" + attach_mime = "message/rfc822" + except Exception as e: + logger.debug("Could not convert email JSON to .eml: %s", e) + base64_content = raw_data + attach_name = (doc.get("documentName") or doc.get("fileName") or "attachment") if isinstance(doc, dict) else (getattr(doc, "documentName", None) or getattr(doc, "fileName", "attachment")) + attach_mime = (doc.get("mimeType") or attach_mime) if isinstance(doc, dict) else (getattr(doc, "mimeType", None) or attach_mime) + else: + base64_content = raw_data + attach_name = (doc.get("documentName") or doc.get("fileName")) if isinstance(doc, dict) else (getattr(doc, "documentName", None) or getattr(doc, "fileName", "attachment")) + attach_mime = (doc.get("mimeType") or attach_mime) if isinstance(doc, dict) else (getattr(doc, "mimeType", None) or attach_mime) if base64_content and attach_name: + # Microsoft Graph expects contentBytes as base64 string; documentData may be bytes (e.g. from ai.generateDocument) + if isinstance(base64_content, bytes): + base64_content = base64.b64encode(base64_content).decode("utf-8") + elif not isinstance(base64_content, str): + base64_content = base64.b64encode(str(base64_content).encode("utf-8")).decode("utf-8") message["attachments"].append({ "@odata.type": "#microsoft.graph.fileAttachment", "name": attach_name, @@ -361,7 +420,7 @@ Return JSON: "recipients": to, "cc": cc, "bcc": bcc, - "attachments": len(documentList) if documentList else 0, + "attachments": len(message.get("attachments", [])), "aiSelectedAttachments": normalized_ai_attachments if normalized_ai_attachments else "all documents", "aiGenerated": True, "context": context, @@ -373,10 +432,10 @@ Return JSON: # Extract attachment filenames for validation metadata attachmentFilenames = [] attachmentReferences = [] - if documentList: + if attachments_doc_list: # Inline docs (dict with documentName): use directly - string_refs = [r for r in documentList if isinstance(r, str)] - inline_docs = [r for r in documentList if isinstance(r, dict)] + string_refs = [r for r in attachments_doc_list if isinstance(r, str)] + inline_docs = [r for r in attachments_doc_list if isinstance(r, dict)] for d in inline_docs: name = d.get("documentName") or d.get("fileName") if name: diff --git a/modules/workflows/methods/methodSharepoint/actions/uploadFile.py b/modules/workflows/methods/methodSharepoint/actions/uploadFile.py index 86d5787d..56e9f0b2 100644 --- a/modules/workflows/methods/methodSharepoint/actions/uploadFile.py +++ b/modules/workflows/methods/methodSharepoint/actions/uploadFile.py @@ -15,6 +15,11 @@ async def uploadFile(self, parameters: Dict[str, Any]) -> ActionResult: if not connectionReference: return ActionResult.isFailure(error="connectionReference parameter is required") + # Set SharePoint access token first – required before siteDiscovery/sharepoint calls + connection = self.connection.getMicrosoftConnection(connectionReference) + if not connection: + return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") + contentParam = parameters.get("content") if not contentParam: return ActionResult.isFailure(error="content parameter is required") @@ -100,12 +105,7 @@ async def uploadFile(self, parameters: Dict[str, Any]) -> ActionResult: except Exception as e: return ActionResult.isFailure(error=f"Could not decode base64 file content: {str(e)}") - # Get Microsoft connection - connection = self.connection.getMicrosoftConnection(connectionReference) - if not connection: - return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") - - # Upload file + # Upload file (connection/token already set above) uploadResult = await self.services.sharepoint.uploadFile( siteId=siteId, folderPath=folderPath, diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index b9b64a9a..555e3c6f 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -42,7 +42,7 @@ class WorkflowManager: if ctx is not None: ctx.workflow = workflow # Also update contexts on resolved services (they may be cached with different context refs) - for attr in ('chat', 'ai', 'extraction', 'sharepoint', 'utils', 'billing', 'generation'): + for attr in ('chat', 'ai', 'extraction', 'sharepoint', 'clickup', 'utils', 'billing', 'generation'): svc = getattr(self.services, attr, None) if svc is not None and hasattr(svc, '_context') and svc._context is not None: svc._context.workflow = workflow diff --git a/tests/unit/workflows/test_automation2_graphUtils.py b/tests/unit/workflows/test_automation2_graphUtils.py new file mode 100644 index 00000000..45f4ba0f --- /dev/null +++ b/tests/unit/workflows/test_automation2_graphUtils.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Unit tests for automation2 graphUtils - resolveParameterReferences (ref/value format). +""" + +import pytest + +from modules.workflows.automation2.graphUtils import resolveParameterReferences + + +class TestResolveParameterReferences: + """Test structured ref/value resolution.""" + + def test_ref_simple(self): + node_outputs = { + "n1": {"payload": {"country": "CH"}}, + } + value = {"type": "ref", "nodeId": "n1", "path": ["payload", "country"]} + assert resolveParameterReferences(value, node_outputs) == "CH" + + def test_ref_root(self): + node_outputs = {"n1": {"a": 1, "b": 2}} + value = {"type": "ref", "nodeId": "n1", "path": []} + assert resolveParameterReferences(value, node_outputs) == {"a": 1, "b": 2} + + def test_ref_nested(self): + node_outputs = {"form_1": {"customer": {"country": "DE", "name": "Test"}}} + value = {"type": "ref", "nodeId": "form_1", "path": ["customer", "country"]} + assert resolveParameterReferences(value, node_outputs) == "DE" + + def test_ref_array_index(self): + node_outputs = {"n1": {"items": ["a", "b", "c"]}} + value = {"type": "ref", "nodeId": "n1", "path": ["items", 1]} + assert resolveParameterReferences(value, node_outputs) == "b" + + def test_ref_missing_node(self): + node_outputs = {} + value = {"type": "ref", "nodeId": "missing", "path": ["x"]} + assert resolveParameterReferences(value, node_outputs) == value + + def test_value_wrapper(self): + value = {"type": "value", "value": "static text"} + assert resolveParameterReferences(value, {}) == "static text" + + def test_value_nested_ref(self): + node_outputs = {"n1": {"x": 42}} + value = {"type": "value", "value": {"type": "ref", "nodeId": "n1", "path": ["x"]}} + assert resolveParameterReferences(value, node_outputs) == 42 + + def test_dict_mixed_ref_value(self): + node_outputs = {"n1": {"result": "hello"}} + value = { + "prompt": {"type": "ref", "nodeId": "n1", "path": ["result"]}, + "suffix": {"type": "value", "value": " world"}, + } + result = resolveParameterReferences(value, node_outputs) + assert result == {"prompt": "hello", "suffix": " world"} + + def test_legacy_string_template(self): + node_outputs = {"n1": {"country": "CH"}} + value = "Land: {{n1.country}}" + assert resolveParameterReferences(value, node_outputs) == "Land: CH"