#!/usr/bin/env python3 """ Gotify <-> Home Assistant <-> Discord Multi-Channel Bridge for Snarfnet Homelab. Zero external dependencies (Python 3 standard library only). 1. Watches Home Assistant (http://14.4.4.20:8123) logbook.log broadcast events and publishes new notifications to Gotify ('Home Assistant Smart Home', appid=3). 2. Polls Gotify (http://gotify:80) and routes all 5 broadcast applications to their dedicated Discord channel webhooks (#ups-power, #infra-alerts, #home-assistant) with priority-colored rich embeds, persistent cursor tracking, and HTTP 429 backoff. """ import datetime import json import os import ssl import time import urllib.error import urllib.request GOTIFY_URL = os.environ.get("GOTIFY_URL", "http://gotify:80").rstrip("/") GOTIFY_CLIENT_TOKEN = os.environ.get("GOTIFY_CLIENT_TOKEN", "C1dIsCoRdBrIdGeClIeN") GOTIFY_HA_APP_TOKEN = os.environ.get("GOTIFY_HA_APP_TOKEN", "A3hOmEaSsIsTaNtToKeN") STATE_FILE = os.environ.get("STATE_FILE", "/app/data/discord_bridge_last_id.txt") HA_STATE_FILE = os.environ.get("HA_STATE_FILE", "/app/data/ha_last_logbook_when.txt") POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2.0")) HA_URL = os.environ.get("HA_URL", "http://14.4.4.20:8123").rstrip("/") HA_TOKEN = os.environ.get( "HA_TOKEN", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZWNlMWI5ZjJhZDQ0MTRmODU4NGYzN2I1YjgwZDQxMiIsImlhdCI6MTc5MzQ1MjQ2MywiZXhwIjoyMTA4ODEyNDYzfQ.JznpOqsCSnHz6aTVa8K9pQCz1816DC8SrmS1xn1Sm2k", ) WEBHOOK_UPS = os.environ.get( "WEBHOOK_UPS", "https://discord.com/api/webhooks/1566076118535114813/2e_fCJeHb-mF8VwerzOORGRa-QUDFuwN6R2Gfz-MGUiIOudA0PiXGZSyd19PNFG-vR7D", ) WEBHOOK_INFRA = os.environ.get( "WEBHOOK_INFRA", "https://discord.com/api/webhooks/1566076777963593772/wKN30N0ZXVJKGP-prZ1_P9cp_u7Oakmh8R4bVh0qr8RFpyeGcJZrHfPQBJxtfzQqb4dn", ) WEBHOOK_HA = os.environ.get( "WEBHOOK_HA", "https://discord.com/api/webhooks/1566077255837294684/4x_sTqDeEHfIeGccEutOSvPKAnCV6Fyr7uAuECOt8M8QZcOvL2XZ0GVATW7EmcSyUW33", ) # Map Gotify Application ID -> (Channel Name, Discord Webhook URL) APP_WEBHOOK_MAP = { 1: ("ups-power", WEBHOOK_UPS), 2: ("infra-alerts", WEBHOOK_INFRA), 3: ("home-assistant", WEBHOOK_HA), 4: ("infra-alerts", WEBHOOK_INFRA), 5: ("infra-alerts", WEBHOOK_INFRA), } APP_NAMES = { 1: "UPS & Power Resilience", 2: "Zabbix Infrastructure Alerts", 3: "Home Assistant Smart Home", 4: "AWX & GitOps Automation", 5: "Backups & Storage Health", } ssl_ctx = ssl.create_default_context() def priority_color(priority: int) -> int: if priority >= 8: return 15158332 # Crimson Red if priority >= 5: return 15965202 # Amber Orange return 3447003 # Cobalt Blue def load_last_id(): try: if os.path.exists(STATE_FILE): with open(STATE_FILE, "r", encoding="utf-8") as f: return int(f.read().strip()) except Exception: pass return None def save_last_id(msg_id: int): try: with open(STATE_FILE, "w", encoding="utf-8") as f: f.write(str(msg_id)) except Exception as e: print(f"[WARN] Could not write state file {STATE_FILE}: {e}", flush=True) def load_ha_when() -> str: try: if os.path.exists(HA_STATE_FILE): with open(HA_STATE_FILE, "r", encoding="utf-8") as f: val = f.read().strip() if val: return val except Exception: pass return None def save_ha_when(when_str: str): try: with open(HA_STATE_FILE, "w", encoding="utf-8") as f: f.write(when_str) except Exception as e: print(f"[WARN] Could not write HA state file {HA_STATE_FILE}: {e}", flush=True) def render_ha_template(tpl: str) -> str: if "{{" not in tpl: return tpl try: req = urllib.request.Request( f"{HA_URL}/api/template", data=json.dumps({"template": tpl}).encode("utf-8"), headers={"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=6) as r: return r.read().decode("utf-8").strip() except Exception: return tpl def poll_home_assistant(ha_last_when: str) -> str: if not HA_TOKEN: return ha_last_when ts_query = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=2)).isoformat() req = urllib.request.Request( f"{HA_URL}/api/logbook/{ts_query}?entity=script.snarfnet_broadcast", headers={"Authorization": f"Bearer {HA_TOKEN}", "Accept": "application/json"}, ) with urllib.request.urlopen(req, timeout=8) as r: entries = json.loads(r.read().decode("utf-8")) log_entries = [ e for e in entries if e.get("entity_id") == "script.snarfnet_broadcast" and e.get("message") and e.get("message") != "started" and e.get("when") ] if ha_last_when is None: ha_last_when = max((e["when"] for e in log_entries), default="") save_ha_when(ha_last_when) print(f"[INIT-HA] Initialized Home Assistant logbook cursor at '{ha_last_when}'", flush=True) return ha_last_when for e in sorted(log_entries, key=lambda x: x["when"]): when_str = e["when"] if when_str > ha_last_when: title = render_ha_template(e.get("name") or "Home Assistant Alert") message = render_ha_template(e.get("message") or "(no message)") prio = 8 if any(w in title.lower() or w in message.lower() for w in ["critical", "failed", "stalled", "battery", "outage", "high", "warning", "alarm"]) else 5 t_str = title if title.startswith(("🏠", "🖨️", "✅", "⚠️", "🚨", "⚡", "🌡️")) else f"🏠 {title}" g_req = urllib.request.Request( f"{GOTIFY_URL}/message?token={GOTIFY_HA_APP_TOKEN}", data=json.dumps({"title": t_str, "message": message, "priority": prio}).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(g_req, timeout=6) as gr: gid = json.loads(gr.read().decode("utf-8")).get("id") print(f"[HA->GOTIFY] Published '{title}' as Gotify msg #{gid}", flush=True) ha_last_when = when_str save_ha_when(ha_last_when) return ha_last_when def fetch_messages(): req = urllib.request.Request( f"{GOTIFY_URL}/message?limit=25", headers={"X-Gotify-Key": GOTIFY_CLIENT_TOKEN, "Accept": "application/json"}, ) with urllib.request.urlopen(req, timeout=8) as r: data = json.loads(r.read().decode("utf-8")) return data.get("messages", []) def send_discord(msg: dict) -> bool: app_id = msg.get("appid", 0) chan_label, webhook_url = APP_WEBHOOK_MAP.get(app_id, ("infra-alerts", WEBHOOK_INFRA)) app_name = APP_NAMES.get(app_id, f"Gotify App #{app_id}") priority = int(msg.get("priority", 5)) title = msg.get("title") or app_name body = msg.get("message") or "(no content)" date_str = msg.get("date") embed = { "title": title[:256], "description": body[:4000], "color": priority_color(priority), "footer": {"text": f"Snarfnet Gotify • {app_name} • Priority {priority}"}, } if date_str: embed["timestamp"] = date_str payload = { "username": f"Snarfnet • {app_name}", "embeds": [embed], } for attempt in range(3): try: req = urllib.request.Request( webhook_url, data=json.dumps(payload).encode("utf-8"), headers={ "Content-Type": "application/json", "User-Agent": "SnarfNet-GotifyDiscordBridge/1.0", }, method="POST", ) with urllib.request.urlopen(req, timeout=10, context=ssl_ctx) as r: if r.status in (200, 204): print(f"[OK] Forwarded msg #{msg['id']} ({app_name}) -> #{chan_label}", flush=True) return True except urllib.error.HTTPError as e: if e.code == 429: retry_after = float(e.headers.get("Retry-After", "2.5")) print(f"[RATE-LIMIT] HTTP 429 on #{chan_label}, sleeping {retry_after}s...", flush=True) time.sleep(retry_after) continue print(f"[ERROR] Discord HTTP {e.code} on msg #{msg['id']}: {e.read().decode('utf-8', errors='ignore')}", flush=True) return False except Exception as e: print(f"[ERROR] Discord exception on msg #{msg['id']} (attempt {attempt + 1}): {e}", flush=True) time.sleep(1.5) return False def main(): print(f"[START] Snarfnet Gotify <-> HA <-> Discord Bridge polling {GOTIFY_URL} & {HA_URL}", flush=True) last_id = load_last_id() ha_last_when = load_ha_when() while True: try: ha_last_when = poll_home_assistant(ha_last_when) except Exception as e: print(f"[WARN] HA poll error: {e}", flush=True) try: messages = fetch_messages() if last_id is None: last_id = max((m["id"] for m in messages), default=0) save_last_id(last_id) print(f"[INIT] Initialized cursor at message ID {last_id}", flush=True) else: new_msgs = sorted((m for m in messages if m["id"] > last_id), key=lambda x: x["id"]) for m in new_msgs: send_discord(m) last_id = m["id"] save_last_id(last_id) except Exception as e: print(f"[WARN] Gotify poll error: {e}", flush=True) time.sleep(POLL_INTERVAL) if __name__ == "__main__": main()