ArtyomV2X 1 hónapja
commit
79fa917e95
6 módosított fájl, 1450 hozzáadás és 0 törlés
  1. 157 0
      README.md
  2. 534 0
      app.py
  3. 28 0
      dis2hook.initd
  4. 542 0
      index.html
  5. 133 0
      install.sh
  6. 56 0
      update.sh

+ 157 - 0
README.md

@@ -0,0 +1,157 @@
+# Dis2Hook — Webhook Relay to Discord Bot
+
+Dis2Hook receives webhooks from anything that can send an HTTP POST — Gitea, Gogs,
+Proxmox notifications, Uptime Kuma, home-automation scripts — formats them as
+Markdown, and relays them into Discord channels **through the Discord Bot API**
+(not Discord's incoming webhooks, so one bot identity serves every channel).
+
+It is built to live in a small **Alpine Linux LXC on Proxmox** and is configured
+entirely from a built-in web UI.
+
+```
+  source A ──▶ POST /hook/gitea-ci ──┐
+  source B ──▶ POST /hook/kuma ──────┼──▶  Dis2Hook  ──▶  Discord Bot API ──▶ #channels
+  source C ──▶ POST /hook/backup ────┘
+```
+
+## Features
+
+- **Web UI configurator** — patch in any number of webhook sources, each routed
+  to its own Discord server/channel, from a single console page.
+- **Markdown templates** — shape each source's message with Discord Markdown and
+  `{dotted.path[0]}` placeholders resolved from the incoming JSON payload; leave
+  the template empty to relay the raw payload as a pretty-printed JSON block.
+- **Event filters** — per-source rules (`equals` / `contains` / `exists` on any
+  payload path) so only the events you care about reach Discord.
+- **Heartbeat** — optional periodic pulse to a channel with uptime and relay counts.
+- **Status updates** — optional online/offline announcements when the service
+  starts or stops.
+- **Secrets stay local** — bot token and admin key live in a `token.json`
+  generated at install time; it is never pulled from, nor pushed to, this repo.
+- **One-file updater** — pulls fresh `app.py` / `index.html` straight from the
+  repo, validates them, backs up the old ones, and restarts the service.
+
+## Install
+
+> **Run this inside an existing Alpine LXC — never on the Proxmox host.**
+> The installer refuses to run if it detects Proxmox VE.
+
+1. On the Proxmox host, enter your Alpine container: `pct enter <ctid>`
+2. Run the one-liner:
+
+```sh
+wget -qO- https://gogs.av2x.dev/av2x/Dis2Hook/raw/main/install.sh | ash
+```
+
+The installer will:
+
+- verify it is running inside Alpine (and not on a Proxmox host),
+- install `python3`, `py3-flask`, `py3-requests` via `apk`,
+- pull `app.py`, `index.html`, `update.sh`, and the OpenRC service script
+  **directly from this repo**,
+- prompt for your **Discord bot token** (Developer Portal → your app → Bot),
+- generate `/opt/dis2hook/token.json` (mode `600`) with the token and a fresh
+  random **admin key**, printed once at the end — save it,
+- register and start the `dis2hook` OpenRC service.
+
+Non-interactive install:
+
+```sh
+DISCORD_BOT_TOKEN=xxxxx wget -qO- https://gogs.av2x.dev/av2x/Dis2Hook/raw/main/install.sh | ash
+```
+
+Optional environment overrides: `D2H_BRANCH` (defaults to `main`, auto-falls
+back to `master`), `D2H_DIR` (default `/opt/dis2hook`), `D2H_PORT` (default `8823`).
+
+## First-time setup
+
+1. Create a bot at the Discord Developer Portal and copy its token.
+2. Invite it to your server with the **Send Messages** and **View Channel**
+   permissions (OAuth2 URL generator → scope `bot`).
+3. In Discord, enable Developer Mode, right-click a channel → **Copy Channel ID**.
+4. Open `http://<container-ip>:8823/`, unlock with the admin key, add a source,
+   paste the channel ID, save, and hit **Send test message**.
+5. Point your service at the source's webhook URL, sending the source secret in
+   the `X-Hook-Secret` header (or `?secret=` query, or a `Bearer` token).
+
+### Sending a webhook by hand
+
+```sh
+curl -X POST "http://<container-ip>:8823/hook/gitea-ci" \
+  -H "Content-Type: application/json" \
+  -H "X-Hook-Secret: <source secret>" \
+  -d '{"action":"opened","repository":{"full_name":"av2x/Dis2Hook"}}'
+```
+
+### Template example
+
+```
+🔔 **{repository.full_name}** — {pusher.name} pushed to `{ref}`
+> {commits[0].message}
+{commits[0].url}
+```
+
+Unresolved placeholders render as empty strings; objects/lists render as
+compact JSON. Messages are truncated to Discord's 2000-character limit.
+
+## Updating
+
+Pull the latest `app.py` and `index.html` directly from the repo and restart:
+
+```sh
+/opt/dis2hook/update.sh          # app.py + index.html
+/opt/dis2hook/update.sh --all    # also refresh update.sh and the service script
+```
+
+Downloads are validated (version marker + compile check) before they replace
+anything, previous files are kept as `*.bak`, and **`token.json` and
+`config.json` are never touched**.
+
+## Files
+
+| Path | Purpose | In repo? |
+|---|---|---|
+| `/opt/dis2hook/app.py` | relay service (Flask) | yes |
+| `/opt/dis2hook/index.html` | web UI | yes |
+| `/opt/dis2hook/update.sh` | updater | yes |
+| `/etc/init.d/dis2hook` | OpenRC service (`dis2hook.initd`) | yes |
+| `/opt/dis2hook/token.json` | **bot token + admin key — generated at install, mode 600** | **never** |
+| `/opt/dis2hook/config.json` | sources & settings, managed by the web UI | never |
+| `/var/log/dis2hook.log` | service log | — |
+
+## Service management
+
+```sh
+rc-service dis2hook status|start|stop|restart
+tail -f /var/log/dis2hook.log
+```
+
+## Security notes
+
+- `token.json` is generated locally, `chmod 600`, and listed in `.gitignore`;
+  treat the admin key like a password — it is the only credential for the UI/API.
+- Every source has its own shared secret, compared in constant time; requests
+  with a wrong or missing secret are rejected with `401`.
+- Dis2Hook serves plain HTTP. Keep it on a trusted LAN/VLAN, or front it with a
+  reverse proxy (Caddy, nginx, Nginx Proxy Manager) for TLS if sources send
+  webhooks across the internet.
+- Relayed messages are sent with all mentions disabled, so a hostile payload
+  cannot ping `@everyone`.
+- The UI cannot change the listen host/port — edit `config.json` and restart if
+  you need to, so a bad save can never lock you out.
+
+## How it compares to Discohook
+
+Discohook is a superb editor for Discord's *incoming webhooks* — you compose a
+message and Discord hosts the endpoint. Dis2Hook is the opposite direction:
+**it hosts the endpoint**, accepts webhooks from your own services, filters and
+reformats them, and delivers via a bot token — so one self-hosted relay fans
+out many sources to many servers and channels, with heartbeat and uptime status
+built in.
+
+## Uninstall
+
+```sh
+rc-service dis2hook stop; rc-update del dis2hook default
+rm -rf /opt/dis2hook /etc/init.d/dis2hook /var/log/dis2hook.log
+```

+ 534 - 0
app.py

@@ -0,0 +1,534 @@
+#!/usr/bin/env python3
+"""
+Dis2Hook - Webhook Relay to Discord Bot
+=======================================
+Receives webhooks from any source, formats them as Markdown, and relays
+them to Discord channels through the Discord Bot API.
+
+Runs inside an Alpine Linux LXC. Configuration lives in two files next
+to this script:
+
+  token.json   - secrets (bot token, admin key). Generated at install
+                 time, never stored in the repo. chmod 600.
+  config.json  - sources, heartbeat and status settings. Managed through
+                 the web UI, safe to back up.
+
+Repo: https://gogs.av2x.dev/av2x/Dis2Hook
+"""
+
+import atexit
+import json
+import os
+import re
+import secrets as pysecrets
+import signal
+import sys
+import threading
+import time
+from collections import deque
+from datetime import datetime, timezone
+
+import requests
+from flask import Flask, jsonify, request, send_from_directory
+
+DIS2HOOK_VERSION = "1.0.0"
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+TOKEN_FILE = os.path.join(BASE_DIR, "token.json")
+CONFIG_FILE = os.path.join(BASE_DIR, "config.json")
+
+DISCORD_API = "https://discord.com/api/v10"
+DISCORD_MSG_LIMIT = 2000
+
+app = Flask(__name__, static_folder=None)
+
+# ---------------------------------------------------------------------------
+# State
+# ---------------------------------------------------------------------------
+
+_lock = threading.RLock()
+_started_at = time.time()
+_stats = {"received": 0, "relayed": 0, "filtered": 0, "failed": 0}
+_activity = deque(maxlen=50)  # ring buffer of recent events for the UI
+_bot_identity = {"checked": 0, "ok": False, "name": None, "id": None}
+
+
+def _log(msg):
+    ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
+    print(f"[{ts}] {msg}", flush=True)
+
+
+def _record(source_name, outcome, detail=""):
+    with _lock:
+        _activity.appendleft({
+            "time": datetime.now(timezone.utc).isoformat(),
+            "source": source_name,
+            "outcome": outcome,   # relayed | filtered | failed | rejected
+            "detail": detail[:200],
+        })
+
+
+# ---------------------------------------------------------------------------
+# Config / secrets
+# ---------------------------------------------------------------------------
+
+DEFAULT_CONFIG = {
+    "listen_host": "0.0.0.0",
+    "listen_port": 8823,
+    "heartbeat": {
+        "enabled": False,
+        "channel_id": "",
+        "interval_minutes": 60,
+    },
+    "status": {
+        "enabled": False,
+        "channel_id": "",
+    },
+    "sources": [],
+}
+
+
+def load_tokens():
+    if not os.path.exists(TOKEN_FILE):
+        _log(f"FATAL: {TOKEN_FILE} not found. Run the installer to generate it.")
+        sys.exit(1)
+    with open(TOKEN_FILE, "r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not data.get("bot_token") or not data.get("admin_key"):
+        _log("FATAL: token.json must contain 'bot_token' and 'admin_key'.")
+        sys.exit(1)
+    return data
+
+
+def load_config():
+    if not os.path.exists(CONFIG_FILE):
+        save_config(DEFAULT_CONFIG)
+        return json.loads(json.dumps(DEFAULT_CONFIG))
+    with open(CONFIG_FILE, "r", encoding="utf-8") as f:
+        cfg = json.load(f)
+    # Fill any missing keys so older configs keep working after updates.
+    merged = json.loads(json.dumps(DEFAULT_CONFIG))
+    merged.update({k: v for k, v in cfg.items() if k in ("listen_host", "listen_port", "sources")})
+    for section in ("heartbeat", "status"):
+        merged[section].update(cfg.get(section, {}))
+    return merged
+
+
+def save_config(cfg):
+    tmp = CONFIG_FILE + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        json.dump(cfg, f, indent=2)
+    os.replace(tmp, CONFIG_FILE)
+
+
+TOKENS = load_tokens()
+CONFIG = load_config()
+
+
+def get_source(source_id):
+    with _lock:
+        for s in CONFIG.get("sources", []):
+            if s.get("id") == source_id:
+                return s
+    return None
+
+
+# ---------------------------------------------------------------------------
+# Discord Bot API
+# ---------------------------------------------------------------------------
+
+def discord_send(channel_id, content):
+    """Send a message to a channel via the bot API. Returns (ok, detail)."""
+    if not channel_id:
+        return False, "no channel configured"
+    if len(content) > DISCORD_MSG_LIMIT:
+        content = content[: DISCORD_MSG_LIMIT - 25] + "\n*(message truncated)*"
+    url = f"{DISCORD_API}/channels/{channel_id}/messages"
+    headers = {
+        "Authorization": f"Bot {TOKENS['bot_token']}",
+        "User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}",
+    }
+    body = {"content": content, "allowed_mentions": {"parse": []}}
+    for attempt in (1, 2):
+        try:
+            r = requests.post(url, json=body, headers=headers, timeout=15)
+        except requests.RequestException as e:
+            return False, f"network error: {e}"
+        if r.status_code == 429 and attempt == 1:
+            try:
+                wait = float(r.json().get("retry_after", 1.0))
+            except Exception:
+                wait = 1.0
+            time.sleep(min(wait, 5.0))
+            continue
+        if 200 <= r.status_code < 300:
+            return True, "sent"
+        return False, f"discord api {r.status_code}: {r.text[:150]}"
+    return False, "rate limited"
+
+
+def check_bot_identity(force=False):
+    """Verify the bot token by asking Discord who we are. Cached 5 minutes."""
+    with _lock:
+        fresh = (time.time() - _bot_identity["checked"]) < 300
+        if fresh and not force:
+            return dict(_bot_identity)
+    try:
+        r = requests.get(
+            f"{DISCORD_API}/users/@me",
+            headers={"Authorization": f"Bot {TOKENS['bot_token']}",
+                     "User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}"},
+            timeout=10,
+        )
+        ok = r.status_code == 200
+        data = r.json() if ok else {}
+    except requests.RequestException:
+        ok, data = False, {}
+    with _lock:
+        _bot_identity.update({
+            "checked": time.time(),
+            "ok": ok,
+            "name": data.get("username"),
+            "id": data.get("id"),
+        })
+        return dict(_bot_identity)
+
+
+# ---------------------------------------------------------------------------
+# Payload formatting
+# ---------------------------------------------------------------------------
+
+_PLACEHOLDER = re.compile(r"\{([a-zA-Z0-9_.\[\]-]+)\}")
+
+
+def dig(payload, path):
+    """Resolve a dotted path like 'commits[0].message' inside a payload."""
+    cur = payload
+    for part in re.split(r"\.", path):
+        m = re.match(r"^([a-zA-Z0-9_-]*)((\[\d+\])*)$", part)
+        if not m:
+            return None
+        key, indexes = m.group(1), m.group(2)
+        if key:
+            if not isinstance(cur, dict) or key not in cur:
+                return None
+            cur = cur[key]
+        for idx in re.findall(r"\[(\d+)\]", indexes or ""):
+            i = int(idx)
+            if not isinstance(cur, list) or i >= len(cur):
+                return None
+            cur = cur[i]
+    return cur
+
+
+def render_template(template, payload):
+    def sub(m):
+        val = dig(payload, m.group(1))
+        if val is None:
+            return ""
+        if isinstance(val, (dict, list)):
+            return json.dumps(val, indent=2)[:500]
+        return str(val)
+    return _PLACEHOLDER.sub(sub, template)
+
+
+def default_format(source_name, payload):
+    pretty = json.dumps(payload, indent=2, ensure_ascii=False)
+    if len(pretty) > 1700:
+        pretty = pretty[:1700] + "\n…"
+    return f"**{source_name}** received an event:\n```json\n{pretty}\n```"
+
+
+def passes_filters(source, payload):
+    """All filters must match (AND). No filters = everything passes."""
+    for flt in source.get("filters", []):
+        path = (flt.get("path") or "").strip()
+        if not path:
+            continue
+        val = dig(payload, path)
+        val_str = "" if val is None else str(val)
+        mode = flt.get("mode", "equals")
+        want = str(flt.get("value", ""))
+        if mode == "equals" and val_str != want:
+            return False
+        if mode == "contains" and want not in val_str:
+            return False
+        if mode == "exists" and val is None:
+            return False
+    return True
+
+
+# ---------------------------------------------------------------------------
+# Auth helpers
+# ---------------------------------------------------------------------------
+
+def admin_authorized():
+    supplied = request.headers.get("X-Admin-Key", "")
+    return pysecrets.compare_digest(supplied, TOKENS["admin_key"])
+
+
+def require_admin():
+    if not admin_authorized():
+        return jsonify({"error": "invalid admin key"}), 401
+    return None
+
+
+# ---------------------------------------------------------------------------
+# Routes: web UI
+# ---------------------------------------------------------------------------
+
+@app.route("/")
+@app.route("/index.html")
+def ui():
+    return send_from_directory(BASE_DIR, "index.html")
+
+
+# ---------------------------------------------------------------------------
+# Routes: admin API
+# ---------------------------------------------------------------------------
+
+@app.route("/api/login", methods=["POST"])
+def api_login():
+    """Lets the UI validate the admin key without exposing anything."""
+    if admin_authorized():
+        return jsonify({"ok": True})
+    return jsonify({"ok": False}), 401
+
+
+@app.route("/api/status")
+def api_status():
+    err = require_admin()
+    if err:
+        return err
+    ident = check_bot_identity()
+    with _lock:
+        return jsonify({
+            "version": DIS2HOOK_VERSION,
+            "uptime_seconds": int(time.time() - _started_at),
+            "stats": dict(_stats),
+            "activity": list(_activity),
+            "bot": {"ok": ident["ok"], "name": ident["name"], "id": ident["id"]},
+        })
+
+
+@app.route("/api/config", methods=["GET"])
+def api_get_config():
+    err = require_admin()
+    if err:
+        return err
+    with _lock:
+        return jsonify(CONFIG)
+
+
+@app.route("/api/config", methods=["PUT"])
+def api_put_config():
+    err = require_admin()
+    if err:
+        return err
+    data = request.get_json(silent=True)
+    if not isinstance(data, dict):
+        return jsonify({"error": "body must be a JSON object"}), 400
+
+    problems = validate_config(data)
+    if problems:
+        return jsonify({"error": "; ".join(problems)}), 400
+
+    with _lock:
+        # listen_host / listen_port are only changeable by editing config.json
+        # directly, so a UI save can never lock you out of the UI.
+        data["listen_host"] = CONFIG["listen_host"]
+        data["listen_port"] = CONFIG["listen_port"]
+        CONFIG.clear()
+        CONFIG.update(data)
+        save_config(CONFIG)
+    _log("Configuration saved via web UI.")
+    return jsonify({"ok": True})
+
+
+def validate_config(data):
+    problems = []
+    sources = data.get("sources")
+    if not isinstance(sources, list):
+        return ["'sources' must be a list"]
+    seen_ids = set()
+    for i, s in enumerate(sources):
+        label = s.get("name") or f"source {i + 1}"
+        sid = (s.get("id") or "").strip()
+        if not re.match(r"^[a-z0-9][a-z0-9-]{1,63}$", sid):
+            problems.append(f"{label}: endpoint id must be 2-64 chars of a-z, 0-9, '-'")
+        if sid in seen_ids:
+            problems.append(f"{label}: duplicate endpoint id '{sid}'")
+        seen_ids.add(sid)
+        if not (s.get("secret") or "").strip():
+            problems.append(f"{label}: secret must not be empty")
+        if not re.match(r"^\d{5,25}$", str(s.get("channel_id", ""))):
+            problems.append(f"{label}: channel ID must be numeric")
+    for section in ("heartbeat", "status"):
+        sec = data.get(section, {})
+        if sec.get("enabled") and not re.match(r"^\d{5,25}$", str(sec.get("channel_id", ""))):
+            problems.append(f"{section}: channel ID must be numeric when enabled")
+    hb = data.get("heartbeat", {})
+    try:
+        if hb.get("enabled") and not (1 <= int(hb.get("interval_minutes", 0)) <= 10080):
+            problems.append("heartbeat: interval must be 1-10080 minutes")
+    except (TypeError, ValueError):
+        problems.append("heartbeat: interval must be a number")
+    return problems
+
+
+@app.route("/api/test/<source_id>", methods=["POST"])
+def api_test(source_id):
+    err = require_admin()
+    if err:
+        return err
+    source = get_source(source_id)
+    if not source:
+        return jsonify({"error": "unknown source"}), 404
+    sample = {
+        "event": "dis2hook.test",
+        "message": "Test relay from the Dis2Hook web UI",
+        "time": datetime.now(timezone.utc).isoformat(),
+    }
+    template = (source.get("template") or "").strip()
+    content = render_template(template, sample) if template else default_format(source.get("name", source_id), sample)
+    content = f"🧪 **Test** — {source.get('name', source_id)}\n{content}"
+    ok, detail = discord_send(source.get("channel_id", ""), content)
+    _record(source.get("name", source_id), "relayed" if ok else "failed", f"test: {detail}")
+    return (jsonify({"ok": True}) if ok
+            else (jsonify({"ok": False, "error": detail}), 502))
+
+
+# ---------------------------------------------------------------------------
+# Routes: webhook receiver
+# ---------------------------------------------------------------------------
+
+def extract_payload(req):
+    payload = req.get_json(silent=True)
+    if payload is None and "payload" in req.form:
+        # Some services (e.g. legacy GitHub/Slack style) post form-encoded JSON.
+        try:
+            payload = json.loads(req.form["payload"])
+        except (ValueError, TypeError):
+            payload = None
+    if payload is None:
+        raw = req.get_data(as_text=True)[:2000]
+        payload = {"raw": raw}
+    if not isinstance(payload, dict):
+        payload = {"payload": payload}
+    return payload
+
+
+def supplied_secret(req):
+    return (
+        req.headers.get("X-Hook-Secret")
+        or req.headers.get("Authorization", "").removeprefix("Bearer ").strip()
+        or req.args.get("secret", "")
+    )
+
+
+@app.route("/hook/<source_id>", methods=["POST"])
+def receive_hook(source_id):
+    with _lock:
+        _stats["received"] += 1
+    source = get_source(source_id)
+    if not source or not source.get("enabled", True):
+        _record(source_id, "rejected", "unknown or disabled source")
+        return jsonify({"error": "unknown source"}), 404
+
+    if not pysecrets.compare_digest(supplied_secret(request), source.get("secret", "")):
+        _record(source.get("name", source_id), "rejected", "bad secret")
+        return jsonify({"error": "invalid secret"}), 401
+
+    payload = extract_payload(request)
+
+    if not passes_filters(source, payload):
+        with _lock:
+            _stats["filtered"] += 1
+        _record(source.get("name", source_id), "filtered", "did not match filters")
+        return jsonify({"ok": True, "relayed": False, "reason": "filtered"})
+
+    template = (source.get("template") or "").strip()
+    content = render_template(template, payload) if template else ""
+    if not content.strip():
+        content = default_format(source.get("name", source_id), payload)
+
+    ok, detail = discord_send(source.get("channel_id", ""), content)
+    with _lock:
+        _stats["relayed" if ok else "failed"] += 1
+    _record(source.get("name", source_id), "relayed" if ok else "failed", detail)
+    if not ok:
+        _log(f"Relay failed for '{source_id}': {detail}")
+        return jsonify({"ok": False, "error": detail}), 502
+    return jsonify({"ok": True, "relayed": True})
+
+
+# ---------------------------------------------------------------------------
+# Heartbeat + status updates
+# ---------------------------------------------------------------------------
+
+def _uptime_text():
+    secs = int(time.time() - _started_at)
+    d, rem = divmod(secs, 86400)
+    h, rem = divmod(rem, 3600)
+    m = rem // 60
+    parts = ([f"{d}d"] if d else []) + ([f"{h}h"] if h or d else []) + [f"{m}m"]
+    return " ".join(parts)
+
+
+def heartbeat_loop():
+    while True:
+        with _lock:
+            hb = dict(CONFIG.get("heartbeat", {}))
+        interval = max(1, int(hb.get("interval_minutes", 60) or 60)) * 60
+        time.sleep(interval)
+        with _lock:
+            hb = dict(CONFIG.get("heartbeat", {}))
+            stats = dict(_stats)
+        if hb.get("enabled") and hb.get("channel_id"):
+            msg = (f"💓 **Dis2Hook heartbeat** — up {_uptime_text()} · "
+                   f"{stats['relayed']} relayed · {stats['failed']} failed")
+            ok, detail = discord_send(hb["channel_id"], msg)
+            if not ok:
+                _log(f"Heartbeat send failed: {detail}")
+
+
+def send_status(text):
+    with _lock:
+        st = dict(CONFIG.get("status", {}))
+    if st.get("enabled") and st.get("channel_id"):
+        discord_send(st["channel_id"], text)
+
+
+_shutdown_sent = False
+
+
+def on_shutdown(*_args):
+    global _shutdown_sent
+    if not _shutdown_sent:
+        _shutdown_sent = True
+        send_status(f"🔴 **Dis2Hook** v{DIS2HOOK_VERSION} going offline.")
+    if _args:  # invoked as a signal handler, not via atexit
+        sys.exit(0)
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+if __name__ == "__main__":
+    _log(f"Dis2Hook v{DIS2HOOK_VERSION} starting on "
+         f"{CONFIG['listen_host']}:{CONFIG['listen_port']}")
+    ident = check_bot_identity(force=True)
+    if ident["ok"]:
+        _log(f"Bot token OK — connected as {ident['name']} ({ident['id']})")
+    else:
+        _log("WARNING: bot token could not be verified with Discord. "
+             "Relays will fail until token.json contains a valid token.")
+
+    threading.Thread(target=heartbeat_loop, daemon=True).start()
+    signal.signal(signal.SIGTERM, on_shutdown)
+    signal.signal(signal.SIGINT, on_shutdown)
+    atexit.register(on_shutdown)
+    send_status(f"🟢 **Dis2Hook** v{DIS2HOOK_VERSION} online.")
+
+    app.run(host=CONFIG["listen_host"], port=int(CONFIG["listen_port"]), threaded=True)

+ 28 - 0
dis2hook.initd

@@ -0,0 +1,28 @@
+#!/sbin/openrc-run
+# Dis2Hook — webhook relay to Discord bot (Alpine OpenRC service)
+
+name="dis2hook"
+description="Dis2Hook webhook relay to Discord"
+
+directory="/opt/dis2hook"
+command="/usr/bin/python3"
+command_args="/opt/dis2hook/app.py"
+
+supervisor="supervise-daemon"
+output_log="/var/log/dis2hook.log"
+error_log="/var/log/dis2hook.log"
+respawn_delay=5
+respawn_max=0
+
+depend() {
+    need net
+    after firewall
+}
+
+start_pre() {
+    checkpath -f -m 0644 /var/log/dis2hook.log
+    if [ ! -f "$directory/token.json" ]; then
+        eerror "token.json missing — run the installer first."
+        return 1
+    fi
+}

+ 542 - 0
index.html

@@ -0,0 +1,542 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Dis2Hook — Relay Console</title>
+<style>
+  :root{
+    --ink:#14161C; --panel:#1C1F28; --panel-2:#22263100; --well:#171A22;
+    --line:#2C3040; --line-soft:#242836;
+    --text:#E8E6DF; --muted:#8B8FA0; --faint:#5A5F70;
+    --amber:#F0A93B; --amber-dim:#7A5B26;
+    --blurple:#5865F2; --blurple-dim:#3A4290;
+    --ok:#57C08A; --fail:#E06060; --warn:#E0B060;
+    --mono:ui-monospace,"JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
+    --sans:system-ui,-apple-system,"Segoe UI",sans-serif;
+  }
+  *{box-sizing:border-box;margin:0;padding:0}
+  html{color-scheme:dark}
+  body{background:var(--ink);color:var(--text);font-family:var(--sans);font-size:15px;line-height:1.5;min-height:100vh}
+  body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
+    background:repeating-linear-gradient(0deg,transparent 0 3px,rgba(255,255,255,.012) 3px 4px)}
+  button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
+  input,select,textarea{font-family:var(--mono);font-size:13px;color:var(--text);
+    background:var(--well);border:1px solid var(--line);border-radius:4px;padding:7px 9px;width:100%}
+  input:focus,select:focus,textarea:focus,button:focus-visible{outline:2px solid var(--amber);outline-offset:1px}
+  input::placeholder,textarea::placeholder{color:var(--faint)}
+  textarea{resize:vertical;min-height:72px;line-height:1.55}
+  ::selection{background:var(--amber-dim);color:var(--text)}
+
+  .eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
+
+  /* ---------- rack header ---------- */
+  header{position:relative;z-index:1;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#1E212B,#181B23);
+    display:flex;align-items:center;gap:22px;padding:14px 22px;flex-wrap:wrap}
+  .plate{display:flex;align-items:baseline;gap:10px}
+  .plate h1{font-family:var(--sans);font-weight:800;font-size:19px;letter-spacing:.28em;text-transform:uppercase}
+  .plate h1 .two{color:var(--amber)}
+  .plate .ver{font-family:var(--mono);font-size:11px;color:var(--faint)}
+  .lamp{width:9px;height:9px;border-radius:50%;background:var(--faint);flex:none;box-shadow:0 0 0 3px rgba(255,255,255,.03)}
+  .lamp.ok{background:var(--ok);box-shadow:0 0 8px rgba(87,192,138,.7)}
+  .lamp.fail{background:var(--fail);box-shadow:0 0 8px rgba(224,96,96,.7)}
+  .meter{display:flex;gap:20px;margin-left:auto;align-items:center;flex-wrap:wrap}
+  .gauge{text-align:right}
+  .gauge b{display:block;font-family:var(--mono);font-size:16px;font-weight:600}
+  .gauge span{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted)}
+  .gauge.relayed b{color:var(--ok)} .gauge.failed b{color:var(--fail)}
+  #botLabel{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;color:var(--muted)}
+
+  main{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:26px 22px 90px;
+    display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:26px}
+  @media(max-width:960px){main{grid-template-columns:1fr}}
+
+  section h2{display:flex;align-items:center;gap:10px;margin-bottom:14px;
+    font-size:12px;font-weight:700;letter-spacing:.2em;text-transform:uppercase;color:var(--muted)}
+  section h2::after{content:"";flex:1;height:1px;background:var(--line-soft)}
+
+  /* ---------- patch strips (source cards) ---------- */
+  .strip{background:var(--panel);border:1px solid var(--line);border-radius:6px;margin-bottom:14px;overflow:hidden}
+  .strip.disabled{opacity:.55}
+  .strip-head{display:flex;align-items:center;gap:14px;padding:14px 16px;cursor:pointer}
+  .strip-head:hover{background:rgba(255,255,255,.02)}
+  .strip-name{font-weight:600;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+
+  /* signature: the relay line */
+  .relay{flex:1;display:flex;align-items:center;min-width:120px;height:22px}
+  .jack{width:10px;height:10px;border-radius:50%;flex:none;border:2px solid}
+  .jack.src{border-color:var(--amber);background:var(--amber-dim)}
+  .jack.dst{border-color:var(--blurple);background:var(--blurple-dim)}
+  .wire{flex:1;height:2px;background:linear-gradient(90deg,var(--amber-dim),var(--blurple-dim));position:relative;overflow:visible}
+  .pulse{position:absolute;top:-2px;left:0;width:14px;height:6px;border-radius:3px;background:var(--amber);
+    filter:drop-shadow(0 0 5px var(--amber));opacity:0}
+  .strip.firing .pulse{animation:travel .9s ease-in-out}
+  @keyframes travel{0%{opacity:1;left:0;background:var(--amber)}100%{opacity:1;left:calc(100% - 14px);background:var(--blurple);filter:drop-shadow(0 0 6px var(--blurple))}}
+  @media(prefers-reduced-motion:reduce){.strip.firing .pulse{animation:none}}
+  .chan-tag{font-family:var(--mono);font-size:11px;color:var(--blurple);flex:none;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .caret{color:var(--faint);flex:none;transition:transform .15s}
+  .strip.open .caret{transform:rotate(90deg)}
+
+  .strip-body{display:none;border-top:1px solid var(--line-soft);padding:16px;background:var(--well)}
+  .strip.open .strip-body{display:block}
+  .grid2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+  @media(max-width:640px){.grid2{grid-template-columns:1fr}}
+  .field{margin-bottom:12px;min-width:0}
+  .field label{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:5px}
+  .field .hint{font-size:11px;color:var(--faint);margin-top:4px;font-family:var(--sans)}
+  .inline{display:flex;gap:6px}
+  .inline input{flex:1;min-width:0}
+
+  .hookurl{display:flex;gap:6px;align-items:center;background:var(--panel);border:1px dashed var(--line);
+    border-radius:4px;padding:7px 9px;font-family:var(--mono);font-size:12px;color:var(--amber);overflow:hidden}
+  .hookurl code{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+
+  .filters .filter-row{display:grid;grid-template-columns:1fr 110px 1fr 30px;gap:6px;margin-bottom:6px}
+  @media(max-width:640px){.filters .filter-row{grid-template-columns:1fr 90px 1fr 30px}}
+
+  .strip-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px;align-items:center}
+
+  /* ---------- buttons ---------- */
+  .btn{font-family:var(--mono);font-size:12px;padding:7px 13px;border-radius:4px;border:1px solid var(--line);
+    background:var(--panel);color:var(--text);letter-spacing:.04em}
+  .btn:hover{border-color:var(--muted)}
+  .btn.primary{background:var(--amber);border-color:var(--amber);color:#1A150A;font-weight:700}
+  .btn.primary:hover{filter:brightness(1.08)}
+  .btn.ghost{border-color:transparent;color:var(--muted)} .btn.ghost:hover{color:var(--text)}
+  .btn.danger{color:var(--fail);border-color:transparent} .btn.danger:hover{border-color:var(--fail)}
+  .btn.tiny{padding:4px 8px;font-size:11px}
+  .btn:disabled{opacity:.45;cursor:default}
+
+  .toggle{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-family:var(--mono);font-size:11px;
+    letter-spacing:.1em;text-transform:uppercase;color:var(--muted);user-select:none}
+  .toggle input{display:none}
+  .toggle .track{width:30px;height:16px;border-radius:8px;background:var(--line);position:relative;transition:background .15s;flex:none}
+  .toggle .track::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:var(--muted);transition:all .15s}
+  .toggle input:checked+.track{background:var(--amber-dim)}
+  .toggle input:checked+.track::after{left:16px;background:var(--amber)}
+
+  .empty{border:1px dashed var(--line);border-radius:6px;padding:34px 20px;text-align:center;color:var(--muted)}
+  .empty p{margin-bottom:14px}
+
+  /* ---------- side column ---------- */
+  .panelbox{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:16px;margin-bottom:22px}
+  .panelbox h3{display:flex;align-items:center;gap:8px;font-size:11px;font-weight:700;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}
+  .log{list-style:none;max-height:420px;overflow:auto;font-family:var(--mono);font-size:11.5px}
+  .log li{display:flex;gap:8px;padding:6px 0;border-bottom:1px solid var(--line-soft);align-items:baseline}
+  .log time{color:var(--faint);flex:none}
+  .log .badge{flex:none;font-size:9px;letter-spacing:.12em;text-transform:uppercase;padding:1px 6px;border-radius:3px}
+  .badge.relayed{color:var(--ok);border:1px solid var(--ok)}
+  .badge.failed,.badge.rejected{color:var(--fail);border:1px solid var(--fail)}
+  .badge.filtered{color:var(--warn);border:1px solid var(--warn)}
+  .log .who{color:var(--text)} .log .what{color:var(--muted);overflow-wrap:anywhere}
+
+  /* ---------- save bar ---------- */
+  #savebar{position:fixed;left:0;right:0;bottom:0;z-index:5;display:none;justify-content:center;padding:12px;
+    background:linear-gradient(180deg,transparent,rgba(10,11,15,.92) 40%)}
+  #savebar.show{display:flex}
+  #savebar .inner{display:flex;gap:10px;align-items:center;background:var(--panel);border:1px solid var(--amber);
+    border-radius:6px;padding:10px 14px;box-shadow:0 8px 30px rgba(0,0,0,.5)}
+  #savebar .msg{font-family:var(--mono);font-size:12px;color:var(--amber)}
+
+  #toast{position:fixed;top:16px;right:16px;z-index:9;display:none;font-family:var(--mono);font-size:12.5px;
+    background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--ok);border-radius:4px;padding:10px 14px;max-width:340px}
+  #toast.err{border-left-color:var(--fail)}
+
+  /* ---------- login ---------- */
+  #login{position:fixed;inset:0;z-index:10;background:var(--ink);display:flex;align-items:center;justify-content:center;padding:20px}
+  #login .plate2{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:30px}
+  #login h1{font-size:17px;font-weight:800;letter-spacing:.3em;text-transform:uppercase;text-align:center;margin-bottom:4px}
+  #login h1 .two{color:var(--amber)}
+  #login .sub{text-align:center;font-family:var(--mono);font-size:11px;color:var(--faint);margin-bottom:22px;letter-spacing:.1em}
+  #login .err{color:var(--fail);font-family:var(--mono);font-size:12px;margin-top:10px;min-height:16px}
+  .hidden{display:none!important}
+</style>
+</head>
+<body>
+
+<div id="login">
+  <div class="plate2">
+    <h1>DIS<span class="two">2</span>HOOK</h1>
+    <div class="sub">RELAY CONSOLE · ADMIN KEY REQUIRED</div>
+    <div class="field">
+      <label for="keyInput">Admin key</label>
+      <input id="keyInput" type="password" autocomplete="off" placeholder="from token.json / install output">
+    </div>
+    <button class="btn primary" style="width:100%" id="loginBtn">Unlock console</button>
+    <div class="err" id="loginErr"></div>
+  </div>
+</div>
+
+<header class="hidden" id="hdr">
+  <div class="plate">
+    <h1>DIS<span class="two">2</span>HOOK</h1>
+    <span class="ver" id="ver"></span>
+  </div>
+  <div id="botLabel"><span class="lamp" id="botLamp"></span><span id="botText">checking bot…</span></div>
+  <div class="meter">
+    <div class="gauge"><b id="mRecv">0</b><span>received</span></div>
+    <div class="gauge relayed"><b id="mRelay">0</b><span>relayed</span></div>
+    <div class="gauge"><b id="mFilt">0</b><span>filtered</span></div>
+    <div class="gauge failed"><b id="mFail">0</b><span>failed</span></div>
+    <div class="gauge"><b id="mUp">—</b><span>uptime</span></div>
+  </div>
+</header>
+
+<main class="hidden" id="mainEl">
+  <section>
+    <h2>Webhook sources <button class="btn tiny" id="addSrc" style="margin-left:auto">+ add source</button></h2>
+    <div id="strips"></div>
+  </section>
+
+  <aside>
+    <section>
+      <h2>Relay settings</h2>
+      <div class="panelbox">
+        <h3><span class="lamp ok" style="width:7px;height:7px"></span> Heartbeat</h3>
+        <label class="toggle" style="margin-bottom:10px"><input type="checkbox" id="hbOn"><span class="track"></span>Post a periodic pulse</label>
+        <div class="field"><label for="hbChan">Channel ID</label><input id="hbChan" placeholder="e.g. 1123456789012345678"></div>
+        <div class="field"><label for="hbInt">Interval (minutes)</label><input id="hbInt" type="number" min="1" max="10080" value="60"></div>
+      </div>
+      <div class="panelbox">
+        <h3><span class="lamp" style="width:7px;height:7px;background:var(--blurple)"></span> Status updates</h3>
+        <label class="toggle" style="margin-bottom:10px"><input type="checkbox" id="stOn"><span class="track"></span>Announce online / offline</label>
+        <div class="field"><label for="stChan">Channel ID</label><input id="stChan" placeholder="e.g. 1123456789012345678"></div>
+      </div>
+    </section>
+    <section>
+      <h2>Recent activity</h2>
+      <div class="panelbox"><ul class="log" id="log"><li style="color:var(--faint)">Nothing yet — waiting for signal.</li></ul></div>
+    </section>
+  </aside>
+</main>
+
+<div id="savebar"><div class="inner">
+  <span class="msg">Unsaved changes</span>
+  <button class="btn ghost" id="discardBtn">Discard</button>
+  <button class="btn primary" id="saveBtn">Save changes</button>
+</div></div>
+<div id="toast" role="status"></div>
+
+<script>
+"use strict";
+const $ = s => document.querySelector(s);
+let ADMIN_KEY = "";
+let cfg = null;          // working copy (edited by UI)
+let dirty = false;
+let statusTimer = null;
+
+/* ---------- storage guard (falls back to memory-only) ---------- */
+const store = {
+  get(k){ try { return sessionStorage.getItem(k); } catch(e){ return null; } },
+  set(k,v){ try { sessionStorage.setItem(k,v); } catch(e){} },
+  del(k){ try { sessionStorage.removeItem(k); } catch(e){} }
+};
+
+/* ---------- api ---------- */
+async function api(path, opts={}){
+  const r = await fetch(path, {...opts, headers:{
+    "Content-Type":"application/json", "X-Admin-Key":ADMIN_KEY, ...(opts.headers||{})}});
+  if (r.status === 401) { logout(); throw new Error("unauthorized"); }
+  const data = await r.json().catch(()=>({}));
+  if (!r.ok) throw new Error(data.error || ("HTTP "+r.status));
+  return data;
+}
+
+/* ---------- toast ---------- */
+let toastT;
+function toast(msg, err=false){
+  const t = $("#toast");
+  t.textContent = msg; t.className = err ? "err" : ""; t.style.display = "block";
+  clearTimeout(toastT); toastT = setTimeout(()=> t.style.display="none", 3500);
+}
+
+/* ---------- login ---------- */
+async function tryLogin(key){
+  ADMIN_KEY = key;
+  await api("/api/login", {method:"POST"});
+  store.set("d2h_key", key);
+  $("#login").classList.add("hidden");
+  $("#hdr").classList.remove("hidden");
+  $("#mainEl").classList.remove("hidden");
+  await loadConfig();
+  await refreshStatus();
+  statusTimer = setInterval(refreshStatus, 10000);
+}
+function logout(){
+  store.del("d2h_key"); ADMIN_KEY = "";
+  clearInterval(statusTimer);
+  $("#login").classList.remove("hidden");
+  $("#hdr").classList.add("hidden");
+  $("#mainEl").classList.add("hidden");
+}
+$("#loginBtn").addEventListener("click", async ()=>{
+  $("#loginErr").textContent = "";
+  try { await tryLogin($("#keyInput").value.trim()); }
+  catch(e){ $("#loginErr").textContent = "That key was not accepted."; }
+});
+$("#keyInput").addEventListener("keydown", e=>{ if(e.key==="Enter") $("#loginBtn").click(); });
+
+/* ---------- status ---------- */
+function fmtUptime(s){
+  const d=Math.floor(s/86400), h=Math.floor(s%86400/3600), m=Math.floor(s%3600/60);
+  return (d?d+"d ":"")+(h||d?h+"h ":"")+m+"m";
+}
+async function refreshStatus(){
+  try {
+    const st = await api("/api/status");
+    $("#ver").textContent = "v"+st.version;
+    $("#mRecv").textContent = st.stats.received;
+    $("#mRelay").textContent = st.stats.relayed;
+    $("#mFilt").textContent = st.stats.filtered;
+    $("#mFail").textContent = st.stats.failed;
+    $("#mUp").textContent = fmtUptime(st.uptime_seconds);
+    const lamp = $("#botLamp");
+    if (st.bot.ok){ lamp.className="lamp ok"; $("#botText").textContent = "bot: "+st.bot.name; }
+    else { lamp.className="lamp fail"; $("#botText").textContent = "bot token not verified"; }
+    renderLog(st.activity);
+  } catch(e){ /* transient; keep last values */ }
+}
+function renderLog(items){
+  const ul = $("#log"); ul.innerHTML = "";
+  if (!items || !items.length){
+    ul.innerHTML = '<li style="color:var(--faint)">Nothing yet — waiting for signal.</li>'; return;
+  }
+  for (const it of items){
+    const li = document.createElement("li");
+    const t = document.createElement("time");
+    t.textContent = new Date(it.time).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"});
+    const b = document.createElement("span"); b.className = "badge "+it.outcome; b.textContent = it.outcome;
+    const who = document.createElement("span"); who.className="who"; who.textContent = it.source;
+    const what = document.createElement("span"); what.className="what"; what.textContent = it.detail || "";
+    li.append(t,b,who,what); ul.appendChild(li);
+  }
+}
+
+/* ---------- config ---------- */
+async function loadConfig(){
+  cfg = await api("/api/config");
+  cfg.sources = cfg.sources || [];
+  bindGlobals(); renderStrips(); setDirty(false);
+}
+function bindGlobals(){
+  $("#hbOn").checked = !!cfg.heartbeat.enabled;
+  $("#hbChan").value = cfg.heartbeat.channel_id || "";
+  $("#hbInt").value  = cfg.heartbeat.interval_minutes || 60;
+  $("#stOn").checked = !!cfg.status.enabled;
+  $("#stChan").value = cfg.status.channel_id || "";
+}
+function collectGlobals(){
+  cfg.heartbeat.enabled = $("#hbOn").checked;
+  cfg.heartbeat.channel_id = $("#hbChan").value.trim();
+  cfg.heartbeat.interval_minutes = parseInt($("#hbInt").value, 10) || 60;
+  cfg.status.enabled = $("#stOn").checked;
+  cfg.status.channel_id = $("#stChan").value.trim();
+}
+for (const id of ["hbOn","hbChan","hbInt","stOn","stChan"])
+  $("#"+id).addEventListener("input", ()=>{ collectGlobals(); setDirty(true); });
+
+function setDirty(v){ dirty = v; $("#savebar").classList.toggle("show", v); }
+window.addEventListener("beforeunload", e=>{ if(dirty){ e.preventDefault(); e.returnValue=""; }});
+
+$("#saveBtn").addEventListener("click", async ()=>{
+  collectGlobals();
+  try { await api("/api/config", {method:"PUT", body: JSON.stringify(cfg)});
+        setDirty(false); toast("Configuration saved."); }
+  catch(e){ toast("Save failed: "+e.message, true); }
+});
+$("#discardBtn").addEventListener("click", ()=> loadConfig().then(()=>toast("Changes discarded.")));
+
+/* ---------- sources ---------- */
+function randHex(n){
+  const a = new Uint8Array(n); crypto.getRandomValues(a);
+  return Array.from(a, x=>x.toString(16).padStart(2,"0")).join("");
+}
+function slugify(s){ return s.toLowerCase().replace(/[^a-z0-9-]+/g,"-").replace(/^-+|-+$/g,"").slice(0,64) || "source"; }
+function uniqueId(base){
+  let id = base, n = 2;
+  while (cfg.sources.some(s=>s.id===id)) id = base+"-"+(n++);
+  return id;
+}
+
+$("#addSrc").addEventListener("click", ()=>{
+  const id = uniqueId("source-"+(cfg.sources.length+1));
+  cfg.sources.push({ id, name:"New source", secret: randHex(16), channel_id:"",
+                     enabled:true, template:"", filters:[] });
+  renderStrips(id); setDirty(true);
+});
+
+function hookURL(id){ return location.origin + "/hook/" + id; }
+
+function copyText(text, btn){
+  const done = ()=>{ const old=btn.textContent; btn.textContent="copied"; setTimeout(()=>btn.textContent=old,1200); };
+  if (navigator.clipboard && navigator.clipboard.writeText)
+    navigator.clipboard.writeText(text).then(done).catch(()=>fallbackCopy(text,done));
+  else fallbackCopy(text,done);
+}
+function fallbackCopy(text, done){
+  const ta=document.createElement("textarea"); ta.value=text; document.body.appendChild(ta);
+  ta.select(); try{ document.execCommand("copy"); }catch(e){} ta.remove(); done();
+}
+
+function field(labelText, inputEl, hint){
+  const w = document.createElement("div"); w.className = "field";
+  const l = document.createElement("label"); l.textContent = labelText;
+  w.append(l, inputEl);
+  if (hint){ const h=document.createElement("div"); h.className="hint"; h.textContent=hint; w.appendChild(h); }
+  return w;
+}
+function textInput(value, oninput, placeholder){
+  const i = document.createElement("input"); i.value = value ?? ""; i.placeholder = placeholder||"";
+  i.addEventListener("input", ()=>{ oninput(i.value); setDirty(true); });
+  return i;
+}
+
+function renderStrips(openId){
+  const wrap = $("#strips"); wrap.innerHTML = "";
+  if (!cfg.sources.length){
+    const e = document.createElement("div"); e.className="empty";
+    e.innerHTML = "<p>No sources patched in yet.<br>Add one to get a webhook URL you can point anything at.</p>";
+    const b = document.createElement("button"); b.className="btn primary"; b.textContent="+ add your first source";
+    b.addEventListener("click", ()=> $("#addSrc").click());
+    e.appendChild(b); wrap.appendChild(e); return;
+  }
+  cfg.sources.forEach((src, idx)=> wrap.appendChild(buildStrip(src, idx, src.id===openId)));
+}
+
+function buildStrip(src, idx, open){
+  const strip = document.createElement("div");
+  strip.className = "strip" + (open ? " open":"") + (src.enabled===false ? " disabled":"");
+
+  /* head with the relay line */
+  const head = document.createElement("div"); head.className = "strip-head";
+  head.setAttribute("role","button"); head.tabIndex = 0;
+  const jackS = document.createElement("span"); jackS.className = "jack src"; jackS.title = "webhook in";
+  const name = document.createElement("span"); name.className = "strip-name"; name.textContent = src.name || src.id;
+  const relay = document.createElement("span"); relay.className = "relay";
+  const wire = document.createElement("span"); wire.className = "wire";
+  const pulse = document.createElement("span"); pulse.className = "pulse";
+  wire.appendChild(pulse);
+  const jackD = document.createElement("span"); jackD.className = "jack dst"; jackD.title = "Discord out";
+  relay.append(wire);
+  const chan = document.createElement("span"); chan.className = "chan-tag";
+  chan.textContent = src.channel_id ? "#"+src.channel_id : "no channel";
+  const caret = document.createElement("span"); caret.className = "caret"; caret.textContent = "▸";
+  head.append(jackS, name, relay, jackD, chan, caret);
+  const toggleOpen = ()=> strip.classList.toggle("open");
+  head.addEventListener("click", toggleOpen);
+  head.addEventListener("keydown", e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); toggleOpen(); }});
+
+  /* body */
+  const body = document.createElement("div"); body.className = "strip-body";
+
+  const g1 = document.createElement("div"); g1.className = "grid2";
+  g1.appendChild(field("Display name",
+    textInput(src.name, v=>{ src.name=v; name.textContent=v||src.id; }, "e.g. Gitea CI")));
+  const idInput = textInput(src.id, v=>{ src.id = slugify(v); urlCode.textContent = hookURL(src.id); },
+    "gitea-ci");
+  g1.appendChild(field("Endpoint ID", idInput, "Lowercase letters, digits, dashes. Sets the URL below."));
+  body.appendChild(g1);
+
+  /* webhook url */
+  const urlBox = document.createElement("div"); urlBox.className = "hookurl";
+  const urlCode = document.createElement("code"); urlCode.textContent = hookURL(src.id);
+  const urlCopy = document.createElement("button"); urlCopy.className="btn tiny"; urlCopy.textContent="copy";
+  urlCopy.addEventListener("click", ()=> copyText(urlCode.textContent, urlCopy));
+  urlBox.append(urlCode, urlCopy);
+  body.appendChild(field("Webhook URL — point your service here", urlBox,
+    "Send POSTs with header X-Hook-Secret (or ?secret=) set to the secret below."));
+
+  const g2 = document.createElement("div"); g2.className = "grid2";
+  /* secret with regen + copy */
+  const secretWrap = document.createElement("div"); secretWrap.className = "inline";
+  const secretIn = textInput(src.secret, v=>{ src.secret=v; });
+  const regen = document.createElement("button"); regen.className="btn tiny"; regen.textContent="new";
+  regen.title = "Generate a new secret";
+  regen.addEventListener("click", ()=>{ src.secret = randHex(16); secretIn.value = src.secret; setDirty(true); });
+  const scopy = document.createElement("button"); scopy.className="btn tiny"; scopy.textContent="copy";
+  scopy.addEventListener("click", ()=> copyText(secretIn.value, scopy));
+  secretWrap.append(secretIn, regen, scopy);
+  g2.appendChild(field("Shared secret", secretWrap));
+  g2.appendChild(field("Discord channel ID",
+    textInput(src.channel_id, v=>{ src.channel_id=v.trim(); chan.textContent = v.trim()? "#"+v.trim():"no channel"; },
+      "1123456789012345678"),
+    "Right-click a channel in Discord → Copy Channel ID (developer mode)."));
+  body.appendChild(g2);
+
+  /* template */
+  const ta = document.createElement("textarea");
+  ta.value = src.template || "";
+  ta.placeholder = "**{repository.full_name}** — {pusher.name} pushed\n{commits[0].message}\n\nLeave empty to relay the full payload as a JSON block.";
+  ta.addEventListener("input", ()=>{ src.template = ta.value; setDirty(true); });
+  body.appendChild(field("Markdown template", ta,
+    "Discord Markdown plus {dotted.paths[0]} placeholders resolved from the incoming JSON payload."));
+
+  /* filters */
+  const fwrap = document.createElement("div"); fwrap.className = "filters";
+  const flabel = document.createElement("div"); flabel.className = "field";
+  const fl = document.createElement("label"); fl.textContent = "Event filters — relay only when every rule matches";
+  flabel.appendChild(fl); flabel.appendChild(fwrap);
+  const renderFilters = ()=>{
+    fwrap.innerHTML = "";
+    (src.filters||[]).forEach((flt, fi)=>{
+      const row = document.createElement("div"); row.className = "filter-row";
+      const p = textInput(flt.path, v=>flt.path=v, "payload path, e.g. action");
+      const sel = document.createElement("select");
+      for (const m of ["equals","contains","exists"]){
+        const o=document.createElement("option"); o.value=m; o.textContent=m;
+        if ((flt.mode||"equals")===m) o.selected=true; sel.appendChild(o);
+      }
+      sel.addEventListener("change", ()=>{ flt.mode=sel.value; v.disabled = sel.value==="exists"; setDirty(true); });
+      const v = textInput(flt.value, x=>flt.value=x, "expected value");
+      v.disabled = (flt.mode||"equals")==="exists";
+      const del = document.createElement("button"); del.className="btn tiny danger"; del.textContent="✕";
+      del.title = "Remove rule";
+      del.addEventListener("click", ()=>{ src.filters.splice(fi,1); renderFilters(); setDirty(true); });
+      row.append(p, sel, v, del); fwrap.appendChild(row);
+    });
+    const add = document.createElement("button"); add.className="btn tiny ghost"; add.textContent="+ add rule";
+    add.addEventListener("click", ()=>{ (src.filters ||= []).push({path:"",mode:"equals",value:""}); renderFilters(); setDirty(true); });
+    fwrap.appendChild(add);
+  };
+  renderFilters();
+  body.appendChild(flabel);
+
+  /* actions */
+  const actions = document.createElement("div"); actions.className = "strip-actions";
+  const en = document.createElement("label"); en.className = "toggle";
+  const enIn = document.createElement("input"); enIn.type="checkbox"; enIn.checked = src.enabled !== false;
+  const track = document.createElement("span"); track.className="track";
+  en.append(enIn, track, document.createTextNode("Enabled"));
+  enIn.addEventListener("change", ()=>{ src.enabled = enIn.checked; strip.classList.toggle("disabled", !enIn.checked); setDirty(true); });
+
+  const test = document.createElement("button"); test.className="btn"; test.textContent="Send test message";
+  test.addEventListener("click", async ()=>{
+    if (dirty){ toast("Save changes first, then test.", true); return; }
+    test.disabled = true;
+    strip.classList.remove("firing"); void strip.offsetWidth; strip.classList.add("firing");
+    try { await api("/api/test/"+encodeURIComponent(src.id), {method:"POST"}); toast("Test message relayed."); }
+    catch(e){ toast("Test failed: "+e.message, true); }
+    test.disabled = false;
+  });
+
+  const del = document.createElement("button"); del.className="btn danger"; del.textContent="Delete source";
+  del.addEventListener("click", ()=>{
+    if (!confirm(`Delete "${src.name||src.id}"? Its webhook URL stops working when you save.`)) return;
+    cfg.sources.splice(idx,1); renderStrips(); setDirty(true);
+  });
+  actions.append(en, test, del);
+  body.appendChild(actions);
+
+  strip.append(head, body);
+  return strip;
+}
+
+/* ---------- boot ---------- */
+(async ()=>{
+  const saved = store.get("d2h_key");
+  if (saved){ try { await tryLogin(saved); return; } catch(e){ store.del("d2h_key"); } }
+  $("#keyInput").focus();
+})();
+</script>
+</body>
+</html>

