app.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  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 base64
  17. import binascii
  18. import json
  19. import os
  20. import re
  21. import secrets as pysecrets
  22. import signal
  23. import sys
  24. import threading
  25. import time
  26. from collections import deque
  27. from datetime import datetime, timezone
  28. import requests
  29. from flask import Flask, jsonify, request, send_from_directory
  30. DIS2HOOK_VERSION = "1.1.0"
  31. BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  32. TOKEN_FILE = os.path.join(BASE_DIR, "token.json")
  33. CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
  34. DISCORD_API = "https://discord.com/api/v10"
  35. DISCORD_MSG_LIMIT = 2000
  36. app = Flask(__name__, static_folder=None)
  37. # ---------------------------------------------------------------------------
  38. # State
  39. # ---------------------------------------------------------------------------
  40. _lock = threading.RLock()
  41. _started_at = time.time()
  42. _stats = {"received": 0, "relayed": 0, "filtered": 0, "failed": 0}
  43. _activity = deque(maxlen=50) # ring buffer of recent events for the UI
  44. _last_payloads = {} # source_id -> last received payload (for the template builder)
  45. _bot_identity = {"checked": 0, "ok": False, "name": None, "id": None}
  46. def _log(msg):
  47. ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
  48. print(f"[{ts}] {msg}", flush=True)
  49. def _record(source_name, outcome, detail=""):
  50. with _lock:
  51. _activity.appendleft({
  52. "time": datetime.now(timezone.utc).isoformat(),
  53. "source": source_name,
  54. "outcome": outcome, # relayed | filtered | failed | rejected
  55. "detail": detail[:200],
  56. })
  57. # ---------------------------------------------------------------------------
  58. # Config / secrets
  59. # ---------------------------------------------------------------------------
  60. DEFAULT_CONFIG = {
  61. "listen_host": "0.0.0.0",
  62. "listen_port": 8823,
  63. "heartbeat": {
  64. "enabled": False,
  65. "channel_id": "",
  66. "interval_minutes": 60,
  67. },
  68. "status": {
  69. "enabled": False,
  70. "channel_id": "",
  71. },
  72. "sources": [],
  73. }
  74. def load_tokens():
  75. if not os.path.exists(TOKEN_FILE):
  76. _log(f"FATAL: {TOKEN_FILE} not found. Run the installer to generate it.")
  77. sys.exit(1)
  78. with open(TOKEN_FILE, "r", encoding="utf-8") as f:
  79. data = json.load(f)
  80. if not data.get("bot_token") or not data.get("admin_key"):
  81. _log("FATAL: token.json must contain 'bot_token' and 'admin_key'.")
  82. sys.exit(1)
  83. return data
  84. def load_config():
  85. if not os.path.exists(CONFIG_FILE):
  86. save_config(DEFAULT_CONFIG)
  87. return json.loads(json.dumps(DEFAULT_CONFIG))
  88. with open(CONFIG_FILE, "r", encoding="utf-8") as f:
  89. cfg = json.load(f)
  90. # Fill any missing keys so older configs keep working after updates.
  91. merged = json.loads(json.dumps(DEFAULT_CONFIG))
  92. merged.update({k: v for k, v in cfg.items() if k in ("listen_host", "listen_port", "sources")})
  93. for section in ("heartbeat", "status"):
  94. merged[section].update(cfg.get(section, {}))
  95. return merged
  96. def save_config(cfg):
  97. tmp = CONFIG_FILE + ".tmp"
  98. with open(tmp, "w", encoding="utf-8") as f:
  99. json.dump(cfg, f, indent=2)
  100. os.replace(tmp, CONFIG_FILE)
  101. TOKENS = load_tokens()
  102. CONFIG = load_config()
  103. def get_source(source_id):
  104. with _lock:
  105. for s in CONFIG.get("sources", []):
  106. if s.get("id") == source_id:
  107. return s
  108. return None
  109. # ---------------------------------------------------------------------------
  110. # Discord Bot API
  111. # ---------------------------------------------------------------------------
  112. def discord_send(channel_id, content, image=None):
  113. """Send a message to a channel via the bot API. Returns (ok, detail).
  114. image may be None, {"mode": "url", "url": ...} for a Discord-side embed,
  115. or {"mode": "bytes", "data": ..., "mime": ..., "filename": ...} to upload
  116. the picture as an attachment (needed for LAN URLs and base64 payloads).
  117. """
  118. if not channel_id:
  119. return False, "no channel configured"
  120. if len(content) > DISCORD_MSG_LIMIT:
  121. content = content[: DISCORD_MSG_LIMIT - 25] + "\n*(message truncated)*"
  122. url = f"{DISCORD_API}/channels/{channel_id}/messages"
  123. headers = {
  124. "Authorization": f"Bot {TOKENS['bot_token']}",
  125. "User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}",
  126. }
  127. body = {"content": content, "allowed_mentions": {"parse": []}}
  128. files = None
  129. if image and image.get("mode") == "url":
  130. body["embeds"] = [{"image": {"url": image["url"]}}]
  131. elif image and image.get("mode") == "bytes":
  132. fname = image.get("filename", "image.jpg")
  133. body["embeds"] = [{"image": {"url": f"attachment://{fname}"}}]
  134. files = {"files[0]": (fname, image["data"], image.get("mime", "image/jpeg"))}
  135. for attempt in (1, 2):
  136. try:
  137. if files:
  138. r = requests.post(url, data={"payload_json": json.dumps(body)},
  139. files=files, headers=headers, timeout=30)
  140. else:
  141. r = requests.post(url, json=body, headers=headers, timeout=15)
  142. except requests.RequestException as e:
  143. return False, f"network error: {e}"
  144. if r.status_code == 429 and attempt == 1:
  145. try:
  146. wait = float(r.json().get("retry_after", 1.0))
  147. except Exception:
  148. wait = 1.0
  149. time.sleep(min(wait, 5.0))
  150. continue
  151. if 200 <= r.status_code < 300:
  152. return True, "sent with image" if image else "sent"
  153. return False, f"discord api {r.status_code}: {r.text[:150]}"
  154. return False, "rate limited"
  155. def check_bot_identity(force=False):
  156. """Verify the bot token by asking Discord who we are. Cached 5 minutes."""
  157. with _lock:
  158. fresh = (time.time() - _bot_identity["checked"]) < 300
  159. if fresh and not force:
  160. return dict(_bot_identity)
  161. try:
  162. r = requests.get(
  163. f"{DISCORD_API}/users/@me",
  164. headers={"Authorization": f"Bot {TOKENS['bot_token']}",
  165. "User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}"},
  166. timeout=10,
  167. )
  168. ok = r.status_code == 200
  169. data = r.json() if ok else {}
  170. except requests.RequestException:
  171. ok, data = False, {}
  172. with _lock:
  173. _bot_identity.update({
  174. "checked": time.time(),
  175. "ok": ok,
  176. "name": data.get("username"),
  177. "id": data.get("id"),
  178. })
  179. return dict(_bot_identity)
  180. # ---------------------------------------------------------------------------
  181. # Payload formatting
  182. # ---------------------------------------------------------------------------
  183. _PLACEHOLDER = re.compile(r"\{([a-zA-Z0-9_.\[\]-]+)\}")
  184. def dig(payload, path):
  185. """Resolve a dotted path like 'commits[0].message' inside a payload."""
  186. cur = payload
  187. for part in re.split(r"\.", path):
  188. m = re.match(r"^([a-zA-Z0-9_-]*)((\[\d+\])*)$", part)
  189. if not m:
  190. return None
  191. key, indexes = m.group(1), m.group(2)
  192. if key:
  193. if not isinstance(cur, dict) or key not in cur:
  194. return None
  195. cur = cur[key]
  196. for idx in re.findall(r"\[(\d+)\]", indexes or ""):
  197. i = int(idx)
  198. if not isinstance(cur, list) or i >= len(cur):
  199. return None
  200. cur = cur[i]
  201. return cur
  202. def render_template(template, payload):
  203. def sub(m):
  204. val = dig(payload, m.group(1))
  205. if val is None:
  206. return ""
  207. if isinstance(val, (dict, list)):
  208. return json.dumps(val, indent=2)[:500]
  209. return str(val)
  210. return _PLACEHOLDER.sub(sub, template)
  211. def default_format(source_name, payload):
  212. def shorten(v):
  213. if isinstance(v, str) and len(v) > 200:
  214. return v[:200] + f"… ({len(v)} chars)"
  215. if isinstance(v, dict):
  216. return {k: shorten(x) for k, x in v.items()}
  217. if isinstance(v, list):
  218. return [shorten(x) for x in v[:20]]
  219. return v
  220. pretty = json.dumps(shorten(payload), indent=2, ensure_ascii=False)
  221. if len(pretty) > 1700:
  222. pretty = pretty[:1700] + "\n…"
  223. return f"**{source_name}** received an event:\n```json\n{pretty}\n```"
  224. def passes_filters(source, payload):
  225. """All filters must match (AND). No filters = everything passes."""
  226. for flt in source.get("filters", []):
  227. path = (flt.get("path") or "").strip()
  228. if not path:
  229. continue
  230. val = dig(payload, path)
  231. val_str = "" if val is None else str(val)
  232. mode = flt.get("mode", "equals")
  233. want = str(flt.get("value", ""))
  234. if mode == "equals" and val_str != want:
  235. return False
  236. if mode == "contains" and want not in val_str:
  237. return False
  238. if mode == "exists" and val is None:
  239. return False
  240. return True
  241. # ---------------------------------------------------------------------------
  242. # Image embedding
  243. # ---------------------------------------------------------------------------
  244. IMAGE_MAX_BYTES = 8 * 1024 * 1024 # Discord's default upload cap
  245. _MAGIC = [
  246. (b"\xff\xd8\xff", "image/jpeg", "jpg"),
  247. (b"\x89PNG", "image/png", "png"),
  248. (b"GIF8", "image/gif", "gif"),
  249. (b"RIFF", "image/webp", "webp"),
  250. ]
  251. def _sniff(data):
  252. for magic, mime, ext in _MAGIC:
  253. if data.startswith(magic):
  254. return mime, ext
  255. return None, None
  256. def resolve_image(source, payload):
  257. """Resolve a source's image_path against the payload.
  258. Returns an image dict for discord_send, or None. Modes:
  259. embed - hand Discord the URL (only works for internet-reachable URLs)
  260. attach - Dis2Hook fetches the URL itself (works on the LAN) or decodes
  261. base64 image data, then uploads it as an attachment.
  262. """
  263. path = (source.get("image_path") or "").strip().strip("{}")
  264. if not path:
  265. return None
  266. val = dig(payload, path)
  267. if val is None or not isinstance(val, str) or not val.strip():
  268. return None
  269. val = val.strip()
  270. mode = source.get("image_mode", "attach")
  271. if val.startswith("http://") or val.startswith("https://"):
  272. if mode == "embed":
  273. return {"mode": "url", "url": val}
  274. try:
  275. r = requests.get(val, timeout=20, stream=True)
  276. r.raise_for_status()
  277. data = r.raw.read(IMAGE_MAX_BYTES + 1, decode_content=True)
  278. except requests.RequestException as e:
  279. _log(f"Image fetch failed ({val[:80]}): {e}")
  280. return None
  281. if len(data) > IMAGE_MAX_BYTES:
  282. _log("Image skipped: larger than 8 MB.")
  283. return None
  284. mime, ext = _sniff(data)
  285. if not mime:
  286. _log("Image skipped: fetched data is not a recognized image format.")
  287. return None
  288. return {"mode": "bytes", "data": data, "mime": mime, "filename": f"image.{ext}"}
  289. # Not a URL: try base64 (raw, or a data: URI)
  290. b64 = val
  291. if b64.startswith("data:"):
  292. b64 = b64.split(",", 1)[-1]
  293. try:
  294. data = base64.b64decode(b64, validate=False)
  295. except (ValueError, binascii.Error):
  296. return None
  297. if not data or len(data) > IMAGE_MAX_BYTES:
  298. return None
  299. mime, ext = _sniff(data)
  300. if not mime:
  301. return None
  302. return {"mode": "bytes", "data": data, "mime": mime, "filename": f"image.{ext}"}
  303. # ---------------------------------------------------------------------------
  304. # Auth helpers
  305. # ---------------------------------------------------------------------------
  306. def admin_authorized():
  307. supplied = request.headers.get("X-Admin-Key", "")
  308. return pysecrets.compare_digest(supplied, TOKENS["admin_key"])
  309. def require_admin():
  310. if not admin_authorized():
  311. return jsonify({"error": "invalid admin key"}), 401
  312. return None
  313. # ---------------------------------------------------------------------------
  314. # Routes: web UI
  315. # ---------------------------------------------------------------------------
  316. @app.route("/")
  317. @app.route("/index.html")
  318. def ui():
  319. return send_from_directory(BASE_DIR, "index.html")
  320. # ---------------------------------------------------------------------------
  321. # Routes: admin API
  322. # ---------------------------------------------------------------------------
  323. @app.route("/api/login", methods=["POST"])
  324. def api_login():
  325. """Lets the UI validate the admin key without exposing anything."""
  326. if admin_authorized():
  327. return jsonify({"ok": True})
  328. return jsonify({"ok": False}), 401
  329. @app.route("/api/status")
  330. def api_status():
  331. err = require_admin()
  332. if err:
  333. return err
  334. ident = check_bot_identity()
  335. with _lock:
  336. return jsonify({
  337. "version": DIS2HOOK_VERSION,
  338. "uptime_seconds": int(time.time() - _started_at),
  339. "stats": dict(_stats),
  340. "activity": list(_activity),
  341. "bot": {"ok": ident["ok"], "name": ident["name"], "id": ident["id"]},
  342. })
  343. @app.route("/api/config", methods=["GET"])
  344. def api_get_config():
  345. err = require_admin()
  346. if err:
  347. return err
  348. with _lock:
  349. return jsonify(CONFIG)
  350. @app.route("/api/config", methods=["PUT"])
  351. def api_put_config():
  352. err = require_admin()
  353. if err:
  354. return err
  355. data = request.get_json(silent=True)
  356. if not isinstance(data, dict):
  357. return jsonify({"error": "body must be a JSON object"}), 400
  358. problems = validate_config(data)
  359. if problems:
  360. return jsonify({"error": "; ".join(problems)}), 400
  361. with _lock:
  362. # listen_host / listen_port are only changeable by editing config.json
  363. # directly, so a UI save can never lock you out of the UI.
  364. data["listen_host"] = CONFIG["listen_host"]
  365. data["listen_port"] = CONFIG["listen_port"]
  366. CONFIG.clear()
  367. CONFIG.update(data)
  368. save_config(CONFIG)
  369. _log("Configuration saved via web UI.")
  370. return jsonify({"ok": True})
  371. def validate_config(data):
  372. problems = []
  373. sources = data.get("sources")
  374. if not isinstance(sources, list):
  375. return ["'sources' must be a list"]
  376. seen_ids = set()
  377. for i, s in enumerate(sources):
  378. label = s.get("name") or f"source {i + 1}"
  379. sid = (s.get("id") or "").strip()
  380. if not re.match(r"^[a-z0-9][a-z0-9-]{1,63}$", sid):
  381. problems.append(f"{label}: endpoint id must be 2-64 chars of a-z, 0-9, '-'")
  382. if sid in seen_ids:
  383. problems.append(f"{label}: duplicate endpoint id '{sid}'")
  384. seen_ids.add(sid)
  385. if not (s.get("secret") or "").strip():
  386. problems.append(f"{label}: secret must not be empty")
  387. if not re.match(r"^\d{5,25}$", str(s.get("channel_id", ""))):
  388. problems.append(f"{label}: channel ID must be numeric")
  389. if s.get("image_mode") not in (None, "", "attach", "embed"):
  390. problems.append(f"{label}: image mode must be 'attach' or 'embed'")
  391. for section in ("heartbeat", "status"):
  392. sec = data.get(section, {})
  393. if sec.get("enabled") and not re.match(r"^\d{5,25}$", str(sec.get("channel_id", ""))):
  394. problems.append(f"{section}: channel ID must be numeric when enabled")
  395. hb = data.get("heartbeat", {})
  396. try:
  397. if hb.get("enabled") and not (1 <= int(hb.get("interval_minutes", 0)) <= 10080):
  398. problems.append("heartbeat: interval must be 1-10080 minutes")
  399. except (TypeError, ValueError):
  400. problems.append("heartbeat: interval must be a number")
  401. return problems
  402. @app.route("/api/sample/<source_id>")
  403. def api_sample(source_id):
  404. """Last received payload for a source — powers the drag-and-drop
  405. template builder in the web UI."""
  406. err = require_admin()
  407. if err:
  408. return err
  409. with _lock:
  410. payload = _last_payloads.get(source_id)
  411. if payload is None:
  412. return jsonify({"ok": False, "payload": None})
  413. return jsonify({"ok": True, "payload": payload})
  414. @app.route("/api/test/<source_id>", methods=["POST"])
  415. def api_test(source_id):
  416. err = require_admin()
  417. if err:
  418. return err
  419. source = get_source(source_id)
  420. if not source:
  421. return jsonify({"error": "unknown source"}), 404
  422. with _lock:
  423. sample = _last_payloads.get(source_id)
  424. used_real = sample is not None
  425. if sample is None:
  426. sample = {
  427. "event": "dis2hook.test",
  428. "message": "Test relay from the Dis2Hook web UI",
  429. "time": datetime.now(timezone.utc).isoformat(),
  430. }
  431. template = (source.get("template") or "").strip()
  432. content = render_template(template, sample) if template else default_format(source.get("name", source_id), sample)
  433. label = "last received event" if used_real else "sample payload"
  434. content = f"🧪 **Test** ({label}) — {source.get('name', source_id)}\n{content}"
  435. image = resolve_image(source, sample)
  436. ok, detail = discord_send(source.get("channel_id", ""), content, image=image)
  437. _record(source.get("name", source_id), "relayed" if ok else "failed", f"test: {detail}")
  438. return (jsonify({"ok": True}) if ok
  439. else (jsonify({"ok": False, "error": detail}), 502))
  440. # ---------------------------------------------------------------------------
  441. # Routes: webhook receiver
  442. # ---------------------------------------------------------------------------
  443. def extract_payload(req):
  444. payload = req.get_json(silent=True)
  445. if payload is None and "payload" in req.form:
  446. # Some services (e.g. legacy GitHub/Slack style) post form-encoded JSON.
  447. try:
  448. payload = json.loads(req.form["payload"])
  449. except (ValueError, TypeError):
  450. payload = None
  451. if payload is None:
  452. raw = req.get_data(as_text=True)[:2000]
  453. payload = {"raw": raw}
  454. if not isinstance(payload, dict):
  455. payload = {"payload": payload}
  456. return payload
  457. def supplied_secret(req):
  458. return (
  459. req.headers.get("X-Hook-Secret")
  460. or req.headers.get("Authorization", "").removeprefix("Bearer ").strip()
  461. or req.args.get("secret", "")
  462. )
  463. @app.route("/hook/<source_id>", methods=["POST"])
  464. def receive_hook(source_id):
  465. with _lock:
  466. _stats["received"] += 1
  467. source = get_source(source_id)
  468. if not source or not source.get("enabled", True):
  469. _record(source_id, "rejected", "unknown or disabled source")
  470. return jsonify({"error": "unknown source"}), 404
  471. if not pysecrets.compare_digest(supplied_secret(request), source.get("secret", "")):
  472. _record(source.get("name", source_id), "rejected", "bad secret")
  473. return jsonify({"error": "invalid secret"}), 401
  474. payload = extract_payload(request)
  475. with _lock:
  476. _last_payloads[source_id] = payload # feeds the UI template builder
  477. if not passes_filters(source, payload):
  478. with _lock:
  479. _stats["filtered"] += 1
  480. _record(source.get("name", source_id), "filtered", "did not match filters")
  481. return jsonify({"ok": True, "relayed": False, "reason": "filtered"})
  482. template = (source.get("template") or "").strip()
  483. content = render_template(template, payload) if template else ""
  484. if not content.strip():
  485. content = default_format(source.get("name", source_id), payload)
  486. image = resolve_image(source, payload)
  487. ok, detail = discord_send(source.get("channel_id", ""), content, image=image)
  488. with _lock:
  489. _stats["relayed" if ok else "failed"] += 1
  490. _record(source.get("name", source_id), "relayed" if ok else "failed", detail)
  491. if not ok:
  492. _log(f"Relay failed for '{source_id}': {detail}")
  493. return jsonify({"ok": False, "error": detail}), 502
  494. return jsonify({"ok": True, "relayed": True})
  495. # ---------------------------------------------------------------------------
  496. # Heartbeat + status updates
  497. # ---------------------------------------------------------------------------
  498. def _uptime_text():
  499. secs = int(time.time() - _started_at)
  500. d, rem = divmod(secs, 86400)
  501. h, rem = divmod(rem, 3600)
  502. m = rem // 60
  503. parts = ([f"{d}d"] if d else []) + ([f"{h}h"] if h or d else []) + [f"{m}m"]
  504. return " ".join(parts)
  505. def heartbeat_loop():
  506. while True:
  507. with _lock:
  508. hb = dict(CONFIG.get("heartbeat", {}))
  509. interval = max(1, int(hb.get("interval_minutes", 60) or 60)) * 60
  510. time.sleep(interval)
  511. with _lock:
  512. hb = dict(CONFIG.get("heartbeat", {}))
  513. stats = dict(_stats)
  514. if hb.get("enabled") and hb.get("channel_id"):
  515. msg = (f"💓 **Dis2Hook heartbeat** — up {_uptime_text()} · "
  516. f"{stats['relayed']} relayed · {stats['failed']} failed")
  517. ok, detail = discord_send(hb["channel_id"], msg)
  518. if not ok:
  519. _log(f"Heartbeat send failed: {detail}")
  520. def send_status(text):
  521. with _lock:
  522. st = dict(CONFIG.get("status", {}))
  523. if st.get("enabled") and st.get("channel_id"):
  524. discord_send(st["channel_id"], text)
  525. _shutdown_sent = False
  526. def on_shutdown(*_args):
  527. global _shutdown_sent
  528. if not _shutdown_sent:
  529. _shutdown_sent = True
  530. send_status(f"🔴 **Dis2Hook** v{DIS2HOOK_VERSION} going offline.")
  531. if _args: # invoked as a signal handler, not via atexit
  532. sys.exit(0)
  533. # ---------------------------------------------------------------------------
  534. # Main
  535. # ---------------------------------------------------------------------------
  536. if __name__ == "__main__":
  537. _log(f"Dis2Hook v{DIS2HOOK_VERSION} starting on "
  538. f"{CONFIG['listen_host']}:{CONFIG['listen_port']}")
  539. ident = check_bot_identity(force=True)
  540. if ident["ok"]:
  541. _log(f"Bot token OK — connected as {ident['name']} ({ident['id']})")
  542. else:
  543. _log("WARNING: bot token could not be verified with Discord. "
  544. "Relays will fail until token.json contains a valid token.")
  545. threading.Thread(target=heartbeat_loop, daemon=True).start()
  546. signal.signal(signal.SIGTERM, on_shutdown)
  547. signal.signal(signal.SIGINT, on_shutdown)
  548. atexit.register(on_shutdown)
  549. send_status(f"🟢 **Dis2Hook** v{DIS2HOOK_VERSION} online.")
  550. app.run(host=CONFIG["listen_host"], port=int(CONFIG["listen_port"]), threaded=True)