diff --git a/backend/workflows/actions.py b/backend/workflows/actions.py
index 14be92c..8edd09c 100644
--- a/backend/workflows/actions.py
+++ b/backend/workflows/actions.py
@@ -15,6 +15,7 @@
LogAction,
ReleaseIPAction,
SendEmailAction,
+ SendNotificationAction,
UpdateTicketAction,
)
@@ -33,5 +34,6 @@
"LogAction",
"ReleaseIPAction",
"SendEmailAction",
+ "SendNotificationAction",
"UpdateTicketAction",
]
diff --git a/backend/workflows/prefect/actions/__init__.py b/backend/workflows/prefect/actions/__init__.py
index 6f1fef6..034370d 100644
--- a/backend/workflows/prefect/actions/__init__.py
+++ b/backend/workflows/prefect/actions/__init__.py
@@ -11,6 +11,7 @@
from .registry import ActionRegistry
from .release_ip import ReleaseIPAction
from .send_email import SendEmailAction
+from .send_notification import SendNotificationAction
from .update_ticket import UpdateTicketAction
__all__ = [
@@ -28,5 +29,6 @@
"LogAction",
"ReleaseIPAction",
"SendEmailAction",
+ "SendNotificationAction",
"UpdateTicketAction",
]
diff --git a/backend/workflows/prefect/actions/registry.py b/backend/workflows/prefect/actions/registry.py
index fec0ad9..7e8457a 100644
--- a/backend/workflows/prefect/actions/registry.py
+++ b/backend/workflows/prefect/actions/registry.py
@@ -14,6 +14,7 @@
from .log import LogAction
from .release_ip import ReleaseIPAction
from .send_email import SendEmailAction
+from .send_notification import SendNotificationAction
from .update_ticket import UpdateTicketAction
@@ -22,6 +23,7 @@ class ActionRegistry:
"log": LogAction,
"delay": DelayAction,
"send_email": SendEmailAction,
+ "send_notification": SendNotificationAction,
"api_call": ApiCallAction,
"create_ticket": CreateTicketAction,
"update_ticket": UpdateTicketAction,
diff --git a/backend/workflows/prefect/actions/send_notification.py b/backend/workflows/prefect/actions/send_notification.py
new file mode 100644
index 0000000..93f85ef
--- /dev/null
+++ b/backend/workflows/prefect/actions/send_notification.py
@@ -0,0 +1,273 @@
+from __future__ import annotations
+
+import json
+from typing import Any, Dict
+from urllib.parse import urlsplit
+
+import requests
+
+from .base import ActionResult, BaseAction
+
+
+MAX_RESPONSE_BYTES = 16 * 1024
+PROVIDERS = {"slack", "feishu", "wecom"}
+
+
+class SendNotificationAction(BaseAction):
+ name = "Send Notification"
+ description = "Send a workflow notification to Slack, Feishu, or Wecom using an incoming webhook"
+ category = "notification"
+ config_schema = {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "type": "string",
+ "enum": ["slack", "feishu", "wecom"],
+ "x-enum-labels": {
+ "slack": "Slack",
+ "feishu": "Feishu",
+ "wecom": "Wecom",
+ },
+ "default": "feishu",
+ "description": "Notification provider",
+ },
+ "webhook_url": {
+ "type": "string",
+ "description": "Incoming bot webhook URL for the selected provider",
+ "writeOnly": True,
+ "x-sensitive": True,
+ "x-secret-bindings": ["provider"],
+ },
+ "title": {
+ "type": "string",
+ "description": "Notification title. Supports {{variable.path}} placeholders.",
+ },
+ "message": {
+ "type": "string",
+ "description": "Notification message. Supports {{variable.path}} placeholders.",
+ },
+ "format": {
+ "type": "string",
+ "enum": ["text", "markdown"],
+ "x-enum-labels": {
+ "text": "Text",
+ "markdown": "Markdown",
+ },
+ "default": "markdown",
+ "description": "Message format",
+ },
+ "mention_all": {
+ "type": "boolean",
+ "default": False,
+ "description": "Mention everyone when supported by the provider",
+ },
+ "include_context_payload": {
+ "type": "boolean",
+ "default": False,
+ "description": "Append selected workflow context data to the message",
+ },
+ "context_source": {
+ "type": "string",
+ "enum": ["previous_step", "trigger_data", "variables"],
+ "x-enum-labels": {
+ "previous_step": "Previous Step Output",
+ "trigger_data": "Trigger Data",
+ "variables": "Workflow Variables",
+ },
+ "default": "previous_step",
+ "description": "Workflow context data appended when include_context_payload is enabled",
+ },
+ "timeout": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 60,
+ "default": 15,
+ },
+ },
+ "required": ["provider", "webhook_url", "message"],
+ }
+
+ @staticmethod
+ def _bool(value: Any) -> bool:
+ if isinstance(value, bool):
+ return value
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
+
+ @staticmethod
+ def _provider(value: Any) -> str:
+ provider = str(value or "feishu").strip().lower()
+ aliases = {
+ "wechat_work": "wecom",
+ "wechat": "wecom",
+ "enterprise_wechat": "wecom",
+ "lark": "feishu",
+ }
+ return aliases.get(provider, provider)
+
+ @staticmethod
+ def _compose(title: str, message: str) -> str:
+ title = title.strip()
+ message = message.strip()
+ if title and message:
+ return f"{title}\n{message}"
+ return title or message
+
+ @staticmethod
+ def _validated_webhook_url(raw_url: str) -> str:
+ parsed = urlsplit(raw_url)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
+ raise ValueError("Webhook URL must be an absolute HTTP or HTTPS URL.")
+ if parsed.username is not None or parsed.password is not None:
+ raise ValueError("Webhook URL cannot contain embedded credentials.")
+ if parsed.fragment:
+ raise ValueError("Webhook URL cannot contain a URL fragment.")
+ return raw_url
+
+ @staticmethod
+ def _truncate_response(response: requests.Response) -> str:
+ raw = response.content or b""
+ try:
+ return raw[:MAX_RESPONSE_BYTES].decode(response.encoding or "utf-8", errors="replace")
+ except LookupError:
+ return raw[:MAX_RESPONSE_BYTES].decode("utf-8", errors="replace")
+
+ def _context_payload(self, context: Dict[str, Any], source: str) -> Any:
+ if source == "trigger_data":
+ return context.get("trigger_data") or {}
+ if source == "variables":
+ return context.get("variables") or {}
+ previous = context.get("previous_step") or {}
+ return previous.get("output") or {}
+
+ def _format_context_payload(self, payload: Any, fmt: str) -> str:
+ if payload in (None, "", {}, []):
+ return ""
+ if isinstance(payload, dict):
+ compact = {
+ key: value
+ for key, value in payload.items()
+ if key not in {"raw_response", "response_body", "response_json"}
+ }
+ payload = compact or payload
+ rendered = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
+ if fmt == "markdown":
+ return f"\n\nContext Payload:\n```json\n{rendered}\n```"
+ return f"\n\nContext Payload:\n{rendered}"
+
+ def _message(self, config: Dict[str, Any], context: Dict[str, Any], fmt: str) -> tuple[str, str]:
+ title = str(self.resolve_variables(config.get("title", ""), context) or "")
+ message = str(self.resolve_variables(config.get("message", ""), context) or "")
+ content = self._compose(title, message)
+ if self._bool(config.get("include_context_payload")):
+ source = str(config.get("context_source") or "previous_step")
+ content += self._format_context_payload(self._context_payload(context, source), fmt)
+ return title, content
+
+ def _slack_payload(self, title: str, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]:
+ prefix = "\n" if mention_all else ""
+ if fmt == "markdown" and title.strip():
+ body = content
+ if body.startswith(title.strip()):
+ body = body[len(title.strip()):].lstrip("\n")
+ text = f"{prefix}*{title.strip()}*"
+ if body:
+ text += f"\n{body}"
+ else:
+ text = prefix + content
+ return {"text": text, "mrkdwn": fmt == "markdown"}
+
+ def _feishu_payload(self, title: str, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]:
+ mention = 'All\n' if mention_all else ""
+ if fmt == "markdown":
+ return {
+ "msg_type": "interactive",
+ "card": {
+ "config": {"wide_screen_mode": True},
+ "header": {
+ "template": "blue",
+ "title": {
+ "tag": "plain_text",
+ "content": title.strip() or "Workflow Notification",
+ },
+ },
+ "elements": [
+ {
+ "tag": "div",
+ "text": {"tag": "lark_md", "content": mention + content},
+ }
+ ],
+ },
+ }
+ return {"msg_type": "text", "content": {"text": mention + content}}
+
+ def _wecom_payload(self, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]:
+ if fmt == "markdown":
+ mention = "<@all>\n" if mention_all else ""
+ return {"msgtype": "markdown", "markdown": {"content": mention + content}}
+ payload: Dict[str, Any] = {"msgtype": "text", "text": {"content": content}}
+ if mention_all:
+ payload["text"]["mentioned_list"] = ["@all"]
+ return payload
+
+ def _payload(self, provider: str, title: str, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]:
+ if provider == "slack":
+ return self._slack_payload(title, content, fmt, mention_all)
+ if provider == "feishu":
+ return self._feishu_payload(title, content, fmt, mention_all)
+ return self._wecom_payload(content, fmt, mention_all)
+
+ def _provider_error(self, provider: str, response: requests.Response) -> str:
+ if not response.content:
+ return ""
+ try:
+ payload = response.json()
+ except ValueError:
+ return ""
+ if not isinstance(payload, dict):
+ return ""
+ if provider == "feishu" and int(payload.get("code", 0) or 0) != 0:
+ return str(payload.get("msg") or payload.get("message") or "Feishu webhook returned an error.")
+ if provider == "wecom" and int(payload.get("errcode", 0) or 0) != 0:
+ return str(payload.get("errmsg") or payload.get("message") or "Wecom webhook returned an error.")
+ return ""
+
+ def execute(self, config: Dict[str, Any], context: Dict[str, Any]) -> ActionResult:
+ provider = self._provider(config.get("provider"))
+ if provider not in PROVIDERS:
+ return ActionResult(False, error=f"Unsupported notification provider: {provider}", logs="Notification validation failed.")
+ fmt = str(config.get("format") or "markdown").strip().lower()
+ if fmt not in {"text", "markdown"}:
+ fmt = "markdown"
+ try:
+ timeout = int(config.get("timeout") or 15)
+ if not 1 <= timeout <= 60:
+ raise ValueError("Notification timeout must be between 1 and 60 seconds.")
+ webhook_url = self._validated_webhook_url(str(config.get("webhook_url") or "").strip())
+ title, content = self._message(config, context, fmt)
+ if not content.strip():
+ raise ValueError("Notification message is empty after variable resolution.")
+ payload = self._payload(provider, title, content, fmt, self._bool(config.get("mention_all")))
+ response = requests.post(
+ webhook_url,
+ headers={"Content-Type": "application/json"},
+ json=payload,
+ timeout=timeout,
+ allow_redirects=False,
+ )
+ provider_error = self._provider_error(provider, response)
+ success = response.ok and not provider_error
+ return ActionResult(
+ success,
+ {
+ "provider": provider,
+ "status_code": response.status_code,
+ "response_body": self._truncate_response(response),
+ "format": fmt,
+ },
+ provider_error or ("" if response.ok else f"Notification webhook returned HTTP {response.status_code}."),
+ f"Notification request to {provider} returned HTTP {response.status_code}.",
+ )
+ except requests.RequestException as exc:
+ return ActionResult(False, error=f"Notification request failed ({type(exc).__name__}).", logs=f"Notification to {provider} failed.")
+ except (TypeError, ValueError) as exc:
+ return ActionResult(False, error=str(exc), logs="Notification validation failed.")
diff --git a/backend/workflows/views.py b/backend/workflows/views.py
index 13b1f10..a175f34 100644
--- a/backend/workflows/views.py
+++ b/backend/workflows/views.py
@@ -110,6 +110,12 @@ def bootstrap_presets(self, request):
'description': 'Send a security notification email',
'category': 'notification',
},
+ {
+ 'action_type': 'send_notification',
+ 'name': 'Slack / Feishu / Wecom Notification',
+ 'description': 'Send a security notification to Slack, Feishu, or Wecom',
+ 'category': 'notification',
+ },
{
'action_type': 'api_call',
'name': 'API Call',
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index cc7b848..f9590f3 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1286,7 +1286,7 @@ export async function getWorkflow(id: string): Promise {
}
// Create workflow
-type WorkflowWritePayload = Omit, 'execution_engine'> & {
+export type WorkflowWritePayload = Omit, 'execution_engine'> & {
execution_engine?: 'prefect';
};
diff --git a/frontend/src/modules/workflows/WorkflowEditor.tsx b/frontend/src/modules/workflows/WorkflowEditor.tsx
index dbdc4a8..1607081 100644
--- a/frontend/src/modules/workflows/WorkflowEditor.tsx
+++ b/frontend/src/modules/workflows/WorkflowEditor.tsx
@@ -35,6 +35,7 @@ import {
Workflow,
WorkflowStep,
ActionInfo,
+ WorkflowWritePayload,
} from 'services/workflows';
import { listInterfaceEndpoints } from 'services/interfaces';
import type { InterfaceEndpoint } from 'services/interfaces';
@@ -187,7 +188,7 @@ const WorkflowEditor: React.FC = ({ workflowId, onBack, onS
const tags = normalizeTags(values.tags);
- const payload: Partial = {
+ const payload: WorkflowWritePayload = {
name: values.name,
description: values.description || '',
trigger_type: values.trigger_type,
@@ -714,4 +715,3 @@ const WorkflowEditor: React.FC = ({ workflowId, onBack, onS
};
export default WorkflowEditor;
-
diff --git a/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx b/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx
index 2c37f8d..7ae7b9c 100644
--- a/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx
+++ b/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx
@@ -45,6 +45,7 @@ type JsonSchemaProperty = {
writeOnly?: boolean;
items?: JsonSchemaProperty;
properties?: Record;
+ 'x-enum-labels'?: Record | string[];
'x-sensitive'?: boolean;
};
@@ -97,10 +98,51 @@ const formatLabel = (name: string): string => (
const formatOptionLabel = (value: unknown): string => {
const raw = String(value);
+ if (raw === 'wechat_work' || raw === 'wechat' || raw === 'enterprise_wechat' || raw === 'wecom') {
+ return 'Wecom';
+ }
if (raw && raw === raw.toUpperCase()) return raw;
return formatLabel(raw.replace(/-/g, '_'));
};
+const optionLabelFromSchema = (
+ value: unknown,
+ labels?: Record | string[],
+ values?: unknown[],
+): string => {
+ const raw = String(value);
+ if (raw === 'wechat_work' || raw === 'wechat' || raw === 'enterprise_wechat' || raw === 'wecom') {
+ return 'Wecom';
+ }
+ if (Array.isArray(labels)) {
+ return labels[values?.indexOf(value) || 0] || formatOptionLabel(value);
+ }
+ return labels?.[raw] || formatOptionLabel(value);
+};
+
+const normalizeProviderForAction = (actionType: string, provider: unknown): string => {
+ const value = String(provider || 'generic').toLowerCase();
+ if (
+ actionType === 'send_notification'
+ && ['wechat_work', 'wechat', 'enterprise_wechat', 'wecom'].includes(value)
+ ) {
+ return 'wecom';
+ }
+ return value;
+};
+
+const normalizeProviderOptionValue = (fieldName: string, value: unknown): string | number => {
+ if (typeof value === 'number') return value;
+ const raw = String(value);
+ if (
+ fieldName === 'provider'
+ && ['wechat_work', 'wechat', 'enterprise_wechat', 'wecom'].includes(raw.toLowerCase())
+ ) {
+ return 'wecom';
+ }
+ return raw;
+};
+
const isMultilineField = (name: string): boolean => (
/(^|_)(body|comment|description|message|notes?|records?|template)$/.test(name)
);
@@ -129,15 +171,24 @@ const fieldsFromSchema = (schema?: ActionConfigSchema): FieldDef[] => {
const required = new Set(schema?.required || []);
return Object.entries(schema?.properties || {}).map(([name, property]) => {
const sensitive = Boolean(property.writeOnly || property['x-sensitive']);
+ const seenOptions = new Set();
+ const options = property.enum
+ ?.map((value) => ({
+ value: normalizeProviderOptionValue(name, value),
+ label: optionLabelFromSchema(value, property['x-enum-labels'], property.enum),
+ }))
+ .filter((option) => {
+ const key = String(option.value);
+ if (seenOptions.has(key)) return false;
+ seenOptions.add(key);
+ return true;
+ });
return {
name,
label: property.title || formatLabel(name),
type: resolveFieldType(name, property, sensitive),
required: required.has(name),
- options: property.enum?.map((value) => ({
- value: typeof value === 'number' ? value : String(value),
- label: formatOptionLabel(value),
- })),
+ options,
default: property.default,
description: property.description,
sensitive,
@@ -278,7 +329,8 @@ const ActionConfigBuilder: React.FC = ({
const [form] = Form.useForm();
const loadedConfigKey = useRef(null);
const initialTargets = useRef>({});
- const provider = Form.useWatch('provider', form) || config?.provider || 'generic';
+ const watchedProvider = Form.useWatch('provider', form) || config?.provider || 'generic';
+ const provider = normalizeProviderForAction(actionType, watchedProvider);
const schema = actionInfo?.config_schema as ActionConfigSchema | undefined;
const fields = fieldsFromSchema(schema);
@@ -298,7 +350,7 @@ const ActionConfigBuilder: React.FC = ({
if (loadedConfigKey.current === nextConfigKey) return;
loadedConfigKey.current = nextConfigKey;
initialTargets.current = {
- provider: String(config?.provider || 'generic').toLowerCase(),
+ provider: normalizeProviderForAction(actionType, config?.provider),
api_url: normalizeTargetValue(config?.api_url),
url: normalizeTargetValue(config?.url),
alias_name: String(config?.alias_name || 'ARGUS_BLOCKLIST').trim(),
@@ -312,6 +364,9 @@ const ActionConfigBuilder: React.FC = ({
&& !(typeof value === 'string' && value.startsWith('enc:v1:'))
)),
);
+ if (actionType === 'send_notification' && safeConfig.provider) {
+ safeConfig.provider = normalizeProviderForAction(actionType, safeConfig.provider);
+ }
form.resetFields();
form.setFieldsValue(safeConfig);
setJsonValue(JSON.stringify(safeConfig, null, 2));
@@ -321,12 +376,16 @@ const ActionConfigBuilder: React.FC = ({
const values = { ...inputValues };
const initial = initialTargets.current;
const apiUrlChanged = normalizeTargetValue(values.api_url) !== initial.api_url;
+ if (actionType === 'send_notification' && values.provider) {
+ values.provider = normalizeProviderForAction(actionType, values.provider);
+ }
const providerChanged = (
- String(values.provider || 'generic').toLowerCase() !== initial.provider
+ normalizeProviderForAction(actionType, values.provider) !== initial.provider
);
const isOPNsenseAction = actionType === 'block_ip' || actionType === 'release_ip';
+ const providerBoundSecretAction = actionType === 'send_notification';
const protectedChanged = configuredSecretFields.length > 0
- && (apiUrlChanged || (isOPNsenseAction && providerChanged));
+ && (apiUrlChanged || ((isOPNsenseAction || providerBoundSecretAction) && providerChanged));
const nextAliasChanged = (
isOPNsenseAction
&& configuredSecretFields.some((field) => field === 'api_key' || field === 'api_secret')
@@ -465,7 +524,7 @@ const ActionConfigBuilder: React.FC = ({
showIcon
style={{ marginBottom: 16 }}
message="Credential re-entry required"
- description="The Provider or API URL changed. Re-enter the API Key and, for OPNsense, the API Secret before saving."
+ description="A credential binding field changed. Re-enter the protected credential before saving."
/>
)}
diff --git a/frontend/src/modules/workflows/components/ActionPalette.tsx b/frontend/src/modules/workflows/components/ActionPalette.tsx
index 178665c..094e7d5 100644
--- a/frontend/src/modules/workflows/components/ActionPalette.tsx
+++ b/frontend/src/modules/workflows/components/ActionPalette.tsx
@@ -15,6 +15,7 @@ import {
BranchesOutlined,
PlayCircleOutlined,
StopOutlined,
+ NotificationOutlined,
} from '@ant-design/icons';
import type { ActionInfo, SavedWorkflowNode } from 'services/workflows';
@@ -46,6 +47,7 @@ const toCategoryLabel = (value: string): string => {
const actionIcons: Record = {
send_email: ,
+ send_notification: ,
api_call: ,
ip_lookup: ,
hash_lookup: ,
diff --git a/frontend/src/modules/workflows/components/CustomNodes.tsx b/frontend/src/modules/workflows/components/CustomNodes.tsx
index a758a19..6da8cdb 100644
--- a/frontend/src/modules/workflows/components/CustomNodes.tsx
+++ b/frontend/src/modules/workflows/components/CustomNodes.tsx
@@ -21,6 +21,7 @@ import {
SearchOutlined,
LockOutlined,
UnlockOutlined,
+ NotificationOutlined,
} from '@ant-design/icons';
const { Text } = Typography;
@@ -45,6 +46,7 @@ const nodeColors: Record =
// Icon mapping for known action types
const actionIcons: Record = {
send_email: ,
+ send_notification: ,
api_call: ,
ip_lookup: ,
hash_lookup: ,
diff --git a/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx b/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx
index a75e3c3..832378b 100644
--- a/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx
+++ b/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx
@@ -83,6 +83,7 @@ import type {
WorkflowEdge,
SavedWorkflowNode,
TicketWorkflowBinding,
+ WorkflowWritePayload,
} from 'services/workflows';
import type { InterfaceEndpoint } from 'services/interfaces';
import { nodeTypes } from './CustomNodes';
@@ -1093,7 +1094,7 @@ const VisualWorkflowEditor: React.FC = ({
const steps = nodesToSteps(nodes, sanitizedEdges);
const workflowEdges = flowEdgesToWorkflowEdges(sanitizedEdges);
- const payload: Partial = {
+ const payload: WorkflowWritePayload = {
name: values.name,
description: values.description || '',
trigger_type: values.trigger_type,
diff --git a/frontend/src/services/workflows/index.ts b/frontend/src/services/workflows/index.ts
index 08ab176..819865a 100644
--- a/frontend/src/services/workflows/index.ts
+++ b/frontend/src/services/workflows/index.ts
@@ -7,6 +7,7 @@ export {
type StepExecution,
type ActionInfo,
type WorkflowStats,
+ type WorkflowWritePayload,
type SavedWorkflowNode,
type TicketWorkflowBinding,