Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"""Notification provider interface. Every alert dispatch (app/services/
|
||||
alert_service.py) and every "test destination" action goes through this
|
||||
Protocol, never a specific vendor SDK - swapping providers or adding a new
|
||||
one doesn't touch the dispatch logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationMessage:
|
||||
destination_value: str
|
||||
subject: str
|
||||
body_text: str
|
||||
body_html: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryResult:
|
||||
success: bool
|
||||
external_message_id: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class NotificationProvider(Protocol):
|
||||
provider_name: str
|
||||
|
||||
async def send(self, message: NotificationMessage) -> DeliveryResult: ...
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Console provider: logs the notification instead of sending it anywhere.
|
||||
Used in local dev fallback and wherever a destination type isn't yet wired
|
||||
to a real transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.notifications.base import DeliveryResult, NotificationMessage
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ConsoleProvider:
|
||||
provider_name = "console"
|
||||
|
||||
async def send(self, message: NotificationMessage) -> DeliveryResult:
|
||||
logger.info(
|
||||
"console_notification",
|
||||
destination=message.destination_value,
|
||||
subject=message.subject,
|
||||
body=message.body_text,
|
||||
)
|
||||
return DeliveryResult(success=True)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Resolves a NotificationType to a concrete provider. Console is always
|
||||
available and never costs anything; email/SMS are only meaningfully
|
||||
configured when their settings are present (callers gate on
|
||||
NOTIFICATION_SMS_ENABLED before ever routing to SMS - see alert_service.py).
|
||||
|
||||
SMS has two interchangeable vendor providers (`SMS_PROVIDER=twilio|telnyx`),
|
||||
same pattern as LLM_PROVIDER/SEARCH_PROVIDER elsewhere in the app - swapping
|
||||
the setting changes which class this returns and nothing else has to change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models.enums import NotificationType
|
||||
from app.notifications.base import NotificationProvider
|
||||
from app.notifications.console import ConsoleProvider
|
||||
from app.notifications.smtp_email import SmtpEmailProvider
|
||||
from app.notifications.telnyx_sms import TelnyxSmsProvider
|
||||
from app.notifications.twilio_sms import TwilioSmsProvider
|
||||
|
||||
|
||||
def get_notification_provider(
|
||||
notification_type: NotificationType, settings: Settings
|
||||
) -> NotificationProvider:
|
||||
if notification_type == NotificationType.EMAIL:
|
||||
return SmtpEmailProvider(settings)
|
||||
if notification_type == NotificationType.SMS:
|
||||
if settings.sms_provider == "telnyx":
|
||||
return TelnyxSmsProvider(settings)
|
||||
return TwilioSmsProvider(settings)
|
||||
return ConsoleProvider()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Builds the actual email/SMS/console message bodies for an alert. Kept
|
||||
separate from alert_service.py's dispatch loop so the message format can be
|
||||
tested in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.models.alert import Alert
|
||||
from app.models.company import Company
|
||||
from app.models.enums import NotificationType
|
||||
from app.notifications.base import NotificationMessage
|
||||
|
||||
|
||||
def build_alert_message(
|
||||
notification_type: NotificationType,
|
||||
destination_value: str,
|
||||
company: Company,
|
||||
alert: Alert,
|
||||
settings: Settings,
|
||||
) -> NotificationMessage:
|
||||
dashboard_link = f"{settings.frontend_url}/alerts"
|
||||
|
||||
if notification_type == NotificationType.SMS:
|
||||
text = (
|
||||
f"CI Alert [{alert.severity.value.upper()}]: {alert.title}. "
|
||||
f"Confidence {alert.confidence:.0%}. View details: {dashboard_link}"
|
||||
)
|
||||
return NotificationMessage(
|
||||
destination_value=destination_value, subject=alert.title, body_text=text[:480]
|
||||
)
|
||||
|
||||
subject = f"[{alert.severity.value.upper()}] {company.name}: {alert.title}"
|
||||
text_lines = [
|
||||
f"Company: {company.name}",
|
||||
f"Alert: {alert.title}",
|
||||
f"Severity: {alert.severity.value.title()}",
|
||||
f"Confidence: {alert.confidence:.0%}",
|
||||
"",
|
||||
"What changed:",
|
||||
alert.summary,
|
||||
"",
|
||||
"Why it matters:",
|
||||
alert.why_it_matters,
|
||||
"",
|
||||
f"View in dashboard: {dashboard_link}",
|
||||
f"Manage notification preferences: {settings.frontend_url}/settings",
|
||||
]
|
||||
body_text = "\n".join(text_lines)
|
||||
|
||||
body_html = (
|
||||
f"<h2>{subject}</h2>"
|
||||
f"<p><strong>Company:</strong> {company.name}<br>"
|
||||
f"<strong>Severity:</strong> {alert.severity.value.title()}<br>"
|
||||
f"<strong>Confidence:</strong> {alert.confidence:.0%}</p>"
|
||||
f"<p><strong>What changed:</strong><br>{alert.summary}</p>"
|
||||
f"<p><strong>Why it matters:</strong><br>{alert.why_it_matters}</p>"
|
||||
f'<p><a href="{dashboard_link}">View in dashboard</a></p>'
|
||||
f'<p style="color:#666;font-size:12px">'
|
||||
f'<a href="{settings.frontend_url}/settings">Manage notification preferences</a></p>'
|
||||
)
|
||||
|
||||
return NotificationMessage(
|
||||
destination_value=destination_value,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Resend HTTP API email provider - a separate transport from SmtpEmailProvider
|
||||
(which also happens to point at Resend's SMTP relay for alert notifications
|
||||
in this deployment, but that's a different concern/sender identity; see
|
||||
app/services/security_email_service.py). Fixed, trusted first-party vendor
|
||||
endpoint - no SSRF guard needed, same reasoning as the NinjaPear/USPTO calls
|
||||
elsewhere in this app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.logging import get_logger
|
||||
from app.notifications.base import DeliveryResult, NotificationMessage
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_RESEND_API_URL = "https://api.resend.com/emails"
|
||||
|
||||
|
||||
class ResendEmailProvider:
|
||||
provider_name = "resend"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def send(self, message: NotificationMessage) -> DeliveryResult:
|
||||
payload = {
|
||||
"from": self._settings.resend_security_from_email,
|
||||
"to": [message.destination_value],
|
||||
"subject": message.subject,
|
||||
"text": message.body_text,
|
||||
}
|
||||
if message.body_html:
|
||||
payload["html"] = message.body_html
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.post(
|
||||
_RESEND_API_URL,
|
||||
headers={"Authorization": f"Bearer {self._settings.resend_api_key}"},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return DeliveryResult(success=True, external_message_id=data.get("id"))
|
||||
except Exception as exc: # noqa: BLE001 - network/API failure path
|
||||
logger.error("resend_send_failed", error=str(exc))
|
||||
return DeliveryResult(success=False, error=str(exc))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""SMTP email provider. Points at Mailpit in local dev (see docker-compose.yml)
|
||||
and any real SMTP server in production - same code path either way. Uses
|
||||
stdlib `smtplib` off the event loop via `asyncio.to_thread` rather than
|
||||
adding an async SMTP dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.logging import get_logger
|
||||
from app.notifications.base import DeliveryResult, NotificationMessage
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SmtpEmailProvider:
|
||||
provider_name = "smtp"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def send(self, message: NotificationMessage) -> DeliveryResult:
|
||||
try:
|
||||
await asyncio.to_thread(self._send_sync, message)
|
||||
return DeliveryResult(success=True)
|
||||
except Exception as exc: # pragma: no cover - network/SMTP failure path
|
||||
logger.error("smtp_send_failed", error=str(exc))
|
||||
return DeliveryResult(success=False, error=str(exc))
|
||||
|
||||
def _send_sync(self, message: NotificationMessage) -> None:
|
||||
mime_message = MIMEMultipart("alternative")
|
||||
mime_message["Subject"] = message.subject
|
||||
mime_message["From"] = self._settings.smtp_from_email
|
||||
mime_message["To"] = message.destination_value
|
||||
mime_message.attach(MIMEText(message.body_text, "plain"))
|
||||
if message.body_html:
|
||||
mime_message.attach(MIMEText(message.body_html, "html"))
|
||||
|
||||
with smtplib.SMTP(self._settings.smtp_host, self._settings.smtp_port, timeout=10) as server:
|
||||
if self._settings.smtp_use_tls:
|
||||
server.starttls()
|
||||
if self._settings.smtp_username:
|
||||
server.login(self._settings.smtp_username, self._settings.smtp_password)
|
||||
server.sendmail(
|
||||
self._settings.smtp_from_email,
|
||||
[message.destination_value],
|
||||
mime_message.as_string(),
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Telnyx SMS provider via Telnyx's Programmable Messaging REST API (no SDK
|
||||
dependency - just a Bearer-authenticated POST), mirroring twilio_sms.py's
|
||||
shape. No-ops with a clear error if Telnyx isn't configured; the caller
|
||||
(alert_service) is what additionally gates on `NOTIFICATION_SMS_ENABLED`
|
||||
before ever constructing this provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.notifications.base import DeliveryResult, NotificationMessage
|
||||
|
||||
_API_URL = "https://api.telnyx.com/v2/messages"
|
||||
|
||||
|
||||
class TelnyxSmsProvider:
|
||||
provider_name = "telnyx_sms"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def send(self, message: NotificationMessage) -> DeliveryResult:
|
||||
settings = self._settings
|
||||
if not (settings.telnyx_api_key and settings.telnyx_from_number):
|
||||
return DeliveryResult(success=False, error="Telnyx is not configured")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.post(
|
||||
_API_URL,
|
||||
headers={"Authorization": f"Bearer {settings.telnyx_api_key}"},
|
||||
json={
|
||||
"from": settings.telnyx_from_number,
|
||||
"to": message.destination_value,
|
||||
"text": message.body_text,
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return DeliveryResult(success=False, error=str(exc))
|
||||
|
||||
data = response.json()
|
||||
if response.status_code >= 400:
|
||||
errors = data.get("errors") or []
|
||||
detail = errors[0].get("detail") if errors else response.text[:200]
|
||||
return DeliveryResult(
|
||||
success=False, error=f"Telnyx error {response.status_code}: {detail}"
|
||||
)
|
||||
return DeliveryResult(success=True, external_message_id=data.get("data", {}).get("id"))
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Twilio SMS provider via Twilio's plain REST API (no SDK dependency -
|
||||
just an authenticated POST). No-ops with a clear error if Twilio isn't
|
||||
configured; the caller (alert_service) is what additionally gates on
|
||||
`NOTIFICATION_SMS_ENABLED` before ever constructing this provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.notifications.base import DeliveryResult, NotificationMessage
|
||||
|
||||
_API_BASE = "https://api.twilio.com/2010-04-01"
|
||||
|
||||
|
||||
class TwilioSmsProvider:
|
||||
provider_name = "twilio_sms"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def send(self, message: NotificationMessage) -> DeliveryResult:
|
||||
settings = self._settings
|
||||
if not (
|
||||
settings.twilio_account_sid
|
||||
and settings.twilio_auth_token
|
||||
and settings.twilio_from_number
|
||||
):
|
||||
return DeliveryResult(success=False, error="Twilio is not configured")
|
||||
|
||||
url = f"{_API_BASE}/Accounts/{settings.twilio_account_sid}/Messages.json"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
auth=(settings.twilio_account_sid, settings.twilio_auth_token),
|
||||
data={
|
||||
"From": settings.twilio_from_number,
|
||||
"To": message.destination_value,
|
||||
"Body": message.body_text,
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return DeliveryResult(success=False, error=str(exc))
|
||||
|
||||
if response.status_code >= 400:
|
||||
return DeliveryResult(
|
||||
success=False, error=f"Twilio error {response.status_code}: {response.text[:200]}"
|
||||
)
|
||||
data = response.json()
|
||||
return DeliveryResult(success=True, external_message_id=data.get("sid"))
|
||||
Reference in New Issue
Block a user