app.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. #!/usr/bin/env python3
  2. """
  3. Dis2Hook - Webhook Relay to Discord Bot
  4. =======================================
  5. Receives webhooks from any source, formats them as Markdown, and relays
  6. them to Discord channels through the Discord Bot API.
  7. Runs inside an Alpine Linux LXC. Configuration lives in two files next
  8. to this script:
  9. token.json - secrets (bot token, admin key). Generated at install
  10. time, never stored in the repo. chmod 600.
  11. config.json - sources, heartbeat and status settings. Managed through
  12. the web UI, safe to back up.
  13. Repo: https://gogs.av2x.dev/av2x/Dis2Hook
  14. """
  15. import atexit
  16. import json
  17. import os
  18. import re
  19. import secrets as pysecrets
  20. import signal
  21. import sys
  22. import threading
  23. import time
  24. from collections import deque
  25. from datetime import datetime, timezone
  26. import requests
  27. from flask import Flask, jsonify, request, send_from_directory
  28. DIS2HOOK_VERSION = "1.0.0"
  29. BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  30. TOKEN_FILE = os.path.join(BASE_DIR, "token.json")
  31. CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
  32. DISCORD_API = "https://discord.com/api/v10"
  33. DISCORD_MSG_LIMIT = 2000
  34. app = Flask(__name__, static_folder=None)
  35. # ---------------------------------------------------------------------------
  36. # State
  37. # ---------------------------------------------------------------------------
  38. _lock = threading.RLock()
  39. _started_at = time.time()
  40. _stats = {"received": 0, "relayed": 0, "filtered": 0, "failed": 0}
  41. _activity = deque(maxlen=50) # ring buffer of recent events for the UI
  42. _bot_identity = {"checked": 0, "ok": False, "name": None, "id": None}
  43. def _log(msg):
  44. ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
  45. print(f"[{ts}] {msg}", flush=True)
  46. def _record(source_name, outcome, detail=""):
  47. with _lock:
  48. _activity.appendleft({
  49. "time": datetime.now(timezone.utc).isoformat(),
  50. "source": source_name,
  51. "outcome": outcome, # relayed | filtered | failed | rejected
  52. "detail": detail[:200],
  53. })
  54. # ---------------------------------------------------------------------------
  55. # Config / secrets
  56. # ---------------------------------------------------------------------------
  57. DEFAULT_CONFIG = {
  58. "listen_host": "0.0.0.0",
  59. "listen_port": 8823,
  60. "heartbeat": {
  61. "enabled": False,
  62. "channel_id": "",
  63. "interval_minutes": 60,
  64. },
  65. "status": {
  66. "enabled": False,
  67. "channel_id": "",
  68. },
  69. "sources": [],
  70. }
  71. def load_tokens():
  72. if not os.path.exists(TOKEN_FILE):
  73. _log(f"FATAL: {TOKEN_FILE} not found. Run the installer to generate it.")
  74. sys.exit(1)
  75. with open(TOKEN_FILE, "r", encoding="utf-8") as f:
  76. data = json.load(f)
  77. if not data.get("bot_token") or not data.get("admin_key"):
  78. _log("FATAL: token.json must contain 'bot_token' and 'admin_key'.")
  79. sys.exit(1)
  80. return data
  81. def load_config():
  82. if not os.path.exists(CONFIG_FILE):
  83. save_config(DEFAULT_CONFIG)
  84. return json.loads(json.dumps(DEFAULT_CONFIG))
  85. with open(CONFIG_FILE, "r", encoding="utf-8") as f:
  86. cfg = json.load(f)
  87. # Fill any missing keys so older configs keep working after updates.
  88. merged = json.loads(json.dumps(DEFAULT_CONFIG))
  89. merged.update({k: v for k, v in cfg.items() if k in ("listen_host", "listen_port", "sources")})
  90. for section in ("heartbeat", "status"):
  91. merged[section].update(cfg.get(section, {}))
  92. return merged
  93. def save_config(cfg):
  94. tmp = CONFIG_FILE + ".tmp"
  95. with open(tmp, "w", encoding="utf-8") as f:
  96. json.dump(cfg, f, indent=2)
  97. os.replace(tmp, CONFIG_FILE)
  98. TOKENS = load_tokens()
  99. CONFIG = load_config()
  100. def get_source(source_id):
  101. with _lock:
  102. for s in CONFIG.get("sources", []):
  103. if s.get("id") == source_id:
  104. return s
  105. return None
  106. # ---------------------------------------------------------------------------
  107. # Discord Bot API
  108. # ---------------------------------------------------------------------------
  109. def discord_send(channel_id, content):
  110. """Send a message to a channel via the bot API. Returns (ok, detail)."""
  111. if not channel_id:
  112. return False, "no channel configured"
  113. if len(content) > DISCORD_MSG_LIMIT:
  114. content = content[: DISCORD_MSG_LIMIT - 25] + "\n*(message truncated)*"
  115. url = f"{DISCORD_API}/channels/{channel_id}/messages"
  116. headers = {
  117. "Authorization": f"Bot {TOKENS['bot_token']}",
  118. "User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}",
  119. }
  120. body = {"content": content, "allowed_mentions": {"parse": []}}
  121. for attempt in (1, 2):
  122. try:
  123. r = requests.post(url, json=body, headers=headers, timeout=15)
  124. except requests.RequestException as e:
  125. return False, f"network error: {e}"
  126. if r.status_code == 429 and attempt == 1:
  127. try:
  128. wait = float(r.json().get("retry_after", 1.0))
  129. except Exception:
  130. wait = 1.0
  131. time.sleep(min(wait, 5.0))
  132. continue
  133. if 200 <= r.status_code < 300:
  134. return True, "sent"
  135. return False, f"discord api {r.status_code}: {r.text[:150]}"
  136. return False, "rate limited"
  137. def check_bot_identity(force=False):
  138. """Verify the bot token by asking Discord who we are. Cached 5 minutes."""
  139. with _lock:
  140. fresh = (time.time() - _bot_identity["checked"]) < 300
  141. if fresh and not force:
  142. return dict(_bot_identity)
  143. try:
  144. r = requests.get(
  145. f"{DISCORD_API}/users/@me",
  146. headers={"Authorization": f"Bot {TOKENS['bot_token']}",
  147. "User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}"},
  148. timeout=10,
  149. )
  150. ok = r.status_code == 200
  151. data = r.json() if ok else {}
  152. except requests.RequestException:
  153. ok, data = False, {}
  154. with _lock:
  155. _bot_identity.update({
  156. "checked": time.time(),
  157. "ok": ok,
  158. "name": data.get("username"),
  159. "id": data.get("id"),
  160. })
  161. return dict(_bot_identity)
  162. # ---------------------------------------------------------------------------
  163. # Payload formatting
  164. # ---------------------------------------------------------------------------
  165. _PLACEHOLDER = re.compile(r"\{([a-zA-Z0-9_.\[\]-]+)\}")
  166. def dig(payload, path):
  167. """Resolve a dotted path like 'commits[0].message' inside a payload."""
  168. cur = payload
  169. for part in re.split(r"\.", path):
  170. m = re.match(r"^([a-zA-Z0-9_-]*)((\[\d+\])*)$", part)
  171. if not m:
  172. return None
  173. key, indexes = m.group(1), m.group(2)
  174. if key:
  175. if not isinstance(cur, dict) or key not in cur:
  176. return None
  177. cur = cur[key]
  178. for idx in re.findall(r"\[(\d+)\]", indexes or ""):
  179. i = int(idx)
  180. if not isinstance(cur, list) or i >= len(cur):
  181. return None
  182. cur = cur[i]
  183. return cur
  184. def render_template(template, payload):
  185. def sub(m):
  186. val = dig(payload, m.group(1))
  187. if val is None:
  188. return ""
  189. if isinstance(val, (dict, list)):
  190. return json.dumps(val, indent=2)[:500]
  191. return str(val)
  192. return _PLACEHOLDER.sub(sub, template)
  193. def default_format(source_name, payload):
  194. pretty = json.dumps(payload, indent=2, ensure_ascii=False)
  195. if len(pretty) > 1700:
  196. pretty = pretty[:1700] + "\n…"
  197. return f"**{source_name}** received an event:\n```json\n{pretty}\n```"
  198. def passes_filters(source, payload):
  199. """All filters must match (AND). No filters = everything passes."""
  200. for flt in source.get("filters", []):
  201. path = (flt.get("path") or "").strip()
  202. if not path:
  203. continue
  204. val = dig(payload, path)
  205. val_str = "" if val is None else str(val)
  206. mode = flt.get("mode", "equals")
  207. want = str(flt.get("value", ""))
  208. if mode == "equals" and val_str != want:
  209. return False
  210. if mode == "contains" and want not in val_str:
  211. return False
  212. if mode == "exists" and val is None:
  213. return False
  214. return True
  215. # ---------------------------------------------------------------------------
  216. # Auth helpers
  217. # ---------------------------------------------------------------------------
  218. def admin_authorized():
  219. supplied = request.headers.get("X-Admin-Key", "")
  220. return pysecrets.compare_digest(supplied, TOKENS["admin_key"])
  221. def require_admin():
  222. if not admin_authorized():
  223. return jsonify({"error": "invalid admin key"}), 401
  224. return None
  225. # ---------------------------------------------------------------------------
  226. # Routes: web UI
  227. # ---------------------------------------------------------------------------
  228. @app.route("/")
  229. @app.route("/index.html")
  230. def ui():
  231. return send_from_directory(BASE_DIR, "index.html")
  232. # ---------------------------------------------------------------------------
  233. # Routes: admin API
  234. # ---------------------------------------------------------------------------
  235. @app.route("/api/login", methods=["POST"])
  236. def api_login():
  237. """Lets the UI validate the admin key without exposing anything."""
  238. if admin_authorized():
  239. return jsonify({"ok": True})
  240. return jsonify({"ok": False}), 401
  241. @app.route("/api/status")
  242. def api_status():
  243. err = require_admin()
  244. if err:
  245. return err
  246. ident = check_bot_identity()
  247. with _lock:
  248. return jsonify({
  249. "version": DIS2HOOK_VERSION,
  250. "uptime_seconds": int(time.time() - _started_at),
  251. "stats": dict(_stats),
  252. "activity": list(_activity),
  253. "bot": {"ok": ident["ok"], "name": ident["name"], "id": ident["id"]},
  254. })
  255. @app.route("/api/config", methods=["GET"])
  256. def api_get_config():
  257. err = require_admin()
  258. if err:
  259. return err
  260. with _lock:
  261. return jsonify(CONFIG)
  262. @app.route("/api/config", methods=["PUT"])
  263. def api_put_config():
  264. err = require_admin()
  265. if err:
  266. return err
  267. data = request.get_json(silent=True)
  268. if not isinstance(data, dict):
  269. return jsonify({"error": "body must be a JSON object"}), 400
  270. problems = validate_config(data)
  271. if problems:
  272. return jsonify({"error": "; ".join(problems)}), 400
  273. with _lock:
  274. # listen_host / listen_port are only changeable by editing config.json
  275. # directly, so a UI save can never lock you out of the UI.
  276. data["listen_host"] = CONFIG["listen_host"]
  277. data["listen_port"] = CONFIG["listen_port"]
  278. CONFIG.clear()
  279. CONFIG.update(data)
  280. save_config(CONFIG)
  281. _log("Configuration saved via web UI.")
  282. return jsonify({"ok": True})
  283. def validate_config(data):
  284. problems = []
  285. sources = data.get("sources")
  286. if not isinstance(sources, list):
  287. return ["'sources' must be a list"]
  288. seen_ids = set()
  289. for i, s in enumerate(sources):
  290. label = s.get("name") or f"source {i + 1}"
  291. sid = (s.get("id") or "").strip()
  292. if not re.match(r"^[a-z0-9][a-z0-9-]{1,63}$", sid):
  293. problems.append(f"{label}: endpoint id must be 2-64 chars of a-z, 0-9, '-'")
  294. if sid in seen_ids:
  295. problems.append(f"{label}: duplicate endpoint id '{sid}'")
  296. seen_ids.add(sid)
  297. if not (s.get("secret") or "").strip():
  298. problems.append(f"{label}: secret must not be empty")
  299. if not re.match(r"^\d{5,25}$", str(s.get("channel_id", ""))):
  300. problems.append(f"{label}: channel ID must be numeric")
  301. for section in ("heartbeat", "status"):
  302. sec = data.get(section, {})
  303. if sec.get("enabled") and not re.match(r"^\d{5,25}$", str(sec.get("channel_id", ""))):
  304. problems.append(f"{section}: channel ID must be numeric when enabled")
  305. hb = data.get("heartbeat", {})
  306. try:
  307. if hb.get("enabled") and not (1 <= int(hb.get("interval_minutes", 0)) <= 10080):
  308. problems.append("heartbeat: interval must be 1-10080 minutes")
  309. except (TypeError, ValueError):
  310. problems.append("heartbeat: interval must be a number")
  311. return problems
  312. @app.route("/api/test/<source_id>", methods=["POST"])
  313. def api_test(source_id):
  314. err = require_admin()
  315. if err:
  316. return err
  317. source = get_source(source_id)
  318. if not source:
  319. return jsonify({"error": "unknown source"}), 404
  320. sample = {
  321. "event": "dis2hook.test",
  322. "message": "Test relay from the Dis2Hook web UI",
  323. "time": datetime.now(timezone.utc).isoformat(),
  324. }
  325. template = (source.get("template") or "").strip()
  326. content = render_template(template, sample) if template else default_format(source.get("name", source_id), sample)
  327. content = f"🧪 **Test** — {source.get('name', source_id)}\n{content}"
  328. ok, detail = discord_send(source.get("channel_id", ""), content)
  329. _record(source.get("name", source_id), "relayed" if ok else "failed", f"test: {detail}")
  330. return (jsonify({"ok": True}) if ok
  331. else (jsonify({"ok": False, "error": detail}), 502))
  332. # ---------------------------------------------------------------------------
  333. # Routes: webhook receiver
  334. # ---------------------------------------------------------------------------
  335. def extract_payload(req):
  336. payload = req.get_json(silent=True)
  337. if payload is None and "payload" in req.form:
  338. # Some services (e.g. legacy GitHub/Slack style) post form-encoded JSON.
  339. try:
  340. payload = json.loads(req.form["payload"])
  341. except (ValueError, TypeError):
  342. payload = None
  343. if payload is None:
  344. raw = req.get_data(as_text=True)[:2000]
  345. payload = {"raw": raw}
  346. if not isinstance(payload, dict):
  347. payload = {"payload": payload}
  348. return payload
  349. def supplied_secret(req):
  350. return (
  351. req.headers.get("X-Hook-Secret")
  352. or req.headers.get("Authorization", "").removeprefix("Bearer ").strip()
  353. or req.args.get("secret", "")
  354. )
  355. @app.route("/hook/<source_id>", methods=["POST"])
  356. def receive_hook(source_id):
  357. with _lock:
  358. _stats["received"] += 1
  359. source = get_source(source_id)
  360. if not source or not source.get("enabled", True):
  361. _record(source_id, "rejected", "unknown or disabled source")
  362. return jsonify({"error": "unknown source"}), 404
  363. if not pysecrets.compare_digest(supplied_secret(request), source.get("secret", "")):
  364. _record(source.get("name", source_id), "rejected", "bad secret")
  365. return jsonify({"error": "invalid secret"}), 401
  366. payload = extract_payload(request)
  367. if not passes_filters(source, payload):
  368. with _lock:
  369. _stats["filtered"] += 1
  370. _record(source.get("name", source_id), "filtered", "did not match filters")
  371. return jsonify({"ok": True, "relayed": False, "reason": "filtered"})
  372. template = (source.get("template") or "").strip()
  373. content = render_template(template, payload) if template else ""
  374. if not content.strip():
  375. content = default_format(source.get("name", source_id), payload)
  376. ok, detail = discord_send(source.get("channel_id", ""), content)
  377. with _lock:
  378. _stats["relayed" if ok else "failed"] += 1
  379. _record(source.get("name", source_id), "relayed" if ok else "failed", detail)
  380. if not ok:
  381. _log(f"Relay failed for '{source_id}': {detail}")
  382. return jsonify({"ok": False, "error": detail}), 502
  383. return jsonify({"ok": True, "relayed": True})
  384. # ---------------------------------------------------------------------------
  385. # Heartbeat + status updates
  386. # ---------------------------------------------------------------------------
  387. def _uptime_text():
  388. secs = int(time.time() - _started_at)
  389. d, rem = divmod(secs, 86400)
  390. h, rem = divmod(rem, 3600)
  391. m = rem // 60
  392. parts = ([f"{d}d"] if d else []) + ([f"{h}h"] if h or d else []) + [f"{m}m"]
  393. return " ".join(parts)
  394. def heartbeat_loop():
  395. while True:
  396. with _lock:
  397. hb = dict(CONFIG.get("heartbeat", {}))
  398. interval = max(1, int(hb.get("interval_minutes", 60) or 60)) * 60
  399. time.sleep(interval)
  400. with _lock:
  401. hb = dict(CONFIG.get("heartbeat", {}))
  402. stats = dict(_stats)
  403. if hb.get("enabled") and hb.get("channel_id"):
  404. msg = (f"💓 **Dis2Hook heartbeat** — up {_uptime_text()} · "
  405. f"{stats['relayed']} relayed · {stats['failed']} failed")
  406. ok, detail = discord_send(hb["channel_id"], msg)
  407. if not ok:
  408. _log(f"Heartbeat send failed: {detail}")
  409. def send_status(text):
  410. with _lock:
  411. st = dict(CONFIG.get("status", {}))
  412. if st.get("enabled") and st.get("channel_id"):
  413. discord_send(st["channel_id"], text)
  414. _shutdown_sent = False
  415. def on_shutdown(*_args):
  416. global _shutdown_sent
  417. if not _shutdown_sent:
  418. _shutdown_sent = True
  419. send_status(f"🔴 **Dis2Hook** v{DIS2HOOK_VERSION} going offline.")
  420. if _args: # invoked as a signal handler, not via atexit
  421. sys.exit(0)
  422. # ---------------------------------------------------------------------------
  423. # Main
  424. # ---------------------------------------------------------------------------
  425. if __name__ == "__main__":
  426. _log(f"Dis2Hook v{DIS2HOOK_VERSION} starting on "
  427. f"{CONFIG['listen_host']}:{CONFIG['listen_port']}")
  428. ident = check_bot_identity(force=True)
  429. if ident["ok"]:
  430. _log(f"Bot token OK — connected as {ident['name']} ({ident['id']})")
  431. else:
  432. _log("WARNING: bot token could not be verified with Discord. "
  433. "Relays will fail until token.json contains a valid token.")
  434. threading.Thread(target=heartbeat_loop, daemon=True).start()
  435. signal.signal(signal.SIGTERM, on_shutdown)
  436. signal.signal(signal.SIGINT, on_shutdown)
  437. atexit.register(on_shutdown)
  438. send_status(f"🟢 **Dis2Hook** v{DIS2HOOK_VERSION} online.")
  439. app.run(host=CONFIG["listen_host"], port=int(CONFIG["listen_port"]), threaded=True)