| import json |
| import re |
|
|
| import httpx |
|
|
| from agent.messaging.base import ( |
| NotificationError, |
| NotificationProvider, |
| RetryableNotificationError, |
| ) |
| from agent.messaging.models import ( |
| NotificationRequest, |
| NotificationResult, |
| SlackDestinationConfig, |
| ) |
|
|
| _SEVERITY_PREFIX = { |
| "info": "[INFO]", |
| "success": "[SUCCESS]", |
| "warning": "[WARNING]", |
| "error": "[ERROR]", |
| } |
|
|
|
|
| def _format_slack_mrkdwn(content: str) -> str: |
| """Convert common Markdown constructs to Slack's mrkdwn syntax.""" |
| if not content: |
| return content |
|
|
| placeholders: dict[str, str] = {} |
| placeholder_index = 0 |
|
|
| def placeholder(value: str) -> str: |
| nonlocal placeholder_index |
| key = f"\x00SLACK{placeholder_index}\x00" |
| placeholder_index += 1 |
| placeholders[key] = value |
| return key |
|
|
| text = content |
|
|
| |
| |
| text = re.sub( |
| r"(```(?:[^\n]*\n)?[\s\S]*?```)", |
| lambda match: placeholder(match.group(0)), |
| text, |
| ) |
| text = re.sub(r"(`[^`\n]+`)", lambda match: placeholder(match.group(0)), text) |
|
|
| def convert_markdown_link(match: re.Match[str]) -> str: |
| label = match.group(1) |
| url = match.group(2).strip() |
| if url.startswith("<") and url.endswith(">"): |
| url = url[1:-1].strip() |
| return placeholder(f"<{url}|{label}>") |
|
|
| text = re.sub( |
| r"\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)", |
| convert_markdown_link, |
| text, |
| ) |
|
|
| |
| text = re.sub( |
| r"(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)", |
| lambda match: placeholder(match.group(1)), |
| text, |
| ) |
| text = re.sub( |
| r"^(>+\s)", |
| lambda match: placeholder(match.group(0)), |
| text, |
| flags=re.MULTILINE, |
| ) |
|
|
| text = text.replace("&", "&").replace("<", "<").replace(">", ">") |
| text = text.replace("&", "&").replace("<", "<").replace(">", ">") |
|
|
| def convert_header(match: re.Match[str]) -> str: |
| header = match.group(1).strip() |
| header = re.sub(r"\*\*(.+?)\*\*", r"\1", header) |
| return placeholder(f"*{header}*") |
|
|
| text = re.sub(r"^#{1,6}\s+(.+)$", convert_header, text, flags=re.MULTILINE) |
| text = re.sub( |
| r"\*\*\*(.+?)\*\*\*", |
| lambda match: placeholder(f"*_{match.group(1)}_*"), |
| text, |
| ) |
| text = re.sub( |
| r"\*\*(.+?)\*\*", |
| lambda match: placeholder(f"*{match.group(1)}*"), |
| text, |
| ) |
| text = re.sub( |
| r"(?<!\*)\*([^*\n]+)\*(?!\*)", |
| lambda match: placeholder(f"_{match.group(1)}_"), |
| text, |
| ) |
| text = re.sub( |
| r"~~(.+?)~~", |
| lambda match: placeholder(f"~{match.group(1)}~"), |
| text, |
| ) |
|
|
| for key in reversed(placeholders): |
| text = text.replace(key, placeholders[key]) |
|
|
| return text |
|
|
|
|
| def _format_text(request: NotificationRequest) -> str: |
| lines: list[str] = [] |
| prefix = _SEVERITY_PREFIX[request.severity] |
| if request.title: |
| lines.append(f"{prefix} {request.title}") |
| else: |
| lines.append(prefix) |
| lines.append(request.message) |
| for key, value in request.metadata.items(): |
| lines.append(f"{key}: {value}") |
| return _format_slack_mrkdwn("\n".join(lines)) |
|
|
|
|
| class SlackProvider(NotificationProvider): |
| provider_name = "slack" |
|
|
| async def send( |
| self, |
| client: httpx.AsyncClient, |
| destination_name: str, |
| destination: SlackDestinationConfig, |
| request: NotificationRequest, |
| ) -> NotificationResult: |
| payload = { |
| "channel": destination.channel, |
| "text": _format_text(request), |
| "mrkdwn": True, |
| "unfurl_links": False, |
| "unfurl_media": False, |
| } |
| if destination.username: |
| payload["username"] = destination.username |
| if destination.icon_emoji: |
| payload["icon_emoji"] = destination.icon_emoji |
|
|
| try: |
| response = await client.post( |
| "https://slack.com/api/chat.postMessage", |
| headers={ |
| "Authorization": f"Bearer {destination.token}", |
| "Content-Type": "application/json; charset=utf-8", |
| }, |
| content=json.dumps(payload), |
| ) |
| except httpx.TimeoutException as exc: |
| raise RetryableNotificationError("Slack request timed out") from exc |
| except httpx.TransportError as exc: |
| raise RetryableNotificationError("Slack transport error") from exc |
|
|
| if response.status_code == 429 or response.status_code >= 500: |
| raise RetryableNotificationError(f"Slack HTTP {response.status_code}") |
| if response.status_code >= 400: |
| raise NotificationError(f"Slack HTTP {response.status_code}") |
|
|
| try: |
| data = response.json() |
| except ValueError as exc: |
| raise RetryableNotificationError("Slack returned invalid JSON") from exc |
|
|
| if not data.get("ok"): |
| error = str(data.get("error") or "unknown_error") |
| if error == "ratelimited": |
| raise RetryableNotificationError(error) |
| raise NotificationError(error) |
|
|
| return NotificationResult( |
| destination=destination_name, |
| ok=True, |
| provider=self.provider_name, |
| external_id=str(data.get("ts") or ""), |
| error=None, |
| ) |
|
|