Files
ss-tools/backend/src/services/notifications/providers.py
busya 5ca1131ba3 032: Phase 5 (T036-T039) + Phase 6 (T040-T044) completed
T036: superset_compilation_adapter fully async
T037: fileio.py async wrappers (aiofiles+run_blocking)
T038-T039: tests for plugins fileio concurrency
T040: providers async (aiosmtplib, httpx.AsyncClient)
T041: dispatch_report parallel via asyncio.gather
T042: TaskManager ThreadPoolExecutor->asyncio.create_task
T043: EventBus asyncio.Queue(maxsize=10000)
T044: lifecycle async context manager

Remaining: T045 git/_base.py, T046-T048 tests, T005-T020-T021 tests
2026-06-04 20:30:43 +03:00

185 lines
6.1 KiB
Python

# #region providers [C:5] [TYPE Module] [SEMANTICS notification, provider, smtp, telegram, slack]
#
# @BRIEF Defines abstract base and concrete implementations for external notification delivery.
# @RELATION CALLED_BY -> [NotificationService]
# @RELATION DEPENDS_ON -> [NotificationProvider]
# @RELATION DEPENDS_ON -> [SMTPProvider]
# @RELATION DEPENDS_ON -> [TelegramProvider]
# @RELATION DEPENDS_ON -> [SlackProvider]
# @LAYER Infrastructure
# @PRE Provider configuration dictionaries are supplied by trusted configuration sources.
# @POST Each provider exposes async send contract returning boolean delivery outcome.
# @SIDE_EFFECT Performs outbound network I/O to SMTP or HTTP endpoints.
# @DATA_CONTRACT Input[target, subject, body, context?] -> Output[bool]
# @INVARIANT Concrete providers preserve boolean send contract and swallow transport exceptions into False.
#
# @INVARIANT Providers must be stateless and resilient to network failures.
# @INVARIANT Sensitive credentials must be handled via encrypted config.
from abc import ABC, abstractmethod
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
import aiosmtplib
import httpx
from src.core.logger import logger
# Module-level httpx client, lazily initialized for connection reuse
_http_client: httpx.AsyncClient | None = None
async def _get_http_client() -> httpx.AsyncClient:
"""Get or create the module-level httpx.AsyncClient singleton."""
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return _http_client
# #region NotificationProvider [C:2] [TYPE Class]
# @BRIEF Abstract base class for all notification providers.
# @RELATION CALLED_BY -> [SMTPProvider]
# @RELATION CALLED_BY -> [TelegramProvider]
# @RELATION CALLED_BY -> [SlackProvider]
class NotificationProvider(ABC):
@abstractmethod
async def send(
self,
target: str,
subject: str,
body: str,
context: dict[str, Any] | None = None,
) -> bool:
"""
Send a notification to a specific target.
:param target: Recipient identifier (email, channel ID, user ID).
:param subject: Notification subject or title.
:param body: Main content of the notification.
:param context: Additional metadata for the provider.
:return: True if successfully dispatched.
"""
pass
# #endregion NotificationProvider
# #region SMTPProvider [C:3] [TYPE Class]
# @BRIEF Delivers notifications via SMTP.
# @RELATION INHERITS -> [NotificationProvider]
class SMTPProvider(NotificationProvider):
def __init__(self, config: dict[str, Any]):
self.host = config.get("host")
self.port = int(config.get("port", 587))
self.username = config.get("username")
self.password = config.get("password")
self.from_email = config.get("from_email")
self.use_tls = config.get("use_tls", True)
async def send(
self,
target: str,
subject: str,
body: str,
context: dict[str, Any] | None = None,
) -> bool:
try:
msg = MIMEMultipart()
msg["From"] = self.from_email
msg["To"] = target
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
async with aiosmtplib.SMTP(host=self.host, port=self.port) as smtp:
if self.use_tls:
await smtp.starttls()
if self.username and self.password:
await smtp.login(self.username, self.password)
await smtp.sendmail(self.from_email, [target], msg.as_string())
return True
except Exception as e:
logger.error(
f"[SMTPProvider][FAILED] Failed to send email to {target}: {e}"
)
return False
# #endregion SMTPProvider
# #region TelegramProvider [C:3] [TYPE Class]
# @BRIEF Delivers notifications via Telegram Bot API.
# @RELATION INHERITS -> [NotificationProvider]
class TelegramProvider(NotificationProvider):
def __init__(self, config: dict[str, Any]):
self.bot_token = config.get("bot_token")
async def send(
self,
target: str,
subject: str,
body: str,
context: dict[str, Any] | None = None,
) -> bool:
if not self.bot_token:
logger.error("[TelegramProvider][FAILED] Bot token not configured")
return False
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
payload = {
"chat_id": target,
"text": f"*{subject}*\n\n{body}",
"parse_mode": "Markdown",
}
try:
client = await _get_http_client()
response = await client.post(url, json=payload)
response.raise_for_status()
return True
except Exception as e:
logger.error(
f"[TelegramProvider][FAILED] Failed to send Telegram message to {target}: {e}"
)
return False
# #endregion TelegramProvider
# #region SlackProvider [C:3] [TYPE Class]
# @BRIEF Delivers notifications via Slack Webhooks or API.
# @RELATION INHERITS -> [NotificationProvider]
class SlackProvider(NotificationProvider):
def __init__(self, config: dict[str, Any]):
self.webhook_url = config.get("webhook_url")
async def send(
self,
target: str,
subject: str,
body: str,
context: dict[str, Any] | None = None,
) -> bool:
if not self.webhook_url:
logger.error("[SlackProvider][FAILED] Webhook URL not configured")
return False
payload = {"text": f"*{subject}*\n{body}"}
try:
client = await _get_http_client()
response = await client.post(self.webhook_url, json=payload)
response.raise_for_status()
return True
except Exception as e:
logger.error(f"[SlackProvider][FAILED] Failed to send Slack message: {e}")
return False
# #endregion SlackProvider
# #endregion providers