680 lines
20 KiB
Python
680 lines
20 KiB
Python
"""Slack Channel for alert notifications.
|
|
|
|
This module provides Slack integration for alerts including:
|
|
- Webhook-based message delivery
|
|
- Rich message formatting with blocks
|
|
- Priority-based styling
|
|
- Rate limiting and retry logic
|
|
|
|
Issue #40: [ALERT-39] Slack channel - webhooks
|
|
|
|
Design Principles:
|
|
- Non-blocking async delivery
|
|
- Rich Slack Block Kit formatting
|
|
- Configurable webhook endpoints
|
|
- Graceful error handling
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from typing import Any, Dict, List, Optional
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
from .alert_manager import (
|
|
Alert,
|
|
AlertPriority,
|
|
AlertCategory,
|
|
ChannelType,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Logging Setup
|
|
# ============================================================================
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# Enums
|
|
# ============================================================================
|
|
|
|
class SlackMessageStyle(str, Enum):
|
|
"""Slack message styling options."""
|
|
SIMPLE = "simple" # Plain text message
|
|
BLOCKS = "blocks" # Rich Block Kit formatting
|
|
ATTACHMENT = "attachment" # Legacy attachment format
|
|
|
|
|
|
# ============================================================================
|
|
# Data Classes
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class SlackConfig:
|
|
"""Configuration for Slack channel.
|
|
|
|
Attributes:
|
|
webhook_url: Slack incoming webhook URL
|
|
channel: Override channel (optional)
|
|
username: Bot username (optional)
|
|
icon_emoji: Bot icon emoji (optional)
|
|
icon_url: Bot icon URL (optional)
|
|
style: Message formatting style
|
|
include_timestamp: Include timestamp in messages
|
|
include_source: Include alert source
|
|
mention_on_critical: Mention users for critical alerts
|
|
mention_users: Users to mention for critical alerts
|
|
retry_count: Number of retries on failure
|
|
retry_delay_seconds: Delay between retries
|
|
timeout_seconds: Request timeout
|
|
"""
|
|
webhook_url: str = ""
|
|
channel: Optional[str] = None
|
|
username: str = "TradingAgents Alert"
|
|
icon_emoji: str = ":chart_with_upwards_trend:"
|
|
icon_url: Optional[str] = None
|
|
style: SlackMessageStyle = SlackMessageStyle.BLOCKS
|
|
include_timestamp: bool = True
|
|
include_source: bool = True
|
|
mention_on_critical: bool = True
|
|
mention_users: List[str] = field(default_factory=list)
|
|
retry_count: int = 3
|
|
retry_delay_seconds: float = 1.0
|
|
timeout_seconds: int = 30
|
|
|
|
|
|
@dataclass
|
|
class SlackMessageResult:
|
|
"""Result of Slack message send.
|
|
|
|
Attributes:
|
|
success: Whether message was sent
|
|
status_code: HTTP status code
|
|
response_body: Response body
|
|
error_message: Error message if failed
|
|
attempts: Number of attempts made
|
|
latency_ms: Total latency in milliseconds
|
|
"""
|
|
success: bool = False
|
|
status_code: int = 0
|
|
response_body: str = ""
|
|
error_message: str = ""
|
|
attempts: int = 0
|
|
latency_ms: float = 0.0
|
|
|
|
|
|
# ============================================================================
|
|
# Slack Message Formatter
|
|
# ============================================================================
|
|
|
|
class SlackMessageFormatter:
|
|
"""Formats alerts for Slack messages."""
|
|
|
|
# Priority to color mapping
|
|
PRIORITY_COLORS = {
|
|
AlertPriority.LOW: "#36a64f", # Green
|
|
AlertPriority.MEDIUM: "#ffcc00", # Yellow
|
|
AlertPriority.HIGH: "#ff9900", # Orange
|
|
AlertPriority.CRITICAL: "#ff0000", # Red
|
|
}
|
|
|
|
# Priority to emoji mapping
|
|
PRIORITY_EMOJIS = {
|
|
AlertPriority.LOW: ":information_source:",
|
|
AlertPriority.MEDIUM: ":warning:",
|
|
AlertPriority.HIGH: ":exclamation:",
|
|
AlertPriority.CRITICAL: ":rotating_light:",
|
|
}
|
|
|
|
# Category to emoji mapping
|
|
CATEGORY_EMOJIS = {
|
|
AlertCategory.TRADE: ":chart_with_upwards_trend:",
|
|
AlertCategory.RISK: ":shield:",
|
|
AlertCategory.SYSTEM: ":gear:",
|
|
AlertCategory.MARKET: ":bar_chart:",
|
|
AlertCategory.PORTFOLIO: ":moneybag:",
|
|
AlertCategory.EXECUTION: ":zap:",
|
|
AlertCategory.COMPLIANCE: ":memo:",
|
|
}
|
|
|
|
@classmethod
|
|
def format_simple(cls, alert: Alert, config: SlackConfig) -> Dict[str, Any]:
|
|
"""Format alert as simple text message.
|
|
|
|
Args:
|
|
alert: Alert to format
|
|
config: Slack configuration
|
|
|
|
Returns:
|
|
Slack message payload
|
|
"""
|
|
emoji = cls.PRIORITY_EMOJIS.get(alert.priority, ":bell:")
|
|
category_emoji = cls.CATEGORY_EMOJIS.get(alert.category, ":bell:")
|
|
|
|
text_parts = [
|
|
f"{emoji} *{alert.title}*",
|
|
"",
|
|
alert.message,
|
|
]
|
|
|
|
if config.include_source and alert.source:
|
|
text_parts.append(f"_Source: {alert.source}_")
|
|
|
|
if config.include_timestamp:
|
|
text_parts.append(f"_Time: {alert.timestamp.strftime('%Y-%m-%d %H:%M:%S')}_")
|
|
|
|
# Add mention for critical alerts
|
|
if config.mention_on_critical and alert.priority == AlertPriority.CRITICAL:
|
|
mentions = " ".join(f"<@{user}>" for user in config.mention_users)
|
|
if mentions:
|
|
text_parts.insert(0, mentions)
|
|
|
|
payload: Dict[str, Any] = {
|
|
"text": "\n".join(text_parts),
|
|
}
|
|
|
|
if config.username:
|
|
payload["username"] = config.username
|
|
|
|
if config.icon_emoji:
|
|
payload["icon_emoji"] = config.icon_emoji
|
|
elif config.icon_url:
|
|
payload["icon_url"] = config.icon_url
|
|
|
|
if config.channel:
|
|
payload["channel"] = config.channel
|
|
|
|
return payload
|
|
|
|
@classmethod
|
|
def format_blocks(cls, alert: Alert, config: SlackConfig) -> Dict[str, Any]:
|
|
"""Format alert with Slack Block Kit.
|
|
|
|
Args:
|
|
alert: Alert to format
|
|
config: Slack configuration
|
|
|
|
Returns:
|
|
Slack message payload with blocks
|
|
"""
|
|
emoji = cls.PRIORITY_EMOJIS.get(alert.priority, ":bell:")
|
|
color = cls.PRIORITY_COLORS.get(alert.priority, "#808080")
|
|
category_emoji = cls.CATEGORY_EMOJIS.get(alert.category, ":bell:")
|
|
|
|
blocks = []
|
|
|
|
# Header section with critical mention
|
|
header_text = f"{emoji} *{alert.title}*"
|
|
if config.mention_on_critical and alert.priority == AlertPriority.CRITICAL:
|
|
mentions = " ".join(f"<@{user}>" for user in config.mention_users)
|
|
if mentions:
|
|
header_text = f"{mentions}\n{header_text}"
|
|
|
|
blocks.append({
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": header_text,
|
|
},
|
|
})
|
|
|
|
# Message body
|
|
blocks.append({
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": alert.message,
|
|
},
|
|
})
|
|
|
|
# Context fields
|
|
context_elements = []
|
|
|
|
# Category badge
|
|
context_elements.append({
|
|
"type": "mrkdwn",
|
|
"text": f"{category_emoji} *{alert.category.value.upper()}*",
|
|
})
|
|
|
|
# Priority badge
|
|
priority_text = {
|
|
AlertPriority.LOW: "LOW",
|
|
AlertPriority.MEDIUM: "MEDIUM",
|
|
AlertPriority.HIGH: "HIGH",
|
|
AlertPriority.CRITICAL: ":fire: CRITICAL :fire:",
|
|
}.get(alert.priority, "UNKNOWN")
|
|
|
|
context_elements.append({
|
|
"type": "mrkdwn",
|
|
"text": f"*Priority:* {priority_text}",
|
|
})
|
|
|
|
if config.include_source and alert.source:
|
|
context_elements.append({
|
|
"type": "mrkdwn",
|
|
"text": f"*Source:* {alert.source}",
|
|
})
|
|
|
|
if config.include_timestamp:
|
|
context_elements.append({
|
|
"type": "mrkdwn",
|
|
"text": f"*Time:* {alert.timestamp.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
})
|
|
|
|
if context_elements:
|
|
blocks.append({
|
|
"type": "context",
|
|
"elements": context_elements,
|
|
})
|
|
|
|
# Add divider
|
|
blocks.append({"type": "divider"})
|
|
|
|
# Add data fields if present
|
|
if alert.data:
|
|
fields_text = []
|
|
for key, value in list(alert.data.items())[:10]: # Limit to 10 fields
|
|
fields_text.append(f"*{key}:* {value}")
|
|
|
|
if fields_text:
|
|
blocks.append({
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": "\n".join(fields_text),
|
|
},
|
|
})
|
|
|
|
payload: Dict[str, Any] = {
|
|
"blocks": blocks,
|
|
"text": f"{emoji} {alert.title}", # Fallback text
|
|
}
|
|
|
|
# Add attachment for color
|
|
payload["attachments"] = [{
|
|
"color": color,
|
|
"blocks": blocks,
|
|
}]
|
|
# Remove blocks from top level when using attachments
|
|
del payload["blocks"]
|
|
|
|
if config.username:
|
|
payload["username"] = config.username
|
|
|
|
if config.icon_emoji:
|
|
payload["icon_emoji"] = config.icon_emoji
|
|
elif config.icon_url:
|
|
payload["icon_url"] = config.icon_url
|
|
|
|
if config.channel:
|
|
payload["channel"] = config.channel
|
|
|
|
return payload
|
|
|
|
@classmethod
|
|
def format_attachment(cls, alert: Alert, config: SlackConfig) -> Dict[str, Any]:
|
|
"""Format alert with legacy attachment format.
|
|
|
|
Args:
|
|
alert: Alert to format
|
|
config: Slack configuration
|
|
|
|
Returns:
|
|
Slack message payload with attachments
|
|
"""
|
|
emoji = cls.PRIORITY_EMOJIS.get(alert.priority, ":bell:")
|
|
color = cls.PRIORITY_COLORS.get(alert.priority, "#808080")
|
|
|
|
fields = []
|
|
|
|
fields.append({
|
|
"title": "Category",
|
|
"value": alert.category.value.upper(),
|
|
"short": True,
|
|
})
|
|
|
|
fields.append({
|
|
"title": "Priority",
|
|
"value": alert.priority.value.upper(),
|
|
"short": True,
|
|
})
|
|
|
|
if config.include_source and alert.source:
|
|
fields.append({
|
|
"title": "Source",
|
|
"value": alert.source,
|
|
"short": True,
|
|
})
|
|
|
|
if alert.tags:
|
|
fields.append({
|
|
"title": "Tags",
|
|
"value": ", ".join(alert.tags),
|
|
"short": True,
|
|
})
|
|
|
|
# Add data fields
|
|
for key, value in list(alert.data.items())[:6]:
|
|
fields.append({
|
|
"title": key,
|
|
"value": str(value),
|
|
"short": True,
|
|
})
|
|
|
|
attachment = {
|
|
"color": color,
|
|
"pretext": f"{emoji} *{alert.title}*",
|
|
"text": alert.message,
|
|
"fields": fields,
|
|
"footer": "TradingAgents Alert System",
|
|
"ts": int(alert.timestamp.timestamp()),
|
|
}
|
|
|
|
# Add mention for critical alerts
|
|
if config.mention_on_critical and alert.priority == AlertPriority.CRITICAL:
|
|
mentions = " ".join(f"<@{user}>" for user in config.mention_users)
|
|
if mentions:
|
|
attachment["pretext"] = f"{mentions}\n{attachment['pretext']}"
|
|
|
|
payload: Dict[str, Any] = {
|
|
"attachments": [attachment],
|
|
"text": f"{emoji} {alert.title}", # Fallback text
|
|
}
|
|
|
|
if config.username:
|
|
payload["username"] = config.username
|
|
|
|
if config.icon_emoji:
|
|
payload["icon_emoji"] = config.icon_emoji
|
|
elif config.icon_url:
|
|
payload["icon_url"] = config.icon_url
|
|
|
|
if config.channel:
|
|
payload["channel"] = config.channel
|
|
|
|
return payload
|
|
|
|
@classmethod
|
|
def format(cls, alert: Alert, config: SlackConfig) -> Dict[str, Any]:
|
|
"""Format alert based on configured style.
|
|
|
|
Args:
|
|
alert: Alert to format
|
|
config: Slack configuration
|
|
|
|
Returns:
|
|
Slack message payload
|
|
"""
|
|
if config.style == SlackMessageStyle.SIMPLE:
|
|
return cls.format_simple(alert, config)
|
|
elif config.style == SlackMessageStyle.BLOCKS:
|
|
return cls.format_blocks(alert, config)
|
|
elif config.style == SlackMessageStyle.ATTACHMENT:
|
|
return cls.format_attachment(alert, config)
|
|
else:
|
|
return cls.format_simple(alert, config)
|
|
|
|
|
|
# ============================================================================
|
|
# SlackChannel Class
|
|
# ============================================================================
|
|
|
|
class SlackChannel:
|
|
"""Slack channel for alert delivery.
|
|
|
|
Implements the AlertChannel protocol for integration
|
|
with AlertManager.
|
|
|
|
Attributes:
|
|
config: Slack channel configuration
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
webhook_url: Optional[str] = None,
|
|
config: Optional[SlackConfig] = None,
|
|
):
|
|
"""Initialize Slack channel.
|
|
|
|
Args:
|
|
webhook_url: Slack webhook URL (overrides config)
|
|
config: Full configuration (optional)
|
|
"""
|
|
self.config = config or SlackConfig()
|
|
|
|
if webhook_url:
|
|
self.config.webhook_url = webhook_url
|
|
|
|
self._formatter = SlackMessageFormatter()
|
|
|
|
@property
|
|
def channel_type(self) -> ChannelType:
|
|
"""Get channel type."""
|
|
return ChannelType.SLACK
|
|
|
|
@property
|
|
def is_available(self) -> bool:
|
|
"""Check if channel is available."""
|
|
return bool(self.config.webhook_url)
|
|
|
|
def validate_config(self) -> tuple[bool, str]:
|
|
"""Validate configuration.
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
if not self.config.webhook_url:
|
|
return False, "Webhook URL is required"
|
|
|
|
if not self.config.webhook_url.startswith("https://hooks.slack.com/"):
|
|
return False, "Invalid Slack webhook URL format"
|
|
|
|
return True, ""
|
|
|
|
async def send(self, alert: Alert) -> bool:
|
|
"""Send alert to Slack.
|
|
|
|
Args:
|
|
alert: Alert to send
|
|
|
|
Returns:
|
|
True if sent successfully
|
|
"""
|
|
result = await self.send_with_result(alert)
|
|
return result.success
|
|
|
|
async def send_with_result(self, alert: Alert) -> SlackMessageResult:
|
|
"""Send alert and return detailed result.
|
|
|
|
Args:
|
|
alert: Alert to send
|
|
|
|
Returns:
|
|
Detailed send result
|
|
"""
|
|
result = SlackMessageResult()
|
|
start_time = datetime.now()
|
|
|
|
if not self.is_available:
|
|
result.error_message = "Slack channel not configured"
|
|
return result
|
|
|
|
# Format message
|
|
payload = self._formatter.format(alert, self.config)
|
|
|
|
# Send with retries
|
|
for attempt in range(self.config.retry_count):
|
|
result.attempts = attempt + 1
|
|
|
|
try:
|
|
send_result = await self._send_webhook(payload)
|
|
result.success = send_result.get("success", False)
|
|
result.status_code = send_result.get("status_code", 0)
|
|
result.response_body = send_result.get("body", "")
|
|
|
|
if result.success:
|
|
break
|
|
|
|
# Check if retryable
|
|
if result.status_code >= 500:
|
|
# Server error - retry
|
|
if attempt < self.config.retry_count - 1:
|
|
await asyncio.sleep(self.config.retry_delay_seconds)
|
|
continue
|
|
elif result.status_code == 429:
|
|
# Rate limited - wait and retry
|
|
retry_after = send_result.get("retry_after", 5)
|
|
await asyncio.sleep(retry_after)
|
|
continue
|
|
else:
|
|
# Client error - don't retry
|
|
result.error_message = f"HTTP {result.status_code}: {result.response_body}"
|
|
break
|
|
|
|
except Exception as e:
|
|
result.error_message = str(e)
|
|
if attempt < self.config.retry_count - 1:
|
|
await asyncio.sleep(self.config.retry_delay_seconds)
|
|
|
|
result.latency_ms = (datetime.now() - start_time).total_seconds() * 1000
|
|
return result
|
|
|
|
async def _send_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Send payload to webhook.
|
|
|
|
Args:
|
|
payload: Message payload
|
|
|
|
Returns:
|
|
Result dict with success, status_code, body
|
|
"""
|
|
# Run synchronous HTTP call in executor for async compatibility
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(
|
|
None,
|
|
self._send_webhook_sync,
|
|
payload,
|
|
)
|
|
|
|
def _send_webhook_sync(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Send payload to webhook synchronously.
|
|
|
|
Args:
|
|
payload: Message payload
|
|
|
|
Returns:
|
|
Result dict with success, status_code, body
|
|
"""
|
|
try:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
|
|
req = urllib.request.Request(
|
|
self.config.webhook_url,
|
|
data=data,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
|
|
with urllib.request.urlopen(
|
|
req,
|
|
timeout=self.config.timeout_seconds,
|
|
) as response:
|
|
body = response.read().decode("utf-8")
|
|
return {
|
|
"success": response.status == 200,
|
|
"status_code": response.status,
|
|
"body": body,
|
|
}
|
|
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode("utf-8") if e.fp else ""
|
|
retry_after = e.headers.get("Retry-After", 5) if e.headers else 5
|
|
return {
|
|
"success": False,
|
|
"status_code": e.code,
|
|
"body": body,
|
|
"retry_after": int(retry_after),
|
|
}
|
|
|
|
except urllib.error.URLError as e:
|
|
return {
|
|
"success": False,
|
|
"status_code": 0,
|
|
"body": str(e.reason),
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"status_code": 0,
|
|
"body": str(e),
|
|
}
|
|
|
|
def send_sync(self, alert: Alert) -> SlackMessageResult:
|
|
"""Send alert synchronously.
|
|
|
|
Args:
|
|
alert: Alert to send
|
|
|
|
Returns:
|
|
Send result
|
|
"""
|
|
return asyncio.run(self.send_with_result(alert))
|
|
|
|
def test_webhook(self) -> SlackMessageResult:
|
|
"""Send test message to verify webhook.
|
|
|
|
Returns:
|
|
Send result
|
|
"""
|
|
test_alert = Alert(
|
|
title="Test Alert",
|
|
message="This is a test message from TradingAgents Alert System.",
|
|
priority=AlertPriority.LOW,
|
|
category=AlertCategory.SYSTEM,
|
|
source="slack_channel_test",
|
|
data={"test": True},
|
|
)
|
|
|
|
return self.send_sync(test_alert)
|
|
|
|
|
|
# ============================================================================
|
|
# Factory Functions
|
|
# ============================================================================
|
|
|
|
def create_slack_channel(
|
|
webhook_url: str,
|
|
channel: Optional[str] = None,
|
|
username: str = "TradingAgents Alert",
|
|
style: SlackMessageStyle = SlackMessageStyle.BLOCKS,
|
|
mention_users: Optional[List[str]] = None,
|
|
) -> SlackChannel:
|
|
"""Create a configured Slack channel.
|
|
|
|
Args:
|
|
webhook_url: Slack webhook URL
|
|
channel: Override channel name
|
|
username: Bot username
|
|
style: Message formatting style
|
|
mention_users: Users to mention for critical alerts
|
|
|
|
Returns:
|
|
Configured SlackChannel
|
|
"""
|
|
config = SlackConfig(
|
|
webhook_url=webhook_url,
|
|
channel=channel,
|
|
username=username,
|
|
style=style,
|
|
mention_users=mention_users or [],
|
|
)
|
|
return SlackChannel(config=config)
|