Sync dev changes into release-1.0
This commit is contained in:
216
backend/app/services/notifications.py
Normal file
216
backend/app/services/notifications.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
import httpx
|
||||
|
||||
from ..db import log_notification
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_channels(channels: Optional[Iterable[str]]) -> list[str]:
|
||||
if not channels:
|
||||
return []
|
||||
return [str(channel).strip().lower() for channel in channels if str(channel).strip()]
|
||||
|
||||
|
||||
def _send_email_sync(
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
smtp_user: Optional[str],
|
||||
smtp_password: Optional[str],
|
||||
smtp_from: str,
|
||||
to_address: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
use_tls: bool,
|
||||
use_starttls: bool,
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["From"] = smtp_from
|
||||
message["To"] = to_address
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
|
||||
if use_tls:
|
||||
with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) as server:
|
||||
if smtp_user and smtp_password:
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.send_message(message)
|
||||
else:
|
||||
with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as server:
|
||||
if use_starttls:
|
||||
server.starttls()
|
||||
if smtp_user and smtp_password:
|
||||
server.login(smtp_user, smtp_password)
|
||||
server.send_message(message)
|
||||
|
||||
|
||||
async def _send_email(to_address: str, subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_email_enabled:
|
||||
raise RuntimeError("Email notifications disabled")
|
||||
if not runtime.smtp_host or not runtime.smtp_from:
|
||||
raise RuntimeError("SMTP not configured")
|
||||
await asyncio.to_thread(
|
||||
_send_email_sync,
|
||||
runtime.smtp_host,
|
||||
int(runtime.smtp_port or 587),
|
||||
runtime.smtp_user,
|
||||
runtime.smtp_password,
|
||||
runtime.smtp_from,
|
||||
to_address,
|
||||
subject,
|
||||
body,
|
||||
bool(runtime.smtp_tls),
|
||||
bool(runtime.smtp_starttls),
|
||||
)
|
||||
|
||||
|
||||
async def _send_discord(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_discord_enabled:
|
||||
raise RuntimeError("Discord notifications disabled")
|
||||
if not runtime.discord_webhook_url:
|
||||
raise RuntimeError("Discord webhook not configured")
|
||||
payload = {"content": f"**{subject}**\n{body}"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(runtime.discord_webhook_url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_telegram(subject: str, body: str, chat_id: Optional[str]) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_telegram_enabled:
|
||||
raise RuntimeError("Telegram notifications disabled")
|
||||
if not runtime.telegram_bot_token:
|
||||
raise RuntimeError("Telegram bot token not configured")
|
||||
target_chat = chat_id or runtime.telegram_chat_id
|
||||
if not target_chat:
|
||||
raise RuntimeError("Telegram chat ID not configured")
|
||||
url = f"https://api.telegram.org/bot{runtime.telegram_bot_token}/sendMessage"
|
||||
payload = {"chat_id": target_chat, "text": f"{subject}\n{body}"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_matrix(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_matrix_enabled:
|
||||
raise RuntimeError("Matrix notifications disabled")
|
||||
if not runtime.matrix_homeserver or not runtime.matrix_access_token or not runtime.matrix_room_id:
|
||||
raise RuntimeError("Matrix not configured")
|
||||
url = (
|
||||
f"{runtime.matrix_homeserver}/_matrix/client/v3/rooms/"
|
||||
f"{runtime.matrix_room_id}/send/m.room.message"
|
||||
)
|
||||
payload = {"msgtype": "m.text", "body": f"{subject}\n{body}"}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, json=payload, params={"access_token": runtime.matrix_access_token})
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_pushover(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_pushover_enabled:
|
||||
raise RuntimeError("Pushover notifications disabled")
|
||||
if not runtime.pushover_token or not runtime.pushover_user_key:
|
||||
raise RuntimeError("Pushover not configured")
|
||||
payload = {
|
||||
"token": runtime.pushover_token,
|
||||
"user": runtime.pushover_user_key,
|
||||
"title": subject,
|
||||
"message": body,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post("https://api.pushover.net/1/messages.json", data=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_pushbullet(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_pushbullet_enabled:
|
||||
raise RuntimeError("Pushbullet notifications disabled")
|
||||
if not runtime.pushbullet_token:
|
||||
raise RuntimeError("Pushbullet not configured")
|
||||
payload = {"type": "note", "title": subject, "body": body}
|
||||
headers = {"Access-Token": runtime.pushbullet_token}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post("https://api.pushbullet.com/v2/pushes", json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_gotify(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_gotify_enabled:
|
||||
raise RuntimeError("Gotify notifications disabled")
|
||||
if not runtime.gotify_url or not runtime.gotify_token:
|
||||
raise RuntimeError("Gotify not configured")
|
||||
payload = {"title": subject, "message": body, "priority": 5}
|
||||
url = f"{runtime.gotify_url.rstrip('/')}/message"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, params={"token": runtime.gotify_token}, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def _send_ntfy(subject: str, body: str) -> None:
|
||||
runtime = get_runtime_settings()
|
||||
if not runtime.notify_ntfy_enabled:
|
||||
raise RuntimeError("ntfy notifications disabled")
|
||||
if not runtime.ntfy_url or not runtime.ntfy_topic:
|
||||
raise RuntimeError("ntfy not configured")
|
||||
url = f"{runtime.ntfy_url.rstrip('/')}/{runtime.ntfy_topic}"
|
||||
headers = {"Title": subject}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, content=body.encode("utf-8"), headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def send_notification(
|
||||
subject: str,
|
||||
body: str,
|
||||
channels: Optional[Iterable[str]] = None,
|
||||
email: Optional[str] = None,
|
||||
telegram_chat_id: Optional[str] = None,
|
||||
) -> dict[str, str]:
|
||||
requested = _normalize_channels(channels)
|
||||
results: dict[str, str] = {}
|
||||
if not requested:
|
||||
return results
|
||||
for channel in requested:
|
||||
try:
|
||||
if channel == "email":
|
||||
if not email:
|
||||
raise RuntimeError("Email address not provided")
|
||||
await _send_email(email, subject, body)
|
||||
elif channel == "discord":
|
||||
await _send_discord(subject, body)
|
||||
elif channel == "telegram":
|
||||
await _send_telegram(subject, body, telegram_chat_id)
|
||||
elif channel == "matrix":
|
||||
await _send_matrix(subject, body)
|
||||
elif channel == "pushover":
|
||||
await _send_pushover(subject, body)
|
||||
elif channel == "pushbullet":
|
||||
await _send_pushbullet(subject, body)
|
||||
elif channel == "gotify":
|
||||
await _send_gotify(subject, body)
|
||||
elif channel == "ntfy":
|
||||
await _send_ntfy(subject, body)
|
||||
else:
|
||||
results[channel] = "unsupported"
|
||||
continue
|
||||
results[channel] = "sent"
|
||||
log_notification(channel, email or telegram_chat_id, "sent", None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Notification failed: channel=%s error=%s", channel, exc)
|
||||
results[channel] = "failed"
|
||||
log_notification(channel, email or telegram_chat_id, "failed", str(exc))
|
||||
return results
|
||||
Reference in New Issue
Block a user