kailash 0.4.0__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,499 @@
1
+ """Discord alert node for the Kailash SDK.
2
+
3
+ This module implements Discord webhook integration for sending alerts and notifications
4
+ through Discord channels. It supports both simple text messages and rich embeds with
5
+ full customization options.
6
+
7
+ The Discord node provides:
8
+ - Webhook-based messaging (no bot token required)
9
+ - Rich embed support with colors and fields
10
+ - User and role mentions
11
+ - Thread support
12
+ - Rate limiting compliance
13
+ - Retry logic for failed requests
14
+ """
15
+
16
+ import os
17
+ import re
18
+ import time
19
+ from collections import deque
20
+ from datetime import UTC, datetime
21
+ from typing import Any, Optional
22
+
23
+ from kailash.nodes.alerts.base import AlertNode, AlertSeverity
24
+ from kailash.nodes.api.http import HTTPRequestNode
25
+ from kailash.nodes.base import NodeParameter, register_node
26
+ from kailash.sdk_exceptions import NodeExecutionError
27
+
28
+
29
+ class DiscordRateLimiter:
30
+ """Simple rate limiter for Discord webhooks.
31
+
32
+ Discord limits webhooks to 30 requests per minute per webhook.
33
+ This implements a sliding window rate limiter.
34
+ """
35
+
36
+ def __init__(self, max_requests: int = 30, window_seconds: int = 60):
37
+ self.max_requests = max_requests
38
+ self.window_seconds = window_seconds
39
+ self.requests = deque()
40
+
41
+ def acquire(self) -> float:
42
+ """Wait if necessary to comply with rate limits.
43
+
44
+ Returns:
45
+ Seconds waited (0 if no wait was needed)
46
+ """
47
+ now = time.time()
48
+
49
+ # Remove old requests outside the window
50
+ while self.requests and self.requests[0] < now - self.window_seconds:
51
+ self.requests.popleft()
52
+
53
+ # Check if we need to wait
54
+ if len(self.requests) >= self.max_requests:
55
+ # Calculate how long to wait
56
+ oldest_request = self.requests[0]
57
+ wait_time = (
58
+ (oldest_request + self.window_seconds) - now + 0.1
59
+ ) # Small buffer
60
+ if wait_time > 0:
61
+ time.sleep(wait_time)
62
+ return wait_time
63
+
64
+ # Record this request
65
+ self.requests.append(now)
66
+ return 0.0
67
+
68
+
69
+ @register_node()
70
+ class DiscordAlertNode(AlertNode):
71
+ """
72
+ Node for sending alerts to Discord channels via webhooks.
73
+
74
+ This node provides a streamlined interface for sending notifications to Discord
75
+ using webhook URLs. It supports Discord's rich embed format, allowing for
76
+ visually appealing alerts with colors, fields, timestamps, and more.
77
+
78
+ Design Philosophy:
79
+ The DiscordAlertNode abstracts Discord's webhook API complexity while
80
+ providing access to advanced features when needed. It handles rate limiting,
81
+ retries, and formatting automatically, allowing users to focus on their
82
+ alert content rather than Discord API specifics.
83
+
84
+ Features:
85
+ - Simple text messages or rich embeds
86
+ - Automatic color coding based on alert severity
87
+ - User/role/channel mentions with proper escaping
88
+ - Thread support for organized discussions
89
+ - Environment variable support for webhook URLs
90
+ - Built-in rate limiting (30 requests/minute)
91
+ - Retry logic with exponential backoff
92
+ - Context data formatting as embed fields
93
+
94
+ Webhook URL Security:
95
+ Webhook URLs should be treated as secrets. This node supports:
96
+ - Environment variable substitution (e.g., ${DISCORD_WEBHOOK})
97
+ - Direct URL input (use with caution in production)
98
+ - Configuration-based URLs via workflow parameters
99
+
100
+ Examples:
101
+ >>> # Simple text alert
102
+ >>> node = DiscordAlertNode()
103
+ >>> result = node.run(
104
+ ... webhook_url="${DISCORD_ALERTS_WEBHOOK}",
105
+ ... title="Deployment Complete",
106
+ ... message="Version 1.2.3 deployed successfully",
107
+ ... alert_type="success"
108
+ ... )
109
+ >>>
110
+ >>> # Rich embed with context
111
+ >>> result = node.run(
112
+ ... webhook_url=webhook_url,
113
+ ... title="Error in Data Pipeline",
114
+ ... message="Failed to process customer data",
115
+ ... alert_type="error",
116
+ ... context={
117
+ ... "Pipeline": "CustomerETL",
118
+ ... "Stage": "Transform",
119
+ ... "Error": "Invalid date format",
120
+ ... "Affected Records": 42
121
+ ... },
122
+ ... embed=True
123
+ ... )
124
+ >>>
125
+ >>> # Mention users and post to thread
126
+ >>> result = node.run(
127
+ ... webhook_url=webhook_url,
128
+ ... title="Critical: Database Connection Lost",
129
+ ... alert_type="critical",
130
+ ... mentions=["@everyone"],
131
+ ... thread_id="1234567890",
132
+ ... embed=True
133
+ ... )
134
+ """
135
+
136
+ def __init__(self, **kwargs):
137
+ """Initialize the Discord alert node.
138
+
139
+ Args:
140
+ **kwargs: Node configuration parameters
141
+ """
142
+ super().__init__(**kwargs)
143
+ self.http_client = HTTPRequestNode(id=f"{self.id}_http")
144
+ self.rate_limiter = DiscordRateLimiter()
145
+
146
+ def get_channel_parameters(self) -> dict[str, NodeParameter]:
147
+ """Define Discord-specific parameters.
148
+
149
+ Returns:
150
+ Dictionary of Discord webhook parameters
151
+ """
152
+ return {
153
+ "webhook_url": NodeParameter(
154
+ name="webhook_url",
155
+ type=str,
156
+ required=True,
157
+ description="Discord webhook URL (supports ${ENV_VAR} substitution)",
158
+ ),
159
+ "username": NodeParameter(
160
+ name="username",
161
+ type=str,
162
+ required=False,
163
+ default=None,
164
+ description="Override webhook bot username",
165
+ ),
166
+ "avatar_url": NodeParameter(
167
+ name="avatar_url",
168
+ type=str,
169
+ required=False,
170
+ default=None,
171
+ description="Override webhook bot avatar URL",
172
+ ),
173
+ "embed": NodeParameter(
174
+ name="embed",
175
+ type=bool,
176
+ required=False,
177
+ default=True,
178
+ description="Send as rich embed (True) or plain text (False)",
179
+ ),
180
+ "color": NodeParameter(
181
+ name="color",
182
+ type=int,
183
+ required=False,
184
+ default=None,
185
+ description="Override embed color (decimal color value)",
186
+ ),
187
+ "fields": NodeParameter(
188
+ name="fields",
189
+ type=list,
190
+ required=False,
191
+ default=[],
192
+ description="Additional embed fields as list of {name, value, inline} dicts",
193
+ ),
194
+ "mentions": NodeParameter(
195
+ name="mentions",
196
+ type=list,
197
+ required=False,
198
+ default=[],
199
+ description="List of mentions (@everyone, @here, user/role IDs)",
200
+ ),
201
+ "thread_id": NodeParameter(
202
+ name="thread_id",
203
+ type=str,
204
+ required=False,
205
+ default=None,
206
+ description="Thread ID to post message in",
207
+ ),
208
+ "footer_text": NodeParameter(
209
+ name="footer_text",
210
+ type=str,
211
+ required=False,
212
+ default=None,
213
+ description="Footer text for embeds",
214
+ ),
215
+ "timestamp": NodeParameter(
216
+ name="timestamp",
217
+ type=bool,
218
+ required=False,
219
+ default=True,
220
+ description="Include timestamp in embed",
221
+ ),
222
+ }
223
+
224
+ def resolve_webhook_url(self, webhook_url: str) -> str:
225
+ """Resolve webhook URL, supporting environment variable substitution.
226
+
227
+ Args:
228
+ webhook_url: Raw webhook URL or environment variable reference
229
+
230
+ Returns:
231
+ Resolved webhook URL
232
+
233
+ Raises:
234
+ ValueError: If environment variable is not set
235
+ """
236
+ # Check for environment variable pattern ${VAR_NAME}
237
+ env_pattern = r"\$\{([^}]+)\}"
238
+ match = re.match(env_pattern, webhook_url.strip())
239
+
240
+ if match:
241
+ var_name = match.group(1)
242
+ resolved_url = os.environ.get(var_name)
243
+ if not resolved_url:
244
+ raise ValueError(
245
+ f"Environment variable '{var_name}' is not set. "
246
+ f"Please set it or provide the webhook URL directly."
247
+ )
248
+ return resolved_url
249
+
250
+ return webhook_url
251
+
252
+ def format_mentions(self, mentions: list[str]) -> str:
253
+ """Format mention list into Discord mention string.
254
+
255
+ Args:
256
+ mentions: List of mention strings
257
+
258
+ Returns:
259
+ Formatted mention string
260
+ """
261
+ if not mentions:
262
+ return ""
263
+
264
+ formatted_mentions = []
265
+ for mention in mentions:
266
+ # Handle special mentions
267
+ if mention in ["@everyone", "@here"]:
268
+ formatted_mentions.append(mention)
269
+ # Handle user mentions (numeric IDs)
270
+ elif mention.isdigit():
271
+ formatted_mentions.append(f"<@{mention}>")
272
+ # Handle role mentions (numeric IDs with & prefix)
273
+ elif mention.startswith("&") and mention[1:].isdigit():
274
+ formatted_mentions.append(f"<@{mention}>")
275
+ # Pass through already formatted mentions
276
+ elif mention.startswith("<@") and mention.endswith(">"):
277
+ formatted_mentions.append(mention)
278
+ else:
279
+ # Assume it's a user ID
280
+ formatted_mentions.append(f"<@{mention}>")
281
+
282
+ return " ".join(formatted_mentions) + " "
283
+
284
+ def build_embed(
285
+ self,
286
+ severity: AlertSeverity,
287
+ title: str,
288
+ message: str,
289
+ context: dict[str, Any],
290
+ color: Optional[int],
291
+ fields: list[dict[str, Any]],
292
+ footer_text: Optional[str],
293
+ timestamp: bool,
294
+ ) -> dict[str, Any]:
295
+ """Build a Discord embed object.
296
+
297
+ Args:
298
+ severity: Alert severity for color
299
+ title: Embed title
300
+ message: Embed description
301
+ context: Context data to add as fields
302
+ color: Override color
303
+ fields: Additional fields
304
+ footer_text: Footer text
305
+ timestamp: Whether to include timestamp
306
+
307
+ Returns:
308
+ Discord embed object
309
+ """
310
+ embed = {
311
+ "title": title,
312
+ "color": color if color is not None else severity.get_color(),
313
+ }
314
+
315
+ if message:
316
+ embed["description"] = message
317
+
318
+ # Add context as fields
319
+ embed_fields = []
320
+ if context:
321
+ for key, value in context.items():
322
+ # Convert value to string, truncate if needed
323
+ value_str = str(value)
324
+ if len(value_str) > 1024: # Discord field value limit
325
+ value_str = value_str[:1021] + "..."
326
+
327
+ embed_fields.append({"name": key, "value": value_str, "inline": True})
328
+
329
+ # Add custom fields
330
+ for field in fields:
331
+ if isinstance(field, dict) and "name" in field and "value" in field:
332
+ embed_fields.append(
333
+ {
334
+ "name": str(field["name"]),
335
+ "value": str(field["value"]),
336
+ "inline": field.get("inline", True),
337
+ }
338
+ )
339
+
340
+ if embed_fields:
341
+ embed["fields"] = embed_fields[:25] # Discord limit: 25 fields
342
+
343
+ # Add footer
344
+ if footer_text:
345
+ embed["footer"] = {"text": footer_text}
346
+
347
+ # Add timestamp
348
+ if timestamp:
349
+ embed["timestamp"] = datetime.now(UTC).isoformat()
350
+
351
+ return embed
352
+
353
+ def send_alert(
354
+ self,
355
+ severity: AlertSeverity,
356
+ title: str,
357
+ message: str,
358
+ context: dict[str, Any],
359
+ **kwargs,
360
+ ) -> dict[str, Any]:
361
+ """Send alert to Discord via webhook.
362
+
363
+ Args:
364
+ severity: Alert severity
365
+ title: Alert title
366
+ message: Alert message
367
+ context: Context data
368
+ **kwargs: Discord-specific parameters
369
+
370
+ Returns:
371
+ Dictionary with response data
372
+
373
+ Raises:
374
+ NodeExecutionError: If webhook request fails after retries
375
+ """
376
+ # Extract Discord parameters
377
+ webhook_url = self.resolve_webhook_url(kwargs["webhook_url"])
378
+ username = kwargs.get("username")
379
+ avatar_url = kwargs.get("avatar_url")
380
+ use_embed = kwargs.get("embed", True)
381
+ color = kwargs.get("color")
382
+ fields = kwargs.get("fields", [])
383
+ mentions = kwargs.get("mentions", [])
384
+ thread_id = kwargs.get("thread_id")
385
+ footer_text = kwargs.get("footer_text")
386
+ timestamp = kwargs.get("timestamp", True)
387
+
388
+ # Build payload
389
+ payload = {}
390
+
391
+ # Add username and avatar if provided
392
+ if username:
393
+ payload["username"] = username
394
+ if avatar_url:
395
+ payload["avatar_url"] = avatar_url
396
+
397
+ # Format mentions
398
+ mention_str = self.format_mentions(mentions)
399
+
400
+ if use_embed:
401
+ # Rich embed format
402
+ embed = self.build_embed(
403
+ severity, title, message, context, color, fields, footer_text, timestamp
404
+ )
405
+ payload["embeds"] = [embed]
406
+
407
+ # Add mentions as content if present
408
+ if mention_str:
409
+ payload["content"] = mention_str.strip()
410
+ else:
411
+ # Plain text format
412
+ content_parts = []
413
+ if mention_str:
414
+ content_parts.append(mention_str.strip())
415
+
416
+ # Format as bold title with message
417
+ content_parts.append(f"**{title}**")
418
+ if message:
419
+ content_parts.append(message)
420
+
421
+ # Add context as formatted text
422
+ if context:
423
+ content_parts.append("\n" + self.format_context(context))
424
+
425
+ payload["content"] = "\n".join(content_parts)
426
+
427
+ # Handle thread posting
428
+ url = webhook_url
429
+ if thread_id:
430
+ url = f"{webhook_url}?thread_id={thread_id}"
431
+
432
+ # Apply rate limiting
433
+ wait_time = self.rate_limiter.acquire()
434
+
435
+ # Send with retry logic
436
+ max_retries = 3
437
+ retry_delay = 1.0
438
+
439
+ for attempt in range(max_retries):
440
+ try:
441
+ response = self.http_client.run(
442
+ url=url,
443
+ method="POST",
444
+ json_data=payload,
445
+ headers={"Content-Type": "application/json"},
446
+ timeout=30,
447
+ )
448
+
449
+ # Check response
450
+ if response["status_code"] == 204:
451
+ # Success
452
+ return {
453
+ "success": True,
454
+ "status_code": response["status_code"],
455
+ "wait_time": wait_time,
456
+ "attempt": attempt + 1,
457
+ "webhook_url": webhook_url.split("?")[
458
+ 0
459
+ ], # Remove token for security
460
+ "thread_id": thread_id,
461
+ }
462
+ elif response["status_code"] == 429:
463
+ # Rate limited - wait and retry
464
+ retry_after = float(
465
+ response.get("headers", {}).get(
466
+ "X-RateLimit-Reset-After", retry_delay
467
+ )
468
+ )
469
+ time.sleep(retry_after)
470
+ retry_delay *= 2
471
+ else:
472
+ # Other error
473
+ error_msg = (
474
+ f"Discord webhook returned status {response['status_code']}"
475
+ )
476
+ if response.get("content"):
477
+ error_msg += f": {response['content']}"
478
+
479
+ if attempt < max_retries - 1:
480
+ time.sleep(retry_delay)
481
+ retry_delay *= 2
482
+ continue
483
+ else:
484
+ raise NodeExecutionError(error_msg)
485
+
486
+ except Exception as e:
487
+ if attempt < max_retries - 1:
488
+ self.logger.warning(
489
+ f"Discord webhook attempt {attempt + 1} failed: {e}"
490
+ )
491
+ time.sleep(retry_delay)
492
+ retry_delay *= 2
493
+ else:
494
+ raise NodeExecutionError(
495
+ f"Failed to send Discord alert after {max_retries} attempts: {e}"
496
+ ) from e
497
+
498
+ # Should not reach here
499
+ raise NodeExecutionError("Failed to send Discord alert: Max retries exceeded")
@@ -160,8 +160,8 @@ class KafkaConsumerNode(Node):
160
160
  self._consumer = None