+ 133 - 0
install.sh

@@ -0,0 +1,133 @@
+#!/bin/sh
+# ===========================================================================
+# Dis2Hook installer — run INSIDE an existing Alpine Linux LXC, never on the
+# Proxmox host itself.
+#
+# One-liner:
+#   wget -qO- https://gogs.av2x.dev/av2x/Dis2Hook/raw/main/install.sh | ash
+#
+# Options via environment:
+#   D2H_BRANCH=main            branch to pull from (auto-falls back to master)
+#   D2H_DIR=/opt/dis2hook      install directory
+#   D2H_PORT=8823              web UI / webhook listen port
+#   DISCORD_BOT_TOKEN=...      skip the interactive token prompt
+# ===========================================================================
+set -eu
+
+REPO="${D2H_REPO:-https://gogs.av2x.dev/av2x/Dis2Hook}"
+BRANCH="${D2H_BRANCH:-main}"
+APP_DIR="${D2H_DIR:-/opt/dis2hook}"
+PORT="${D2H_PORT:-8823}"
+SERVICE="dis2hook"
+
+say()  { printf '\033[1;33m[dis2hook]\033[0m %s\n' "$*"; }
+fail() { printf '\033[1;31m[dis2hook] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
+
+# --- guards ----------------------------------------------------------------
+# Never install on the Proxmox host: this belongs inside the container.
+if [ -d /etc/pve ] || command -v pveversion >/dev/null 2>&1; then
+    fail "This looks like a Proxmox VE host. Dis2Hook must be installed INSIDE an Alpine LXC.
+    Enter the container first, e.g.:  pct enter <ctid>   then re-run this installer."
+fi
+[ -f /etc/alpine-release ] || fail "This installer only supports Alpine Linux (no /etc/alpine-release found)."
+[ "$(id -u)" = "0" ] || fail "Run as root (Alpine LXC default)."
+
+# --- fetch helper with branch fallback -------------------------------------
+fetch() { # fetch <repo-path> <dest>
+    wget -q -O "$2" "$REPO/raw/$BRANCH/$1" || return 1
+    [ -s "$2" ]
+}
+
+say "Checking repository branch '$BRANCH'…"
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+if ! fetch app.py "$TMP/app.py"; then
+    if [ "$BRANCH" = "main" ] && wget -q -O "$TMP/app.py" "$REPO/raw/master/app.py" && [ -s "$TMP/app.py" ]; then
+        BRANCH="master"
+        say "Branch 'main' not found, using 'master'."
+    else
+        fail "Could not download app.py from $REPO (branch $BRANCH). Check network/DNS inside the container."
+    fi
+fi
+grep -q DIS2HOOK_VERSION "$TMP/app.py" || fail "Downloaded app.py does not look valid. Aborting."
+
+# --- packages ---------------------------------------------------------------
+say "Installing packages (python3, Flask, requests)…"
+apk add --no-cache python3 py3-flask py3-requests >/dev/null
+
+# --- pull application files from the repo -----------------------------------
+say "Pulling application files from $REPO ($BRANCH)…"
+mkdir -p "$APP_DIR"
+fetch index.html      "$TMP/index.html"      || fail "Could not download index.html."
+fetch update.sh       "$TMP/update.sh"       || fail "Could not download update.sh."
+fetch dis2hook.initd  "$TMP/dis2hook.initd"  || fail "Could not download dis2hook.initd."
+grep -qi '<html' "$TMP/index.html" || fail "Downloaded index.html does not look valid. Aborting."
+
+install -m 644 "$TMP/app.py"     "$APP_DIR/app.py"
+install -m 644 "$TMP/index.html" "$APP_DIR/index.html"
+install -m 755 "$TMP/update.sh"  "$APP_DIR/update.sh"
+install -m 755 "$TMP/dis2hook.initd" "/etc/init.d/$SERVICE"
+printf '%s\n' "$BRANCH" > "$APP_DIR/.branch"
+
+# --- secrets: token.json is generated locally, never synced with the repo ---
+if [ -f "$APP_DIR/token.json" ]; then
+    say "Existing token.json found — keeping it (secrets are never overwritten)."
+    ADMIN_KEY="(unchanged — see $APP_DIR/token.json)"
+else
+    BOT_TOKEN="${DISCORD_BOT_TOKEN:-}"
+    if [ -z "$BOT_TOKEN" ]; then
+        say "A Discord bot token is required (Discord Developer Portal → your app → Bot → Token)."
+        printf '[dis2hook] Paste bot token (input hidden): '
+        stty -echo < /dev/tty 2>/dev/null || true
+        read -r BOT_TOKEN < /dev/tty
+        stty echo < /dev/tty 2>/dev/null || true
+        printf '\n'
+    fi
+    [ -n "$BOT_TOKEN" ] || fail "No bot token provided. Re-run, or set DISCORD_BOT_TOKEN=… before the one-liner."
+    ADMIN_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(16))')"
+    umask 077
+    python3 - "$APP_DIR/token.json" "$BOT_TOKEN" "$ADMIN_KEY" <<'PYEOF'
+import json, sys
+path, bot, admin = sys.argv[1], sys.argv[2], sys.argv[3]
+with open(path, "w") as f:
+    json.dump({"bot_token": bot, "admin_key": admin}, f, indent=2)
+PYEOF
+    chmod 600 "$APP_DIR/token.json"
+    umask 022
+    say "Generated $APP_DIR/token.json (chmod 600)."
+fi
+
+# --- default config (only if missing) ----------------------------------------
+if [ ! -f "$APP_DIR/config.json" ]; then
+    cat > "$APP_DIR/config.json" <<CFGEOF
+{
+  "listen_host": "0.0.0.0",
+  "listen_port": $PORT,
+  "heartbeat": { "enabled": false, "channel_id": "", "interval_minutes": 60 },
+  "status":    { "enabled": false, "channel_id": "" },
+  "sources": []
+}
+CFGEOF
+    say "Wrote default config.json (port $PORT)."
+fi
+
+# --- service ------------------------------------------------------------------
+say "Registering OpenRC service '$SERVICE'…"
+rc-update add "$SERVICE" default >/dev/null 2>&1 || true
+rc-service "$SERVICE" restart >/dev/null 2>&1 || rc-service "$SERVICE" start
+
+sleep 1
+IP="$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n1)"
+[ -n "$IP" ] || IP="<container-ip>"
+ACTUAL_PORT="$(python3 -c 'import json;print(json.load(open("'"$APP_DIR"'/config.json"))["listen_port"])')"
+
+printf '\n'
+say "──────────────────────────────────────────────────────"
+say "Dis2Hook is installed and running."
+say "  Web UI     : http://$IP:$ACTUAL_PORT/"
+say "  Admin key  : $ADMIN_KEY"
+say "  App dir    : $APP_DIR"
+say "  Logs       : tail -f /var/log/dis2hook.log"
+say "  Update     : $APP_DIR/update.sh"
+say "──────────────────────────────────────────────────────"
+say "Keep the admin key safe — it is stored only in token.json."

