TradingAgents/tradingagents/alerts/slack_channel.py

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)