217 lines
8.1 KiB
Python
217 lines
8.1 KiB
Python
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
|