pryscraper/alerter.py
cryptorugmunch a7c30b12cd
Some checks failed
CI / lint (push) Failing after 2s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 2s
CI / Secret scan (gitleaks) (push) Failing after 1s
CI / Security audit (bandit) (push) Failing after 2s
chore(lint): auto-fix 253 of 283 ruff issues (F401, I001, E402, RUF100, UP037, SIM105)
Mass ruff auto-fix:
  - ruff check --fix: 109 issues fixed (F401 unused imports,
    I001 unsorted imports, UP037 quoted annotations, SIM105
    suppressible exception, RUF100 unused-noqa)
  - ruff check --fix --unsafe-fixes: 22 additional issues
  - ruff format: 70 files reformatted
  - Manual pass: fix 16 misplaced import httpx lines
  - Manual pass: fix remaining E402 (import-after-docstring)

Result: 283 errors -> 30 errors.

The remaining 30 are real issues that need manual review:
  5 F401 unused-import (likely auto-generated stubs)
  5 F821 undefined-name (real bugs in code that references
    redis/pydantic/LLMRegistry without imports)
  3 BLE001 (the compliance LLM fallback is intentional; the
    other two are real)
  3 RUF012 mutable-class-default
  3 SIM105, 3 SIM117, 2 E722, 2 E741
  1 B007, 1 B025, 1 E402, 1 RUF200 (pyproject.toml issue)

Tests: 436/437 pass (1 pre-existing SSE sandbox failure).
format check + import sort: now clean.
make ci: still gated on the 30 remaining real issues.
Follow-up: triage the 30 issues file-by-file.
2026-07-02 21:51:25 +02:00

198 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
import httpx
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"}