267 lines
9.8 KiB
Python
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()
|