161
161
  self._topic = None
162
162
 
163
- # Call parent constructor
164
- super().__init__(name=self.name)
163
+ # Call parent constructor with all kwargs
164
+ super().__init__(name=self.name, **kwargs)
165
165
 
166
166
  def get_parameters(self) -> dict[str, NodeParameter]:
167
167
  """Define parameters for the Kafka consumer node."""
@@ -447,8 +447,8 @@ class StreamPublisherNode(Node):
447
447
  self._publisher = None
448
448
  self._protocol = None
449
449
 
450
- # Call parent constructor
451
- super().__init__(name=self.name)
450
+ # Call parent constructor with all kwargs
451
+ super().__init__(name=self.name, **kwargs)
452
452
 
453
453
  def get_parameters(self) -> dict[str, NodeParameter]:
454
454
  """Define parameters for the stream publisher node."""
@@ -725,8 +725,8 @@ class WebSocketNode(Node):
725
725
  self._connected = False
726
726
  self._message_queue = []
727
727
 
728
- # Call parent constructor
729
- super().__init__(name=self.name)
728
+ # Call parent constructor with all kwargs
729
+ super().__init__(name=self.name, **kwargs)
730
730
 
731
731
  def get_parameters(self) -> dict[str, NodeParameter]:
732
732
  """Get the parameters for this node.
@@ -1051,8 +1051,8 @@ class EventStreamNode(Node):
1051
1051
  self._connected = False
1052
1052
  self._last_event_id = None
1053
1053
 
1054
- # Call parent constructor
1055
- super().__init__(name=self.name)
1054
+ # Call parent constructor with all kwargs
1055
+ super().__init__(name=self.name, **kwargs)
1056
1056
 
1057
1057
  def get_parameters(self) -> dict[str, NodeParameter]:
1058
1058
  """Get the parameters for this node.
