Initial commit

This commit is contained in:
Sterling Archer
2026-08-10 21:37:46 -07:00
commit 56e75d9cde
25 changed files with 8285 additions and 0 deletions
+654
View File
@@ -0,0 +1,654 @@
import asyncio
import json
import secrets
import shutil
import smtplib
import sys
import string
import subprocess
import time
from email.message import EmailMessage
from pathlib import Path
import aiohttp
from . import paths
from .profiles import load_yaml, write_text
CONFIG_PATH = paths.BASE_DIR / "notifications.yaml"
SEND_TIMEOUT = 15
CONFIG_TEMPLATE = """# Notification channels for solixauto.
#
# Secrets live here, NOT in your power profiles, so profiles stay safe to
# share or commit. This file is created with owner-only permissions.
#
# Enable a channel by setting enabled: true and filling in its settings.
# Test with:
# solixauto notify-test
# solixauto notify-test --channel ntfy
#
# ---------------------------------------------------------------------
# ntfy - recommended. Free, no account needed.
# 1. install the ntfy app on your phone
# 2. subscribe to a topic name that nobody else would guess
# 3. put that topic below
# Anyone who knows the topic name can read your alerts, so make it long.
# ---------------------------------------------------------------------
ntfy:
enabled: false
server: https://ntfy.sh
topic: solix-CHANGE-ME-to-something-random
priority: default
token: ""
# ---------------------------------------------------------------------
# Pushover - $5 one time per platform. Very reliable delivery.
# Get both keys from https://pushover.net
# ---------------------------------------------------------------------
pushover:
enabled: false
user_key: ""
api_token: ""
priority: 0
sound: ""
# ---------------------------------------------------------------------
# Email over SMTP.
# For Gmail you must use an App Password, not your normal password.
# ---------------------------------------------------------------------
email:
enabled: false
host: smtp.gmail.com
port: 587
use_tls: true
username: ""
password: ""
sender: ""
recipients: []
# ---------------------------------------------------------------------
# Telegram - free. Create a bot with @BotFather, then message it once and
# read your chat id from https://api.telegram.org/bot<TOKEN>/getUpdates
# ---------------------------------------------------------------------
telegram:
enabled: false
bot_token: ""
chat_id: ""
# ---------------------------------------------------------------------
# Generic webhook. Works with Slack and Discord incoming webhooks.
# format: slack | discord | json | form
# ---------------------------------------------------------------------
webhook:
enabled: false
url: ""
format: json
method: POST
# ---------------------------------------------------------------------
# Desktop notification on the machine running the engine.
# Useful while testing. Does not reach your phone.
#
# macOS uses osascript, built in
# Linux uses notify-send, from libnotify-bin
# Windows uses PowerShell, built in
#
# sound is macOS only and ignored elsewhere.
# ---------------------------------------------------------------------
desktop:
enabled: false
sound: Submarine
"""
class SafeDict(dict):
def __missing__(self, key):
return "?"
class TemplateFormatter(string.Formatter):
def get_value(self, key, args, kwargs):
if isinstance(key, str):
return kwargs.get(key, "?")
return "?"
def format_field(self, value, format_spec):
try:
return super().format_field(value, format_spec)
except (TypeError, ValueError):
return str(value)
FORMATTER = TemplateFormatter()
def render(template, context):
try:
return FORMATTER.vformat(str(template), (), SafeDict(context))
except Exception:
return str(template)
def template_fields(template):
found = set()
try:
for _, field, _, _ in string.Formatter().parse(str(template)):
if field:
found.add(field.split(".")[0].split("[")[0])
except ValueError:
pass
return found
def ensure_config():
paths.ensure_dirs()
if not CONFIG_PATH.exists():
write_text(CONFIG_PATH, CONFIG_TEMPLATE)
paths.secure_file(CONFIG_PATH)
return CONFIG_PATH
def set_value(text, channel, key, value):
lines = text.splitlines()
output = []
inside = False
replaced = False
if isinstance(value, bool):
rendered = "true" if value else "false"
elif isinstance(value, list):
rendered = "[" + ", ".join(str(v) for v in value) + "]"
elif value == "":
rendered = '""'
else:
rendered = str(value)
for line in lines:
stripped = line.strip()
if not line.startswith((" ", "\t")) and stripped.endswith(":"):
if inside and not replaced:
pass
inside = stripped[:-1] == channel
if inside and not replaced:
without_indent = line.lstrip()
if without_indent.startswith(f"{key}:"):
indent = line[: len(line) - len(without_indent)]
output.append(f"{indent}{key}: {rendered}")
replaced = True
continue
output.append(line)
return "\n".join(output) + "\n", replaced
def apply_settings(channel, values):
ensure_config()
text = CONFIG_PATH.read_text(encoding="utf-8")
missing = []
for key, value in values.items():
text, replaced = set_value(text, channel, key, value)
if not replaced:
missing.append(key)
CONFIG_PATH.write_text(text, encoding="utf-8")
paths.secure_file(CONFIG_PATH)
return missing
def generate_topic(prefix="solix"):
return f"{prefix}-{secrets.token_hex(10)}"
NTFY_IOS_URL = "https://apps.apple.com/us/app/ntfy/id1625396347"
NTFY_ANDROID_URL = "https://play.google.com/store/apps/details?id=io.heckel.ntfy"
NTFY_FDROID_URL = "https://f-droid.org/en/packages/io.heckel.ntfy/"
NTFY_DOCS_URL = "https://docs.ntfy.sh/subscribe/phone/"
def subscribe_url(topic, server="https://ntfy.sh"):
return f"{server.rstrip('/')}/{topic}"
def _can_encode(sample):
encoding = getattr(sys.stdout, "encoding", None) or ""
if not encoding:
return False
try:
sample.encode(encoding)
except (UnicodeEncodeError, LookupError):
return False
return True
def render_qr(data, border=2, dark_terminal=True):
try:
import qrcode
except ImportError:
return None, "qrcode package not installed"
try:
code = qrcode.QRCode(border=border)
code.add_data(data)
code.make(fit=True)
matrix = code.get_matrix()
except Exception as err:
return None, f"{type(err).__name__}: {err}"
if not _can_encode("\u2588\u2580\u2584"):
return None, "this console cannot render block characters"
def ink(value):
return (not value) if dark_terminal else value
lines = []
for index in range(0, len(matrix), 2):
top = matrix[index]
bottom = matrix[index + 1] if index + 1 < len(matrix) else [False] * len(top)
row = []
for upper, lower in zip(top, bottom):
upper_on = ink(upper)
lower_on = ink(lower)
if upper_on and lower_on:
row.append("\u2588")
elif upper_on:
row.append("\u2580")
elif lower_on:
row.append("\u2584")
else:
row.append(" ")
lines.append("".join(row))
if dark_terminal:
width = len(lines[0]) if lines else 0
pad = "\u2588" * width
lines = [pad] + lines + [pad]
return "\n".join(lines), None
def show_qr(data, label=None, indent=" ", dark_terminal=True):
art, problem = render_qr(data, dark_terminal=dark_terminal)
if label:
print(f"{indent}{label}")
print()
if art is None:
print(f"{indent}[QR unavailable: {problem}]")
print(f"{indent}Open this link on your phone instead:")
print(f"{indent}{data}")
return False
try:
for line in art.splitlines():
print(f"{indent}{line}")
except UnicodeEncodeError:
print(f"{indent}[QR unavailable: console encoding]")
print(f"{indent}{data}")
return False
print()
print(f"{indent}{data}")
return True
def load_config():
if not CONFIG_PATH.exists():
return {}
try:
return load_yaml(CONFIG_PATH)
except Exception:
return {}
def enabled_channels(config=None):
config = config if config is not None else load_config()
return sorted(
name
for name, settings in config.items()
if isinstance(settings, dict) and settings.get("enabled")
)
async def _send_ntfy(session, settings, title, body, priority):
server = str(settings.get("server") or "https://ntfy.sh").rstrip("/")
topic = settings.get("topic")
if not topic:
raise ValueError("ntfy.topic is not set")
headers = {"Title": title}
level = priority or settings.get("priority")
if level:
headers["Priority"] = str(level)
token = settings.get("token")
if token:
headers["Authorization"] = f"Bearer {token}"
async with session.post(
f"{server}/{topic}",
data=body.encode("utf-8"),
headers=headers,
timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT),
) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}")
async def _send_pushover(session, settings, title, body, priority):
user_key = settings.get("user_key")
api_token = settings.get("api_token")
if not user_key or not api_token:
raise ValueError("pushover.user_key and pushover.api_token are required")
payload = {
"token": api_token,
"user": user_key,
"title": title,
"message": body,
"priority": int(priority if priority is not None else settings.get("priority", 0)),
}
if settings.get("sound"):
payload["sound"] = settings["sound"]
if payload["priority"] == 2:
payload["retry"] = 60
payload["expire"] = 3600
async with session.post(
"https://api.pushover.net/1/messages.json",
data=payload,
timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT),
) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}: {await response.text()}")
async def _send_telegram(session, settings, title, body, priority):
token = settings.get("bot_token")
chat_id = settings.get("chat_id")
if not token or not chat_id:
raise ValueError("telegram.bot_token and telegram.chat_id are required")
async with session.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": str(chat_id), "text": f"{title}\n{body}"},
timeout=aiohttp.ClientTimeout(total=SEND_TIMEOUT),
) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}: {await response.text()}")
async def _send_webhook(session, settings, title, body, priority):
url = settings.get("url")
if not url:
raise ValueError("webhook.url is not set")
style = str(settings.get("format") or "json").lower()
method = str(settings.get("method") or "POST").upper()
kwargs = {"timeout": aiohttp.ClientTimeout(total=SEND_TIMEOUT)}
if style == "slack":
kwargs["json"] = {"text": f"*{title}*\n{body}"}
elif style == "discord":
kwargs["json"] = {"content": f"**{title}**\n{body}"}
elif style == "form":
kwargs["data"] = {"title": title, "message": body}
else:
kwargs["json"] = {"title": title, "message": body, "priority": priority}
async with session.request(method, url, **kwargs) as response:
if response.status >= 300:
raise RuntimeError(f"HTTP {response.status}")
def _send_email_blocking(settings, title, body):
recipients = settings.get("recipients") or []
if isinstance(recipients, str):
recipients = [recipients]
sender = settings.get("sender") or settings.get("username")
if not recipients or not sender:
raise ValueError("email.sender and email.recipients are required")
message = EmailMessage()
message["Subject"] = title
message["From"] = sender
message["To"] = ", ".join(recipients)
message.set_content(body)
host = settings.get("host") or "localhost"
port = int(settings.get("port") or 587)
if int(port) == 465:
server = smtplib.SMTP_SSL(host, port, timeout=SEND_TIMEOUT)
else:
server = smtplib.SMTP(host, port, timeout=SEND_TIMEOUT)
try:
if int(port) != 465 and settings.get("use_tls", True):
server.starttls()
if settings.get("username") and settings.get("password"):
server.login(settings["username"], settings["password"])
server.send_message(message)
finally:
try:
server.quit()
except Exception:
pass
async def _send_email(session, settings, title, body, priority):
await asyncio.get_running_loop().run_in_executor(
None, _send_email_blocking, settings, title, body
)
def desktop_backend():
if sys.platform == "darwin":
return "osascript" if shutil.which("osascript") else None
if sys.platform.startswith("win"):
for candidate in ("powershell", "pwsh"):
if shutil.which(candidate):
return candidate
return None
for candidate in ("notify-send", "kdialog", "zenity"):
if shutil.which(candidate):
return candidate
return None
def _desktop_macos(settings, title, body):
escaped_body = body.replace("\\", "\\\\").replace('"', '\\"')
escaped_title = title.replace("\\", "\\\\").replace('"', '\\"')
script = f'display notification "{escaped_body}" with title "{escaped_title}"'
if settings.get("sound"):
sound = str(settings["sound"]).replace('"', "")
script += f' sound name "{sound}"'
return ["osascript", "-e", script]
def _desktop_windows(executable, title, body):
safe_title = title.replace("'", "''")
safe_body = body.replace("'", "''")
script = (
"[reflection.assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; "
"[reflection.assembly]::LoadWithPartialName('System.Drawing') | Out-Null; "
"$n = New-Object System.Windows.Forms.NotifyIcon; "
"$n.Icon = [System.Drawing.SystemIcons]::Information; "
"$n.Visible = $true; "
f"$n.ShowBalloonTip(10000, '{safe_title}', '{safe_body}', "
"[System.Windows.Forms.ToolTipIcon]::Info); "
"Start-Sleep -Seconds 6; "
"$n.Dispose()"
)
return [
executable,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
]
def _desktop_linux(backend, title, body):
if backend == "notify-send":
return ["notify-send", title, body]
if backend == "kdialog":
return ["kdialog", "--title", title, "--passivepopup", body, "10"]
return ["zenity", "--notification", "--text", f"{title}\n{body}"]
def _send_desktop_blocking(settings, title, body):
backend = desktop_backend()
if backend is None:
if sys.platform.startswith("linux"):
raise RuntimeError(
"no desktop notifier found. Install libnotify-bin "
"(apt install libnotify-bin) or use a push channel instead."
)
raise RuntimeError(f"no desktop notifier available on {sys.platform}")
if backend == "osascript":
command = _desktop_macos(settings, title, body)
elif backend in ("powershell", "pwsh"):
command = _desktop_windows(backend, title, body)
else:
command = _desktop_linux(backend, title, body)
result = subprocess.run(
command, capture_output=True, text=True, timeout=SEND_TIMEOUT
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or f"{backend} failed")
async def _send_desktop(session, settings, title, body, priority):
await asyncio.get_running_loop().run_in_executor(
None, _send_desktop_blocking, settings, title, body
)
SENDERS = {
"ntfy": _send_ntfy,
"pushover": _send_pushover,
"telegram": _send_telegram,
"webhook": _send_webhook,
"email": _send_email,
"desktop": _send_desktop,
"macos": _send_desktop,
}
class Notifier:
def __init__(self, settings, reporter=None, dry_run=False):
self.settings = settings
self.reporter = reporter or (lambda message: None)
self.dry_run = dry_run
self.config = load_config()
self._last_sent = {}
self._last_message = {}
def available(self):
configured = enabled_channels(self.config)
wanted = self.settings.channels
if not wanted:
return configured
return [name for name in wanted if name in configured]
def missing(self):
configured = set(enabled_channels(self.config))
return [name for name in (self.settings.channels or []) if name not in configured]
def throttled(self, key, message, now):
window = self.settings.throttle or 0
last_at = self._last_sent.get(key)
if self._last_message.get(key) == message and last_at and window:
if now - last_at < window:
return f"identical message within {int(window)}s"
if last_at and window and now - last_at < window:
return f"throttled, {int(window - (now - last_at))}s remaining"
return None
async def send(self, title, body, priority=None, key="default", force=False):
channels = self.available()
if not channels:
return False
now = time.monotonic()
if not force:
blocked = self.throttled(key, body, now)
if blocked:
self.reporter(f"notification suppressed: {blocked}")
return False
if self.dry_run:
self.reporter(f"DRY RUN would notify via {', '.join(channels)}: {body}")
self._last_sent[key] = now
self._last_message[key] = body
return True
sent = 0
async with aiohttp.ClientSession() as session:
for name in channels:
sender = SENDERS.get(name)
if sender is None:
continue
try:
await sender(session, self.config.get(name) or {}, title, body, priority)
sent += 1
except Exception as err:
self.reporter(
f"notification via {name} failed: {type(err).__name__}: {err}"
)
if sent:
self._last_sent[key] = now
self._last_message[key] = body
self.reporter(f"notified via {sent} channel(s)")
return sent > 0
async def send_test(channel=None, message=None):
ensure_config()
config = load_config()
available = enabled_channels(config)
if channel:
if channel not in SENDERS:
raise ValueError(f"unknown channel {channel!r}. Known: {sorted(SENDERS)}")
if channel not in available:
raise ValueError(
f"channel {channel!r} is not enabled in {paths.relative(CONFIG_PATH)}"
)
available = [channel]
if not available:
raise ValueError(
f"no channels enabled. Edit {paths.relative(CONFIG_PATH)} and set "
"enabled: true on at least one."
)
title = "solixauto test"
body = message or "Test notification. If you can read this, the channel works."
results = {}
async with aiohttp.ClientSession() as session:
for name in available:
try:
await SENDERS[name](session, config.get(name) or {}, title, body, None)
results[name] = "ok"
except Exception as err:
results[name] = f"{type(err).__name__}: {err}"
return results