#!/usr/bin/env python3 """ntfy -> Matrix bridge. Watches a set of ntfy topics over the streaming JSON API and forwards every notification to a Matrix room. Designed to run as a long-lived daemon: * one streaming connection per mapping, with exponential-backoff reconnect * per-mapping resume using ntfy's `since=` so nothing is lost across reconnects or process restarts * a single Matrix sender with retry/backoff; the resume cursor only advances after a message is actually accepted by the homeserver (at-least-once) This handles UNENCRYPTED Matrix rooms only. The target room must not be end-to-end encrypted, or messages will be sent in the clear and ignored by clients. """ from __future__ import annotations import argparse import asyncio import base64 import html import json import logging import os import re import signal import sys from collections import deque from typing import Any, Optional import aiohttp import yaml from nio import AsyncClient, AsyncClientConfig, RoomSendResponse log = logging.getLogger("ntfy-matrix") # --------------------------------------------------------------------------- # # Presentation helpers # --------------------------------------------------------------------------- # # A small subset of ntfy's emoji short-code -> emoji map. Unknown tags are # shown as text labels instead. (ntfy itself does something similar.) EMOJI_MAP = { "warning": "⚠️", "rotating_light": "🚨", "fire": "🔥", "skull": "💀", "bug": "🐛", "tada": "🎉", "partying_face": "🥳", "white_check_mark": "✅", "heavy_check_mark": "✔️", "x": "❌", "no_entry": "⛔", "bell": "🔔", "loudspeaker": "📢", "package": "📦", "computer": "💻", "floppy_disk": "💾", "zzz": "💤", "robot": "🤖", "lock": "🔒", "unlock": "🔓", "green_circle": "🟢", "yellow_circle": "🟡", "red_circle": "🔴", "+1": "👍", "-1": "👎", } # Prepended for elevated priorities (4 = high, 5 = max/urgent). PRIORITY_EMOJI = {4: "🟠", 5: "🔴"} def _split_tags(tags: list[str]) -> tuple[list[str], list[str]]: emojis, texts = [], [] for t in tags: e = EMOJI_MAP.get(t) (emojis.append(e) if e else texts.append(t)) return emojis, texts def build_content(mapping: dict, msg: dict) -> dict: """Turn an ntfy message object into a Matrix m.room.message content dict.""" title = msg.get("title") body = msg.get("message", "") priority = msg.get("priority", 3) tags = msg.get("tags") or [] click = msg.get("click") attachment = msg.get("attachment") or {} emoji_tags, text_tags = _split_tags(tags) lead = "".join(emoji_tags) if priority in PRIORITY_EMOJI: lead = PRIORITY_EMOJI[priority] + lead # ---- plain text body ---- lines: list[str] = [] header = " ".join(p for p in (lead, title) if p).strip() if header: lines.append(header) if body: lines.append(body) if text_tags: lines.append("Tags: " + ", ".join(text_tags)) if click: lines.append("Link: " + click) if attachment.get("url"): lines.append("Attachment: " + attachment["url"]) plain = "\n".join(lines) if lines else "(empty notification)" # ---- HTML formatted body ---- esc = html.escape h: list[str] = [] head_html = " ".join( p for p in (lead, f"{esc(title)}" if title else "") if p ).strip() if head_html: h.append(head_html) if body: h.append(esc(body).replace("\n", "
")) if text_tags: h.append(f"Tags: {esc(', '.join(text_tags))}") if click: h.append(f'{esc(click)}') if attachment.get("url"): a_url = attachment["url"] a_name = attachment.get("name", a_url) h.append(f'📎 {esc(a_name)}') formatted = "
".join(h) return { "msgtype": mapping.get("msgtype", "m.text"), "body": plain, "format": "org.matrix.custom.html", "formatted_body": formatted, } # --------------------------------------------------------------------------- # # Config + state # --------------------------------------------------------------------------- # _ENV_RE = re.compile(r"\$\{([^}]+)\}") def _expand_env(obj: Any) -> Any: """Recursively replace ${VAR} in string values with environment variables. Raises if a referenced variable is unset or empty, so a missing secret fails loudly at startup instead of silently degrading (e.g. an empty token turning into an anonymous request that 403s later). """ if isinstance(obj, str): def repl(m: "re.Match") -> str: var = m.group(1) val = os.environ.get(var) if not val: raise ValueError( f"config references ${{{var}}} but that environment " f"variable is unset or empty" ) return val return _ENV_RE.sub(repl, obj) if isinstance(obj, list): return [_expand_env(x) for x in obj] if isinstance(obj, dict): return {k: _expand_env(v) for k, v in obj.items()} return obj def load_config(path: str) -> dict: with open(path) as f: cfg = _expand_env(yaml.safe_load(f)) if "matrix" not in cfg or "mappings" not in cfg: raise ValueError("config must contain 'matrix' and 'mappings' sections") m = cfg["matrix"] if not m.get("access_token"): m["access_token"] = os.environ.get("MATRIX_ACCESS_TOKEN", "") if not m.get("access_token"): raise ValueError( "no Matrix access token (set matrix.access_token or " "the MATRIX_ACCESS_TOKEN env var)" ) for key in ("homeserver", "user_id"): if not m.get(key): raise ValueError(f"matrix.{key} is required") names = set() for mp in cfg["mappings"]: for key in ("name", "ntfy_server", "topics", "room"): if not mp.get(key): raise ValueError(f"each mapping needs '{key}' (offending: {mp})") if mp["name"] in names: raise ValueError(f"duplicate mapping name: {mp['name']}") names.add(mp["name"]) return cfg class State: """Tiny JSON-backed store of the last forwarded message id per mapping.""" def __init__(self, path: str): self.path = path self.data: dict[str, dict] = {} self._lock = asyncio.Lock() def load(self) -> None: if os.path.exists(self.path): with open(self.path) as f: self.data = json.load(f) def last_id(self, name: str) -> Optional[str]: return self.data.get(name, {}).get("last_id") def set_last_id(self, name: str, mid: str) -> None: self.data.setdefault(name, {})["last_id"] = mid async def save(self) -> None: async with self._lock: tmp = self.path + ".tmp" with open(tmp, "w") as f: json.dump(self.data, f, indent=2) os.replace(tmp, self.path) # atomic replace # --------------------------------------------------------------------------- # # ntfy side: one streaming subscription per mapping # --------------------------------------------------------------------------- # def _auth_headers(mapping: dict) -> dict: if mapping.get("token"): return {"Authorization": f"Bearer {mapping['token']}"} if mapping.get("username") is not None: raw = f"{mapping['username']}:{mapping.get('password', '')}".encode() return {"Authorization": "Basic " + base64.b64encode(raw).decode()} return {} async def subscribe_loop( mapping: dict, queue: "asyncio.Queue", state: State, stop: asyncio.Event, ) -> None: name = mapping["name"] base = mapping["ntfy_server"].rstrip("/") topics = ",".join(mapping["topics"]) url = f"{base}/{topics}/json" headers = _auth_headers(mapping) prio_min = mapping.get("priority_min") auth_mode = ( "bearer" if mapping.get("token") else "basic" if mapping.get("username") is not None else "none" ) log.info("[%s] target %s (auth: %s)", name, url, auth_mode) # Bounded dedup of recently seen ids, seeded with the persisted boundary so # a replayed `since=` message isn't forwarded twice after a restart. seen: deque[str] = deque(maxlen=2000) seen_set: set[str] = set() boundary = state.last_id(name) if boundary: seen.append(boundary) seen_set.add(boundary) def remember(mid: str) -> None: if len(seen) == seen.maxlen: seen_set.discard(seen[0]) seen.append(mid) seen_set.add(mid) timeout = aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=120) backoff = 1 async with aiohttp.ClientSession(timeout=timeout) as session: while not stop.is_set(): params = {} last = state.last_id(name) if last: params["since"] = last try: async with session.get(url, headers=headers, params=params) as resp: if resp.status == 401 or resp.status == 403: log.error("[%s] auth failed (HTTP %s); check token/credentials", name, resp.status) await asyncio.sleep(min(backoff, 60)) backoff = min(backoff * 2, 60) continue resp.raise_for_status() log.info("[%s] subscribed to %s (topics: %s)", name, base, topics) backoff = 1 # connected; reset backoff for the next drop async for raw in resp.content: if stop.is_set(): break line = raw.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: log.warning("[%s] non-JSON line skipped: %r", name, line[:200]) continue if obj.get("event") != "message": continue # open / keepalive / poll_request mid = obj.get("id") if not mid or mid in seen_set: continue if prio_min and obj.get("priority", 3) < prio_min: remember(mid) continue remember(mid) await queue.put((mapping, obj)) except asyncio.CancelledError: raise except Exception as e: # noqa: BLE001 - daemon must keep running log.warning("[%s] stream error: %s; reconnecting in %ds", name, e, backoff) try: await asyncio.wait_for(stop.wait(), timeout=backoff) except asyncio.TimeoutError: pass backoff = min(backoff * 2, 60) # --------------------------------------------------------------------------- # # Matrix side: a single sender drains the queue with retry/backoff # --------------------------------------------------------------------------- # async def sender_loop( client: AsyncClient, queue: "asyncio.Queue", state: State, stop: asyncio.Event, ) -> None: while not stop.is_set(): mapping, msg = await queue.get() try: delay = 1 while not stop.is_set(): try: resp = await client.room_send( room_id=mapping["room"], message_type="m.room.message", content=build_content(mapping, msg), ) if isinstance(resp, RoomSendResponse): state.set_last_id(mapping["name"], msg["id"]) await state.save() log.info("[%s] forwarded %s -> %s", mapping["name"], msg["id"], mapping["room"]) break log.warning("[%s] send rejected: %s; retrying in %ds", mapping["name"], resp, delay) except asyncio.CancelledError: raise except Exception as e: # noqa: BLE001 log.warning("[%s] send error: %s; retrying in %ds", mapping["name"], e, delay) try: await asyncio.wait_for(stop.wait(), timeout=delay) except asyncio.TimeoutError: pass delay = min(delay * 2, 60) finally: queue.task_done() # --------------------------------------------------------------------------- # # Wiring # --------------------------------------------------------------------------- # async def run(cfg: dict) -> None: state = State(cfg.get("state_file", "./state.json")) state.load() mc = cfg["matrix"] client = AsyncClient( mc["homeserver"], mc["user_id"], config=AsyncClientConfig(request_timeout=30), ) client.access_token = mc["access_token"] client.user_id = mc["user_id"] if mc.get("device_id"): client.device_id = mc["device_id"] queue: asyncio.Queue = asyncio.Queue() stop = asyncio.Event() loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, stop.set) except NotImplementedError: pass # e.g. on Windows tasks = [asyncio.create_task(sender_loop(client, queue, state, stop))] for mp in cfg["mappings"]: tasks.append(asyncio.create_task(subscribe_loop(mp, queue, state, stop))) log.info("bridge started with %d mapping(s)", len(cfg["mappings"])) await stop.wait() log.info("shutting down...") for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) await state.save() await client.close() log.info("stopped cleanly") def main() -> None: ap = argparse.ArgumentParser(description="Forward ntfy notifications to Matrix.") ap.add_argument("-c", "--config", default="config.yaml", help="path to config YAML") args = ap.parse_args() logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)-7s %(message)s", ) try: cfg = load_config(args.config) except (OSError, ValueError) as e: log.error("config error: %s", e) sys.exit(1) try: asyncio.run(run(cfg)) except KeyboardInterrupt: pass if __name__ == "__main__": main()