Files

116 lines
3.9 KiB
Python
Raw Permalink Normal View History

2026-08-23 08:16:15 +02:00
#!/usr/bin/env python3
"""Send text notifications to a configured ntfy topic."""
import argparse
import logging
import os
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LOG = logging.getLogger("home_control.notify")
def send_notification(message, topic_url=None, timeout=10):
"""Send *message* to ntfy and return the server response body."""
if not isinstance(message, str):
raise TypeError("message must be a string")
if not message:
raise ValueError("message must not be empty")
url = topic_url or os.environ.get("NTFY_TOPIC_URL")
if not url:
raise ValueError("topic_url is required (or set NTFY_TOPIC_URL)")
request = Request(
url,
data=message.encode("utf-8"),
headers={"Content-Type": "text/plain; charset=utf-8"},
method="POST",
)
with urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8")
def _filter_values(config: dict, mode: str, field: str) -> set[str]:
value = config.get("filter", {}).get(mode, {}).get(field, [])
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
raise ValueError(f"notify.filter.{mode}.{field} must be a list of strings")
return set(value)
def accepts_event(config: dict, message: dict) -> bool:
"""Return whether an event passes configured event and ID filters."""
accepted_events = _filter_values(config, "accept", "events")
accepted_ids = _filter_values(config, "accept", "ids")
ignored_events = _filter_values(config, "ignore", "events")
ignored_ids = _filter_values(config, "ignore", "ids")
if message["event"] in ignored_events or message["id"] in ignored_ids:
return False
if accepted_events and message["event"] not in accepted_events:
return False
if accepted_ids and message["id"] not in accepted_ids:
return False
return True
def on_event(config: dict, message: dict) -> None:
"""Generic controller action for event dictionaries."""
if not config.get("enabled", True):
return
if not accepts_event(config, message):
LOG.debug(
"notification ignored by filter: event=%s id=%s",
message["event"],
message["id"],
)
return
messages = config.get("messages", {})
template = messages.get(message["event"], config.get("message", "{sender}: {event} ({id})"))
text = message["text"] or str(template).format(**message)
try:
send_notification(
text,
topic_url=config.get("topic_url") or None,
timeout=float(config.get("timeout", 10)),
)
except (OSError, RuntimeError, ValueError) as error:
LOG.error(
"notification failed for %s/%s/%s: %s",
message["sender"],
message["event"],
message["id"],
error,
)
def parse_arguments():
parser = argparse.ArgumentParser(description="Send a text message to an ntfy topic.")
parser.add_argument("message", nargs="+", help="notification text")
parser.add_argument("--topic-url", help="ntfy topic URL (or use NTFY_TOPIC_URL)")
parser.add_argument("--timeout", type=float, default=10, help="request timeout in seconds")
return parser.parse_args()
def main():
args = parse_arguments()
try:
print(
send_notification(
" ".join(args.message),
topic_url=args.topic_url,
timeout=args.timeout,
)
)
return 0
except HTTPError as error:
print(f"ntfy returned HTTP {error.code}: {error.reason}", file=sys.stderr)
except URLError as error:
print(f"Could not reach ntfy: {error.reason}", file=sys.stderr)
except (TypeError, ValueError) as error:
print(f"Invalid notification: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())