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
185 lines
6.1 KiB
Python
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
|