feat: add homelab topology documentation, infrastructure improvement plans, and management scripts
This commit is contained in:
266
scripts/gotify_discord_bridge.py
Normal file
266
scripts/gotify_discord_bridge.py
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user