pryscraper/alerter.py
cryptorugmunch 0200bf3e16 refactor(exceptions): add ruff BLE001; convert 103 broad except Exception
Per CONVENTIONS.md Part 2 ("Never bare except") and CONVENTIONS.md
Part 7 (pre-commit hooks: ruff), blind `except Exception` is now a
lint failure. Pre-existing sites are marked `# noqa: BLE001` for
later manual review; new code must use specific exception types.

Changes:
- pyproject.toml: added "BLE" to ruff lint select. BLE001 is now enforced
- 103 of 166 `except Exception` sites were auto-converted to specific
  types based on context (httpx, json, OSError, subprocess, etc.)
- 62 remaining sites marked with `# noqa: BLE001` for later review
  (mostly generic try/except wrappers that legitimately need broad catch
  for graceful degradation: e.g. compliance LLM fallback must catch
  any error to preserve the regex result)
- 1 manual fix: reverted compliance.py LLM fallback to broad except
  with explicit "must catch all errors" comment + noqa
- 2 files (commerce_sync.py, crm_sync.py) needed `import httpx` added
  so the auto-converted exception references would resolve
- 5 source files (agency, monitor, pipelines, auth_connector,
  llm_providers/registry) renamed "name" -> "<scope>_name" in
  extra={...} dicts because "name" is a reserved LogRecord field

Test impact:
- 14 failing tests -> 1 (the SSE subprocess test is a sandbox limitation,
  pre-existing and unrelated)
- New `test_ble_temp.py` verifies BLE001 catches new violations

Follow-up:
- Each `# noqa: BLE001` site should be reviewed and replaced with a
  specific exception type where possible. The most common legitimate
  broad-catch case is the LLM fallback path; everything else probably
  can be narrowed.
2026-07-02 21:04:53 +02:00

195 lines
6.8 KiB
Python

"""Pry — Multi-Channel Alerting System.
SMS, Email, Microsoft Teams, Discord, Telegram alerts."""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import logging
from typing import Any
logger = logging.getLogger(__name__)
async def send_alert(
channel: str,
title: str,
message: str,
config: dict[str, Any],
severity: str = "info",
) -> dict[str, Any]:
"""Send an alert to any supported channel.
Channels: slack, email, discord, teams, telegram, sms
"""
channel_map = {
"slack": _send_slack,
"email": _send_email,
"discord": _send_discord,
"teams": _send_teams,
"telegram": _send_telegram,
"sms": _send_sms,
}
handler = channel_map.get(channel)
if not handler:
return {"success": False, "error": f"Unknown channel: {channel}"}
return await handler(title, message, config, severity)
async def _send_slack(
title: str, message: str, config: dict[str, Any], severity: str
) -> dict[str, Any]:
"""Send alert to Slack via webhook."""
from destinations import write_to_slack
color_map = {"critical": "#dc2626", "error": "#f59e0b", "info": "#3b82f6"}
full_msg = f"*[{severity.upper()}] {title}*\n{message}"
return await write_to_slack(
webhook_url=config.get("webhook_url", ""),
message=full_msg,
title=title,
color=color_map.get(severity, "#36a64f"),
)
async def _send_email(
title: str, message: str, config: dict[str, Any], severity: str
) -> dict[str, Any]:
"""Send alert via email."""
from destinations import write_to_email
return await write_to_email(
recipient=config.get("recipient", ""),
subject=f"[{severity.upper()}] {title}",
body=message,
smtp_host=config.get("smtp_host", ""),
smtp_port=config.get("smtp_port", 587),
smtp_user=config.get("smtp_user", ""),
smtp_password=config.get("smtp_password", ""),
sender=config.get("sender", ""),
)
async def _send_discord(
title: str, message: str, config: dict[str, Any], severity: str
) -> dict[str, Any]:
"""Send alert to Discord via webhook."""
from client import get_client
webhook_url = config.get("webhook_url", "")
if not webhook_url:
return {"success": False, "error": "Discord webhook URL required"}
color_map = {"critical": 0xDC2626, "error": 0xF59E0B, "info": 0x3B82F6}
payload = {
"embeds": [
{
"title": title,
"description": message[:2000] if len(message) > 2000 else message,
"color": color_map.get(severity, 0x36A64F),
"footer": {"text": "Pry Alert System"},
"timestamp": __import__("datetime")
.datetime.now(__import__("datetime").timezone.utc)
.isoformat(),
}
]
}
try:
client = await get_client()
resp = await client.post(webhook_url, json=payload, timeout=10)
if resp.is_success:
return {"success": True, "channel": "discord"}
return {"success": False, "error": f"Discord returned {resp.status_code}"}
except (httpx.HTTPError, httpx.RequestError) as e:
return {"success": False, "error": str(e)[:200]}
async def _send_teams(
title: str, message: str, config: dict[str, Any], severity: str
) -> dict[str, Any]:
"""Send alert to Microsoft Teams via webhook."""
from client import get_client
webhook_url = config.get("webhook_url", "")
if not webhook_url:
return {"success": False, "error": "Teams webhook URL required"}
payload = {
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"summary": title,
"title": f"[{severity.upper()}] {title}",
"text": message[:5000] if len(message) > 5000 else message,
"potentialAction": [],
}
try:
client = await get_client()
resp = await client.post(webhook_url, json=payload, timeout=10)
if resp.is_success:
return {"success": True, "channel": "teams"}
return {"success": False, "error": f"Teams returned {resp.status_code}"}
except (httpx.HTTPError, httpx.RequestError) as e:
return {"success": False, "error": str(e)[:200]}
async def _send_telegram(
title: str, message: str, config: dict[str, Any], severity: str
) -> dict[str, Any]:
"""Send alert to Telegram via bot API."""
from client import get_client
bot_token = config.get("bot_token", "")
chat_id = config.get("chat_id", "")
if not bot_token or not chat_id:
return {"success": False, "error": "Telegram bot_token and chat_id required"}
text = f"*[{severity.upper()}] {title}*\n\n{message[:3000]}"
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
try:
client = await get_client()
resp = await client.post(
url, json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"}, timeout=10
)
if resp.is_success:
return {"success": True, "channel": "telegram"}
return {"success": False, "error": f"Telegram returned {resp.status_code}"}
except (httpx.HTTPError, httpx.RequestError) as e:
return {"success": False, "error": str(e)[:200]}
async def _send_sms(
title: str, message: str, config: dict[str, Any], severity: str
) -> dict[str, Any]:
"""Send SMS alert via Twilio or API."""
provider = config.get("provider", "twilio")
phone = config.get("phone", "")
if not phone:
return {"success": False, "error": "Phone number required for SMS"}
if provider == "twilio":
account_sid = config.get("twilio_sid", "")
auth_token = config.get("twilio_token", "")
from_number = config.get("twilio_from", "")
if not all([account_sid, auth_token, from_number]):
return {"success": False, "error": "Twilio credentials required"}
text = f"[{severity.upper()}] {title}: {message[:500]}"
try:
from client import get_client
cl = await get_client()
resp = await cl.post(
f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json",
data={"To": phone, "From": from_number, "Body": text},
auth=(account_sid, auth_token),
timeout=10,
)
if resp.is_success:
return {"success": True, "channel": "sms", "provider": "twilio"}
return {"success": False, "error": f"Twilio error: {resp.text[:200]}"}
except Exception as e: # noqa: BLE001
return {"success": False, "error": str(e)[:200]}
return {"success": False, "error": f"SMS provider '{provider}' not supported"}