@@ -33,59 +33,71 @@ class AuditLogNode(Node):
33
33
  self.logger.setLevel(level)
34
34
 
35
35
  def get_parameters(self) -> Dict[str, NodeParameter]:
36
+ """Define parameters for audit logging."""
36
37
  return {
37
- "action": NodeParameter(
38
- name="action",
38
+ "event_data": NodeParameter(
39
+ name="event_data",
40
+ type=dict,
41
+ description="Event data to log",
42
+ default=None,
43
+ ),
44
+ "event_type": NodeParameter(
45
+ name="event_type",
39
46
  type=str,
40
- required=True,
41
- description="The action being audited",
47
+ description="Type of event being logged",
48
+ default="info",
42
49
  ),
43
50
  "user_id": NodeParameter(
44
51
  name="user_id",
45
52
  type=str,
46
- required=False,
47
- description="User performing the action",
53
+ description="ID of user associated with event",
54
+ default=None,
48
55
  ),
49
- "details": NodeParameter(
50
- name="details",
51
- type=dict,
52
- required=False,
53
- description="Additional audit details",
56
+ "message": NodeParameter(
57
+ name="message",
58
+ type=str,
59
+ description="Log message",
60
+ default="",
54
61
  ),
55
62
  }
56
63
 
57
- def process(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
58
- """Process audit log entry."""
64
+ def execute(self, **inputs) -> Dict[str, Any]:
65
+ """Execute audit logging."""
66
+ event_data = inputs.get("event_data", {})
67
+ event_type = inputs.get("event_type", "info")
68
+ user_id = inputs.get("user_id")
69
+ message = inputs.get("message", "")
59
70
 
71
+ # Create audit entry
60
72
  audit_entry = {
61
- "action": inputs.get("action"),
62
- "user_id": inputs.get("user_id"),
63
- "details": inputs.get("details", {}),
73
+ "event_type": event_type,
74
+ "message": message,
75
+ "user_id": user_id,
76
+ "data": event_data,
64
77
  }
65
78
 
66
79
  if self.include_timestamp:
67
80
  audit_entry["timestamp"] = datetime.now(timezone.utc).isoformat()
68
81
 
69
- # Log the audit entry
82
+ # Log the event
70
83
  if self.output_format == "json":
71
- self.logger.info(json.dumps(audit_entry))
84
+ log_message = json.dumps(audit_entry)
72
85
  else:
73
- self.logger.info(f"AUDIT: {audit_entry}")
74
-
75
- return {"audit_logged": True, "entry": audit_entry}
76
-
77
- async def aprocess(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
78
- """Async version that just calls the sync version."""
79
- return self.process(inputs)
80
-
81
- def run(self, **kwargs) -> Dict[str, Any]:
82
- """Alias for process method."""
83
- return self.process(kwargs)
84
-
85
- def execute(self, **kwargs) -> Dict[str, Any]:
86
- """Alias for process method."""
87
- return self.process(kwargs)
86
+ log_message = (
87
+ f"[{event_type}] {message} - User: {user_id} - Data: {event_data}"
88
+ )
89
+
90
+ # Use appropriate log level
91
+ if event_type in ["error", "critical"]:
92
+ self.logger.error(log_message)
93
+ elif event_type == "warning":
94
+ self.logger.warning(log_message)
95
+ else:
96
+ self.logger.info(log_message)
88
97
 
89
- async def async_run(self, **kwargs) -> Dict[str, Any]:
90
- """Async execution method for enterprise integration."""
91
- return self.run(**kwargs)
98
+ return {
99
+ "audit_entry": audit_entry,
100
+ "logged": True,
101
+ "log_level": event_type,
102
+ "timestamp": audit_entry.get("timestamp"),
103
+ }