+ 56 - 0
update.sh

@@ -0,0 +1,56 @@
+#!/bin/sh
+# ===========================================================================
+# Dis2Hook updater — pulls the latest app.py and index.html straight from the
+# repo and restarts the service. Never touches token.json or config.json.
+#
+#   /opt/dis2hook/update.sh            update app.py + index.html
+#   /opt/dis2hook/update.sh --all      also refresh update.sh and the initd
+#
+#   D2H_BRANCH=master ./update.sh      pull from a different branch
+# ===========================================================================
+set -eu
+
+REPO="${D2H_REPO:-https://gogs.av2x.dev/av2x/Dis2Hook}"
+APP_DIR="$(cd "$(dirname "$0")" && pwd)"
+SERVICE="dis2hook"
+BRANCH="${D2H_BRANCH:-$(cat "$APP_DIR/.branch" 2>/dev/null || echo main)}"
+
+say()  { printf '\033[1;33m[dis2hook]\033[0m %s\n' "$*"; }
+fail() { printf '\033[1;31m[dis2hook] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
+
+fetch() { wget -q -O "$2" "$REPO/raw/$BRANCH/$1" && [ -s "$2" ]; }
+
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+say "Pulling latest files from $REPO ($BRANCH)…"
+fetch app.py     "$TMP/app.py"     || fail "Could not download app.py."
+fetch index.html "$TMP/index.html" || fail "Could not download index.html."
+
+# Sanity checks so a broken download never replaces a working install.
+grep -q  DIS2HOOK_VERSION "$TMP/app.py"     || fail "Downloaded app.py failed validation. Nothing changed."
+grep -qi '<html'          "$TMP/index.html" || fail "Downloaded index.html failed validation. Nothing changed."
+python3 -m py_compile "$TMP/app.py" 2>/dev/null || fail "Downloaded app.py does not compile. Nothing changed."
+
+OLD_VER="$(grep -m1 'DIS2HOOK_VERSION =' "$APP_DIR/app.py" 2>/dev/null | cut -d'"' -f2 || echo '?')"
+NEW_VER="$(grep -m1 'DIS2HOOK_VERSION =' "$TMP/app.py" | cut -d'"' -f2)"
+
+cp -f "$APP_DIR/app.py"     "$APP_DIR/app.py.bak"     2>/dev/null || true
+cp -f "$APP_DIR/index.html" "$APP_DIR/index.html.bak" 2>/dev/null || true
+install -m 644 "$TMP/app.py"     "$APP_DIR/app.py"
+install -m 644 "$TMP/index.html" "$APP_DIR/index.html"
+say "app.py + index.html updated ($OLD_VER → $NEW_VER). Previous versions kept as *.bak."
+
+if [ "${1:-}" = "--all" ]; then
+    fetch update.sh      "$TMP/update.sh"      && install -m 755 "$TMP/update.sh" "$APP_DIR/update.sh" \
+        && say "update.sh refreshed." || say "Skipped update.sh (not downloadable)."
+    fetch dis2hook.initd "$TMP/dis2hook.initd" && install -m 755 "$TMP/dis2hook.initd" "/etc/init.d/$SERVICE" \
+        && say "Service script refreshed." || say "Skipped service script (not downloadable)."
+fi
+
+say "token.json and config.json were left untouched."
+if rc-service "$SERVICE" restart >/dev/null 2>&1; then
+    say "Service restarted — Dis2Hook v$NEW_VER is live."
+else
+    say "Could not restart via OpenRC. Start manually: rc-service $SERVICE start"
+fi