Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,2 @@
|
||||
"""Notification channel delivery helpers."""
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import EmailMessage
|
||||
import json
|
||||
import smtplib
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib import error, request
|
||||
|
||||
from ...core.redaction import redact_message
|
||||
from ...db.models import NotificationChannel
|
||||
from ..secret_store import get_secret
|
||||
|
||||
|
||||
SECRET_REFS = {
|
||||
"discord": "NOTIFICATION_DISCORD_WEBHOOK",
|
||||
"telegram": "NOTIFICATION_TELEGRAM_BOT_TOKEN",
|
||||
"email": "NOTIFICATION_SMTP_PASSWORD",
|
||||
}
|
||||
|
||||
|
||||
def safe_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"discord": {"username", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"},
|
||||
"telegram": {"chatId", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"},
|
||||
"email": {"host", "port", "username", "fromAddress", "toAddress", "useTls", "smtpMode", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"},
|
||||
}.get(channel_type, set())
|
||||
return {key: value for key, value in config.items() if key in allowed}
|
||||
|
||||
|
||||
def config_status(session, channel: NotificationChannel) -> dict[str, Any]:
|
||||
config = safe_config(channel.type, channel.config_json or {})
|
||||
secret_ref = SECRET_REFS.get(channel.type)
|
||||
has_secret = bool(secret_ref and get_secret(session, secret_ref, touch=False))
|
||||
missing: list[str] = []
|
||||
if channel.type == "telegram" and not str(config.get("chatId") or "").strip():
|
||||
missing.append("chatId")
|
||||
if channel.type == "email":
|
||||
for field in ("host", "fromAddress", "toAddress"):
|
||||
if not str(config.get(field) or "").strip():
|
||||
missing.append(field)
|
||||
if not has_secret:
|
||||
missing.append("credential")
|
||||
elif channel.type == "discord":
|
||||
webhook = get_secret(session, secret_ref, touch=False) or ""
|
||||
if not webhook.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")):
|
||||
missing.append("validDiscordWebhook")
|
||||
return {
|
||||
"status": "disabled" if not channel.enabled else ("configured" if not missing else "missing_config"),
|
||||
"hasSecret": has_secret,
|
||||
"missing": missing,
|
||||
"safeConfig": config,
|
||||
}
|
||||
|
||||
|
||||
def _http_json(url: str, payload: dict[str, Any], timeout: int) -> dict[str, Any]:
|
||||
try:
|
||||
body = json.dumps(payload).encode()
|
||||
req = request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
with request.urlopen(req, timeout=timeout) as response:
|
||||
status = int(response.status)
|
||||
return {"success": 200 <= status < 300, "status": "sent" if 200 <= status < 300 else "failed", "httpStatus": status}
|
||||
except error.HTTPError as exc:
|
||||
return {"success": False, "status": "failed", "httpStatus": int(exc.code), "reason": "remote_http_error"}
|
||||
except (error.URLError, TimeoutError, OSError, ValueError):
|
||||
return {"success": False, "status": "failed", "reason": "connection_failed"}
|
||||
|
||||
|
||||
def _send_once(session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]:
|
||||
status = config_status(session, channel)
|
||||
if not channel.enabled:
|
||||
return {"success": True, "status": "disabled"}
|
||||
if status["missing"]:
|
||||
return {"success": False, "status": "missing_config", "missing": status["missing"]}
|
||||
config = status["safeConfig"]
|
||||
timeout = max(2, min(int(config.get("timeoutSeconds") or 10), 30))
|
||||
title = redact_message(str(event.get("title") or "AppOps notification"))[:200]
|
||||
message = redact_message(str(event.get("message") or ""))[:1500]
|
||||
secret = get_secret(session, SECRET_REFS[channel.type], touch=True) or ""
|
||||
|
||||
if channel.type == "discord":
|
||||
return _http_json(secret, {"username": str(config.get("username") or "AppOps"), "content": f"**{title}**\n{message}"}, timeout)
|
||||
if channel.type == "telegram":
|
||||
url = f"https://api.telegram.org/bot{secret}/sendMessage"
|
||||
return _http_json(url, {"chat_id": str(config["chatId"]), "text": f"{title}\n{message}"}, timeout)
|
||||
if channel.type == "email":
|
||||
email = EmailMessage()
|
||||
email["Subject"] = title
|
||||
email["From"] = str(config["fromAddress"])
|
||||
email["To"] = str(config["toAddress"])
|
||||
email.set_content(message)
|
||||
try:
|
||||
smtp_mode = str(config.get("smtpMode") or ("implicit_tls" if config.get("useTls", True) else "plain"))
|
||||
if smtp_mode not in {"implicit_tls", "starttls", "plain"}:
|
||||
return {"success": False, "status": "missing_config", "missing": ["validSmtpMode"]}
|
||||
port = int(config.get("port") or (465 if smtp_mode == "implicit_tls" else 587 if smtp_mode == "starttls" else 25))
|
||||
if smtp_mode == "implicit_tls":
|
||||
with smtplib.SMTP_SSL(str(config["host"]), port, timeout=timeout, context=ssl.create_default_context()) as smtp:
|
||||
if config.get("username"):
|
||||
smtp.login(str(config["username"]), secret)
|
||||
smtp.send_message(email)
|
||||
else:
|
||||
with smtplib.SMTP(str(config["host"]), port, timeout=timeout) as smtp:
|
||||
if smtp_mode == "starttls":
|
||||
smtp.ehlo()
|
||||
smtp.starttls(context=ssl.create_default_context())
|
||||
smtp.ehlo()
|
||||
if config.get("username"):
|
||||
smtp.login(str(config["username"]), secret)
|
||||
smtp.send_message(email)
|
||||
return {"success": True, "status": "sent"}
|
||||
except (OSError, smtplib.SMTPException, ValueError):
|
||||
return {"success": False, "status": "failed", "reason": "smtp_delivery_failed"}
|
||||
return {"success": False, "status": "unsupported_channel"}
|
||||
|
||||
|
||||
def send_notification(session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]:
|
||||
config = safe_config(channel.type, channel.config_json or {})
|
||||
max_attempts = max(1, min(int(config.get("maxAttempts") or 3), 3))
|
||||
backoff = max(0.0, min(float(config.get("retryBackoffSeconds") or 0.25), 2.0))
|
||||
result: dict[str, Any] = {"success": False, "status": "failed", "reason": "delivery_failed"}
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
result = _send_once(session, channel, event)
|
||||
result["attempts"] = attempt
|
||||
if result.get("success") or result.get("status") in {"disabled", "missing_config", "unsupported_channel"}:
|
||||
return result
|
||||
retryable = result.get("reason") in {"connection_failed", "smtp_delivery_failed"} or int(result.get("httpStatus") or 0) >= 500 or int(result.get("httpStatus") or 0) == 429
|
||||
if not retryable:
|
||||
return result
|
||||
if attempt < max_attempts and backoff:
|
||||
time.sleep(backoff * (2 ** (attempt - 1)))
|
||||
result["deadLetter"] = True
|
||||
return result
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from ...core.config import settings
|
||||
from ...db.models import NotificationChannel
|
||||
from ..secret_store import get_secret
|
||||
|
||||
SECRET_REF = "HOME_ASSISTANT_TOKEN"
|
||||
|
||||
|
||||
def _api_value(key: str, default: Any = None) -> Any:
|
||||
value = settings.apis.get(key)
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def _bool(value: Any, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _clean_url(value: Any) -> str | None:
|
||||
text = str(value or "").strip().rstrip("/")
|
||||
return text or None
|
||||
|
||||
|
||||
def normalize_config(raw: dict[str, Any] | None) -> dict[str, Any]:
|
||||
config = dict(raw or {})
|
||||
return {
|
||||
"url": _clean_url(config.get("url") or _api_value("HOME_ASSISTANT_URL")),
|
||||
"notifyDomain": str(config.get("notifyDomain") or _api_value("HOME_ASSISTANT_NOTIFY_DOMAIN", "notify") or "notify").strip() or "notify",
|
||||
"notifyService": str(config.get("notifyService") or _api_value("HOME_ASSISTANT_NOTIFY_SERVICE", "notify") or "notify").strip() or "notify",
|
||||
"target": str(config.get("target") or _api_value("HOME_ASSISTANT_NOTIFY_TARGET") or "").strip() or None,
|
||||
"verifyTls": _bool(config.get("verifyTls"), _bool(_api_value("HOME_ASSISTANT_VERIFY_TLS"), False)),
|
||||
"timeoutSeconds": _int(config.get("timeoutSeconds") or _api_value("HOME_ASSISTANT_TIMEOUT_SECONDS"), 10),
|
||||
"titlePrefix": str(config.get("titlePrefix") or _api_value("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX", "AppOps") or "AppOps").strip() or "AppOps",
|
||||
"dryRun": _bool(config.get("dryRun"), _bool(_api_value("HOME_ASSISTANT_DRY_RUN"), False)),
|
||||
}
|
||||
|
||||
|
||||
def safe_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
normalized = normalize_config(config)
|
||||
return {
|
||||
"url": normalized["url"],
|
||||
"notifyDomain": normalized["notifyDomain"],
|
||||
"notifyService": normalized["notifyService"],
|
||||
"target": normalized["target"],
|
||||
"verifyTls": normalized["verifyTls"],
|
||||
"timeoutSeconds": normalized["timeoutSeconds"],
|
||||
"titlePrefix": normalized["titlePrefix"],
|
||||
"dryRun": normalized["dryRun"],
|
||||
}
|
||||
|
||||
|
||||
def _token(session: Session) -> str | None:
|
||||
return get_secret(session, SECRET_REF) or (str(_api_value(SECRET_REF) or "").strip() or None)
|
||||
|
||||
|
||||
def config_status(session: Session, channel: NotificationChannel) -> dict[str, Any]:
|
||||
config = normalize_config(channel.config_json)
|
||||
missing: list[str] = []
|
||||
token = _token(session)
|
||||
if not config["url"]:
|
||||
missing.append("HOME_ASSISTANT_URL")
|
||||
if not token:
|
||||
missing.append("HOME_ASSISTANT_TOKEN")
|
||||
if missing:
|
||||
status = "missing_config"
|
||||
elif not channel.enabled:
|
||||
status = "disabled"
|
||||
else:
|
||||
status = "connected"
|
||||
return {
|
||||
"status": status,
|
||||
"missing": missing,
|
||||
"hasSecret": bool(token),
|
||||
"safeConfig": safe_config(channel.config_json),
|
||||
}
|
||||
|
||||
|
||||
def format_message(event: dict[str, Any]) -> str:
|
||||
severity = str(event.get("severity") or "info").upper()
|
||||
source = str(event.get("source") or "appops")
|
||||
target = str(event.get("target") or "global")
|
||||
message = str(event.get("message") or event.get("title") or "Notification")
|
||||
return f"{severity} - {source} - {target}\n{message}"
|
||||
|
||||
|
||||
def _title(config: dict[str, Any], event: dict[str, Any]) -> str:
|
||||
prefix = str(config.get("titlePrefix") or "AppOps")
|
||||
title = str(event.get("title") or "Notification")
|
||||
return f"{prefix}: {title}" if prefix else title
|
||||
|
||||
|
||||
def _request_context(config: dict[str, Any]) -> ssl.SSLContext | None:
|
||||
if config["verifyTls"]:
|
||||
return None
|
||||
return ssl._create_unverified_context()
|
||||
|
||||
|
||||
def _call_home_assistant(config: dict[str, Any], token: str, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
body = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{config['url']}{path}",
|
||||
data=body,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=config["timeoutSeconds"], context=_request_context(config)) as response:
|
||||
return {"ok": True, "statusCode": response.status}
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in {401, 403}:
|
||||
return {"ok": False, "status": "auth_failed", "statusCode": exc.code}
|
||||
return {"ok": False, "status": "error", "statusCode": exc.code}
|
||||
except urllib.error.URLError:
|
||||
return {"ok": False, "status": "unreachable"}
|
||||
except OSError:
|
||||
return {"ok": False, "status": "unreachable"}
|
||||
|
||||
|
||||
def test_connection(session: Session, channel: NotificationChannel) -> dict[str, Any]:
|
||||
status = config_status(session, channel)
|
||||
if status["missing"]:
|
||||
return {"success": False, "status": "missing_config", "missing": status["missing"]}
|
||||
config = normalize_config(channel.config_json)
|
||||
token = _token(session) or ""
|
||||
if config["dryRun"]:
|
||||
return {"success": True, "status": "connected", "dryRun": True}
|
||||
result = _call_home_assistant(config, token, "GET", "/api/")
|
||||
return {
|
||||
"success": bool(result["ok"]),
|
||||
"status": "connected" if result["ok"] else result.get("status", "error"),
|
||||
"statusCode": result.get("statusCode"),
|
||||
}
|
||||
|
||||
|
||||
def _notify_payload(config: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"title": _title(config, event),
|
||||
"message": format_message(event),
|
||||
"data": {
|
||||
"source": event.get("source"),
|
||||
"severity": event.get("severity"),
|
||||
"category": event.get("category"),
|
||||
"target": event.get("target"),
|
||||
"ruleId": event.get("ruleId"),
|
||||
"eventId": event.get("id"),
|
||||
},
|
||||
}
|
||||
if config["target"]:
|
||||
payload["target"] = config["target"]
|
||||
return payload
|
||||
|
||||
|
||||
def send_notification(session: Session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]:
|
||||
if not channel.enabled:
|
||||
return {"status": "suppressed", "reason": "disabled"}
|
||||
status = config_status(session, channel)
|
||||
if status["missing"]:
|
||||
return {"status": "failed", "reason": "missing_config", "missing": status["missing"]}
|
||||
config = normalize_config(channel.config_json)
|
||||
if config["dryRun"]:
|
||||
return {"status": "sent", "reason": "dry_run"}
|
||||
token = _token(session) or ""
|
||||
path = f"/api/services/{config['notifyDomain']}/{config['notifyService']}"
|
||||
result = _call_home_assistant(config, token, "POST", path, _notify_payload(config, event))
|
||||
if result["ok"]:
|
||||
return {"status": "sent"}
|
||||
return {"status": "failed", "reason": result.get("status", "error"), "statusCode": result.get("statusCode")}
|
||||
|
||||
|
||||
def send_test_notification(session: Session, channel: NotificationChannel) -> dict[str, Any]:
|
||||
event = {
|
||||
"id": "test",
|
||||
"ruleId": "notification.channel.test",
|
||||
"source": "notification",
|
||||
"event_type": "notification.channel.test",
|
||||
"severity": "info",
|
||||
"title": "Home Assistant test notification",
|
||||
"message": "AppOps test notification.",
|
||||
"category": "test",
|
||||
"target": "home_assistant",
|
||||
}
|
||||
result = send_notification(session, channel, event)
|
||||
return {
|
||||
"success": result["status"] == "sent",
|
||||
"status": result["status"],
|
||||
"reason": result.get("reason"),
|
||||
"missing": result.get("missing"),
|
||||
}
|
||||
Reference in new issue
Block a user