"""Notification System Provides webhook-based notifications for Slack, Discord, and other platforms. Supports critical security findings, review summaries, and custom alerts. """ import logging import os from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum from typing import Any import requests class NotificationLevel(Enum): """Notification severity levels.""" INFO = "info" WARNING = "warning" ERROR = "error" CRITICAL = "critical" @dataclass class NotificationMessage: """A notification message.""" title: str message: str level: NotificationLevel = NotificationLevel.INFO fields: dict[str, str] | None = None url: str | None = None footer: str | None = None class Notifier(ABC): """Abstract base class for notification providers.""" @abstractmethod def send(self, message: NotificationMessage) -> bool: """Send a notification. Args: message: The notification message to send. Returns: True if sent successfully, False otherwise. """ pass @abstractmethod def send_raw(self, payload: dict) -> bool: """Send a raw payload to the webhook. Args: payload: Raw payload in provider-specific format. Returns: True if sent successfully, False otherwise. """ pass class SlackNotifier(Notifier): """Slack webhook notifier.""" # Color mapping for different levels LEVEL_COLORS = { NotificationLevel.INFO: "#36a64f", # Green NotificationLevel.WARNING: "#ffcc00", # Yellow NotificationLevel.ERROR: "#ff6600", # Orange NotificationLevel.CRITICAL: "#cc0000", # Red } LEVEL_EMOJIS = { NotificationLevel.INFO: ":information_source:", NotificationLevel.WARNING: ":warning:", NotificationLevel.ERROR: ":x:", NotificationLevel.CRITICAL: ":rotating_light:", } def __init__( self, webhook_url: str | None = None, channel: str | None = None, username: str = "OpenRabbit", icon_emoji: str = ":robot_face:", timeout: int = 10, ): """Initialize Slack notifier. Args: webhook_url: Slack incoming webhook URL. Defaults to SLACK_WEBHOOK_URL env var. channel: Override channel (optional). username: Bot username to display. icon_emoji: Bot icon emoji. timeout: Request timeout in seconds. """ self.webhook_url = webhook_url or os.environ.get("SLACK_WEBHOOK_URL", "") self.channel = channel self.username = username self.icon_emoji = icon_emoji self.timeout = timeout self.logger = logging.getLogger(__name__) def send(self, message: NotificationMessage) -> bool: """Send a notification to Slack.""" if not self.webhook_url: self.logger.warning("Slack webhook URL not configured") return False # Build attachment attachment = { "color": self.LEVEL_COLORS.get(message.level, "#36a64f"), "title": f"{self.LEVEL_EMOJIS.get(message.level, '')} {message.title}", "text": message.message, "mrkdwn_in": ["text", "fields"], } if message.url: attachment["title_link"] = message.url if message.fields: attachment["fields"] = [ {"title": k, "value": v, "short": len(v) < 40} for k, v in message.fields.items() ] if message.footer: attachment["footer"] = message.footer attachment["footer_icon"] = "https://github.com/favicon.ico" payload = { "username": self.username, "icon_emoji": self.icon_emoji, "attachments": [attachment], } if self.channel: payload["channel"] = self.channel return self.send_raw(payload) def send_raw(self, payload: dict) -> bool: """Send raw payload to Slack webhook.""" if not self.webhook_url: return False try: response = requests.post( self.webhook_url, json=payload, timeout=self.timeout, ) response.raise_for_status() return True except requests.RequestException as e: self.logger.error(f"Failed to send Slack notification: {e}") return False class DiscordNotifier(Notifier): """Discord webhook notifier.""" # Color mapping for different levels (Discord uses decimal colors) LEVEL_COLORS = { NotificationLevel.INFO: 3066993, # Green NotificationLevel.WARNING: 16776960, # Yellow NotificationLevel.ERROR: 16744448, # Orange NotificationLevel.CRITICAL: 13369344, # Red } def __init__( self, webhook_url: str | None = None, username: str = "OpenRabbit", avatar_url: str | None = None, timeout: int = 10, ): """Initialize Discord notifier. Args: webhook_url: Discord webhook URL. Defaults to DISCORD_WEBHOOK_URL env var. username: Bot username to display. avatar_url: Bot avatar URL. timeout: Request timeout in seconds. """ self.webhook_url = webhook_url or os.environ.get("DISCORD_WEBHOOK_URL", "") self.username = username self.avatar_url = avatar_url self.timeout = timeout self.logger = logging.getLogger(__name__) def send(self, message: NotificationMessage) -> bool: """Send a notification to Discord.""" if not self.webhook_url: self.logger.warning("Discord webhook URL not configured") return False # Build embed embed = { "title": message.title, "description": message.message, "color": self.LEVEL_COLORS.get(message.level, 3066993), } if message.url: embed["url"] = message.url if message.fields: embed["fields"] = [ {"name": k, "value": v, "inline": len(v) < 40} for k, v in message.fields.items() ] if message.footer: embed["footer"] = {"text": message.footer} payload = { "username": self.username, "embeds": [embed], } if self.avatar_url: payload["avatar_url"] = self.avatar_url return self.send_raw(payload) def send_raw(self, payload: dict) -> bool: """Send raw payload to Discord webhook.""" if not self.webhook_url: return False try: response = requests.post( self.webhook_url, json=payload, timeout=self.timeout, ) response.raise_for_status() return True except requests.RequestException as e: self.logger.error(f"Failed to send Discord notification: {e}") return False class WebhookNotifier(Notifier): """Generic webhook notifier for custom integrations.""" def __init__( self, webhook_url: str, headers: dict[str, str] | None = None, timeout: int = 10, ): """Initialize generic webhook notifier. Args: webhook_url: Webhook URL. headers: Custom headers to include. timeout: Request timeout in seconds. """ self.webhook_url = webhook_url self.headers = headers or {"Content-Type": "application/json"} self.timeout = timeout self.logger = logging.getLogger(__name__) def send(self, message: NotificationMessage) -> bool: """Send a notification as JSON payload.""" payload = { "title": message.title, "message": message.message, "level": message.level.value, "fields": message.fields or {}, "url": message.url, "footer": message.footer, } return self.send_raw(payload) def send_raw(self, payload: dict) -> bool: """Send raw payload to webhook.""" try: response = requests.post( self.webhook_url, json=payload, headers=self.headers, timeout=self.timeout, ) response.raise_for_status() return True except requests.RequestException as e: self.logger.error(f"Failed to send webhook notification: {e}") return False class NotifierFactory: """Factory for creating notifier instances from config.""" @staticmethod def create_from_config(config: dict) -> list[Notifier]: """Create notifier instances from configuration. Args: config: Configuration dictionary with 'notifications' section. Returns: List of configured notifier instances. """ notifiers = [] notifications_config = config.get("notifications", {}) if not notifications_config.get("enabled", False): return notifiers # Slack slack_config = notifications_config.get("slack", {}) if slack_config.get("enabled", False): webhook_url = slack_config.get("webhook_url") or os.environ.get( "SLACK_WEBHOOK_URL" ) if webhook_url: notifiers.append( SlackNotifier( webhook_url=webhook_url, channel=slack_config.get("channel"), username=slack_config.get("username", "OpenRabbit"), ) ) # Discord discord_config = notifications_config.get("discord", {}) if discord_config.get("enabled", False): webhook_url = discord_config.get("webhook_url") or os.environ.get( "DISCORD_WEBHOOK_URL" ) if webhook_url: notifiers.append( DiscordNotifier( webhook_url=webhook_url, username=discord_config.get("username", "OpenRabbit"), avatar_url=discord_config.get("avatar_url"), ) ) # Generic webhooks webhooks_config = notifications_config.get("webhooks", []) for webhook in webhooks_config: if webhook.get("enabled", True) and webhook.get("url"): notifiers.append( WebhookNotifier( webhook_url=webhook["url"], headers=webhook.get("headers"), ) ) return notifiers @staticmethod def should_notify( level: NotificationLevel, config: dict, ) -> bool: """Check if notification should be sent based on level threshold. Args: level: Notification level. config: Configuration dictionary. Returns: True if notification should be sent. """ notifications_config = config.get("notifications", {}) if not notifications_config.get("enabled", False): return False threshold = notifications_config.get("threshold", "warning") level_order = ["info", "warning", "error", "critical"] try: threshold_idx = level_order.index(threshold) level_idx = level_order.index(level.value) return level_idx >= threshold_idx except ValueError: return True class NotificationService: """High-level notification service for sending alerts.""" def __init__(self, config: dict): """Initialize notification service. Args: config: Configuration dictionary. """ self.config = config self.notifiers = NotifierFactory.create_from_config(config) self.logger = logging.getLogger(__name__) def notify(self, message: NotificationMessage) -> bool: """Send notification to all configured notifiers. Args: message: Notification message. Returns: True if at least one notification succeeded. """ if not NotifierFactory.should_notify(message.level, self.config): return True # Not an error, just below threshold if not self.notifiers: self.logger.debug("No notifiers configured") return True success = False for notifier in self.notifiers: try: if notifier.send(message): success = True except Exception as e: self.logger.error(f"Notifier failed: {e}") return success def notify_security_finding( self, repo: str, pr_number: int, finding: dict, pr_url: str | None = None, ) -> bool: """Send notification for a security finding. Args: repo: Repository name (owner/repo). pr_number: Pull request number. finding: Security finding dict with severity, description, etc. pr_url: URL to the pull request. Returns: True if notification succeeded. """ severity = finding.get("severity", "MEDIUM").upper() level_map = { "HIGH": NotificationLevel.CRITICAL, "MEDIUM": NotificationLevel.WARNING, "LOW": NotificationLevel.INFO, } level = level_map.get(severity, NotificationLevel.WARNING) message = NotificationMessage( title=f"Security Finding in {repo} PR #{pr_number}", message=finding.get("description", "Security issue detected"), level=level, fields={ "Severity": severity, "Category": finding.get("category", "Unknown"), "File": finding.get("file", "N/A"), "Line": str(finding.get("line", "N/A")), }, url=pr_url, footer=f"CWE: {finding.get('cwe', 'N/A')}", ) return self.notify(message) def notify_review_complete( self, repo: str, pr_number: int, summary: dict, pr_url: str | None = None, ) -> bool: """Send notification for completed review. Args: repo: Repository name (owner/repo). pr_number: Pull request number. summary: Review summary dict. pr_url: URL to the pull request. Returns: True if notification succeeded. """ recommendation = summary.get("recommendation", "COMMENT") level_map = { "APPROVE": NotificationLevel.INFO, "COMMENT": NotificationLevel.INFO, "REQUEST_CHANGES": NotificationLevel.WARNING, } level = level_map.get(recommendation, NotificationLevel.INFO) high_issues = summary.get("high_severity_count", 0) if high_issues > 0: level = NotificationLevel.ERROR message = NotificationMessage( title=f"Review Complete: {repo} PR #{pr_number}", message=summary.get("summary", "AI review completed"), level=level, fields={ "Recommendation": recommendation, "High Severity": str(high_issues), "Medium Severity": str(summary.get("medium_severity_count", 0)), "Files Reviewed": str(summary.get("files_reviewed", 0)), }, url=pr_url, footer="OpenRabbit AI Review", ) return self.notify(message) def notify_error( self, repo: str, error: str, context: str | None = None, ) -> bool: """Send notification for an error. Args: repo: Repository name. error: Error message. context: Additional context. Returns: True if notification succeeded. """ message = NotificationMessage( title=f"Error in {repo}", message=error, level=NotificationLevel.ERROR, fields={"Context": context} if context else None, footer="OpenRabbit Error Alert", ) return self.notify(message)