Files
SnarfCode/scripts/gotify_discord_bridge.py

267 lines
9.8 KiB
Python

#!/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()