Forráskód Böngészése

Add WebUI Template Builder

ArtyomV2X 1 hónapja
szülő
commit
90d439a682
3 módosított fájl, 306 hozzáadás és 18 törlés
  1. 34 0
      README.md
  2. 141 15
      app.py
  3. 131 3
      index.html

+ 34 - 0
README.md

@@ -23,6 +23,15 @@ entirely from a built-in web UI.
   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.
+- **Drag-and-drop template builder** — Dis2Hook remembers the last event each
+  source received; the UI shows its JSON keys as draggable chips you drop
+  straight into the template (or click to insert), so templates are built from
+  real data instead of guesswork.
+- **Image embedding** — point a source at any payload path holding an image:
+  an internet URL (embedded by Discord), a **LAN URL** like
+  `http://192.168.x.x/…` (Dis2Hook fetches it and uploads it as an
+  attachment, since Discord can't reach your LAN), or **base64 image data /
+  data-URIs** (decoded and attached). JPEG/PNG/GIF/WebP, up to 8 MB.
 - **Heartbeat** — optional periodic pulse to a channel with uptime and relay counts.
 - **Status updates** — optional online/offline announcements when the service
   starts or stops.
@@ -94,6 +103,31 @@ curl -X POST "http://<container-ip>:8823/hook/gitea-ci" \
 Unresolved placeholders render as empty strings; objects/lists render as
 compact JSON. Messages are truncated to Discord's 2000-character limit.
 
+### Building templates from a real event
+
+Once a source has received at least one webhook, open its strip in the UI:
+under the template you'll find **"Payload keys from the last received event"**
+— every key/value pair from that event as a chip. Drag chips into the template
+(or click them) to insert `{path}` placeholders at the cursor; hover a chip to
+preview its value. Hit **↻ refresh** after new events arrive. Blue chips are
+values that look like images.
+
+### Embedding images
+
+Set a source's **Image — payload path** to the field holding the picture
+(drag a blue chip into it), and pick a delivery mode:
+
+- **fetch & upload** (default) — Dis2Hook downloads the URL itself, or decodes
+  base64 / `data:` image data, and uploads it to Discord as an attachment.
+  Use this for LAN URLs (e.g. `http://192.168.51.234:8000/...`) and base64
+  fields — Discord's servers can never reach your LAN directly.
+- **embed link** — Discord fetches the URL itself; only works for URLs
+  reachable from the public internet.
+
+Example for a Bambu Lab print notifier: template
+`🖨️ **{printer}** — {title}\n\`{filename}\` after {duration}` with image path
+`image` (base64 snapshot) or `finish_photo_url` (LAN URL) in fetch & upload mode.
+
 ## Updating
 
 Pull the latest `app.py` and `index.html` directly from the repo and restart:

+ 141 - 15
app.py

@@ -17,6 +17,8 @@ Repo: https://gogs.av2x.dev/av2x/Dis2Hook
 """
 
 import atexit
+import base64
+import binascii
 import json
 import os
 import re
@@ -31,7 +33,7 @@ from datetime import datetime, timezone
 import requests
 from flask import Flask, jsonify, request, send_from_directory
 
-DIS2HOOK_VERSION = "1.0.0"
+DIS2HOOK_VERSION = "1.1.0"
 
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 TOKEN_FILE = os.path.join(BASE_DIR, "token.json")
@@ -50,6 +52,7 @@ _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
+_last_payloads = {}           # source_id -> last received payload (for the template builder)
 _bot_identity = {"checked": 0, "ok": False, "name": None, "id": None}
 
 
@@ -137,8 +140,13 @@ def get_source(source_id):
 # Discord Bot API
 # ---------------------------------------------------------------------------
 
-def discord_send(channel_id, content):
-    """Send a message to a channel via the bot API. Returns (ok, detail)."""
+def discord_send(channel_id, content, image=None):
+    """Send a message to a channel via the bot API. Returns (ok, detail).
+
+    image may be None, {"mode": "url", "url": ...} for a Discord-side embed,
+    or {"mode": "bytes", "data": ..., "mime": ..., "filename": ...} to upload
+    the picture as an attachment (needed for LAN URLs and base64 payloads).
+    """
     if not channel_id:
         return False, "no channel configured"
     if len(content) > DISCORD_MSG_LIMIT:
@@ -149,9 +157,21 @@ def discord_send(channel_id, content):
         "User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}",
     }
     body = {"content": content, "allowed_mentions": {"parse": []}}
+    files = None
+    if image and image.get("mode") == "url":
+        body["embeds"] = [{"image": {"url": image["url"]}}]
+    elif image and image.get("mode") == "bytes":
+        fname = image.get("filename", "image.jpg")
+        body["embeds"] = [{"image": {"url": f"attachment://{fname}"}}]
+        files = {"files[0]": (fname, image["data"], image.get("mime", "image/jpeg"))}
+
     for attempt in (1, 2):
         try:
-            r = requests.post(url, json=body, headers=headers, timeout=15)
+            if files:
+                r = requests.post(url, data={"payload_json": json.dumps(body)},
+                                  files=files, headers=headers, timeout=30)
+            else:
+                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:
@@ -162,7 +182,7 @@ def discord_send(channel_id, content):
             time.sleep(min(wait, 5.0))
             continue
         if 200 <= r.status_code < 300:
-            return True, "sent"
+            return True, "sent with image" if image else "sent"
         return False, f"discord api {r.status_code}: {r.text[:150]}"
     return False, "rate limited"
 
@@ -233,7 +253,15 @@ def render_template(template, payload):
 
 
 def default_format(source_name, payload):
-    pretty = json.dumps(payload, indent=2, ensure_ascii=False)
+    def shorten(v):
+        if isinstance(v, str) and len(v) > 200:
+            return v[:200] + f"… ({len(v)} chars)"
+        if isinstance(v, dict):
+            return {k: shorten(x) for k, x in v.items()}
+        if isinstance(v, list):
+            return [shorten(x) for x in v[:20]]
+        return v
+    pretty = json.dumps(shorten(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```"
@@ -258,6 +286,79 @@ def passes_filters(source, payload):
     return True
 
 
+# ---------------------------------------------------------------------------
+# Image embedding
+# ---------------------------------------------------------------------------
+
+IMAGE_MAX_BYTES = 8 * 1024 * 1024  # Discord's default upload cap
+
+_MAGIC = [
+    (b"\xff\xd8\xff", "image/jpeg", "jpg"),
+    (b"\x89PNG", "image/png", "png"),
+    (b"GIF8", "image/gif", "gif"),
+    (b"RIFF", "image/webp", "webp"),
+]
+
+
+def _sniff(data):
+    for magic, mime, ext in _MAGIC:
+        if data.startswith(magic):
+            return mime, ext
+    return None, None
+
+
+def resolve_image(source, payload):
+    """Resolve a source's image_path against the payload.
+
+    Returns an image dict for discord_send, or None. Modes:
+      embed  - hand Discord the URL (only works for internet-reachable URLs)
+      attach - Dis2Hook fetches the URL itself (works on the LAN) or decodes
+               base64 image data, then uploads it as an attachment.
+    """
+    path = (source.get("image_path") or "").strip().strip("{}")
+    if not path:
+        return None
+    val = dig(payload, path)
+    if val is None or not isinstance(val, str) or not val.strip():
+        return None
+    val = val.strip()
+    mode = source.get("image_mode", "attach")
+
+    if val.startswith("http://") or val.startswith("https://"):
+        if mode == "embed":
+            return {"mode": "url", "url": val}
+        try:
+            r = requests.get(val, timeout=20, stream=True)
+            r.raise_for_status()
+            data = r.raw.read(IMAGE_MAX_BYTES + 1, decode_content=True)
+        except requests.RequestException as e:
+            _log(f"Image fetch failed ({val[:80]}): {e}")
+            return None
+        if len(data) > IMAGE_MAX_BYTES:
+            _log("Image skipped: larger than 8 MB.")
+            return None
+        mime, ext = _sniff(data)
+        if not mime:
+            _log("Image skipped: fetched data is not a recognized image format.")
+            return None
+        return {"mode": "bytes", "data": data, "mime": mime, "filename": f"image.{ext}"}
+
+    # Not a URL: try base64 (raw, or a data: URI)
+    b64 = val
+    if b64.startswith("data:"):
+        b64 = b64.split(",", 1)[-1]
+    try:
+        data = base64.b64decode(b64, validate=False)
+    except (ValueError, binascii.Error):
+        return None
+    if not data or len(data) > IMAGE_MAX_BYTES:
+        return None
+    mime, ext = _sniff(data)
+    if not mime:
+        return None
+    return {"mode": "bytes", "data": data, "mime": mime, "filename": f"image.{ext}"}
+
+
 # ---------------------------------------------------------------------------
 # Auth helpers
 # ---------------------------------------------------------------------------
@@ -363,6 +464,8 @@ def validate_config(data):
             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")
+        if s.get("image_mode") not in (None, "", "attach", "embed"):
+            problems.append(f"{label}: image mode must be 'attach' or 'embed'")
     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", ""))):
@@ -376,6 +479,20 @@ def validate_config(data):
     return problems
 
 
+@app.route("/api/sample/<source_id>")
+def api_sample(source_id):
+    """Last received payload for a source — powers the drag-and-drop
+    template builder in the web UI."""
+    err = require_admin()
+    if err:
+        return err
+    with _lock:
+        payload = _last_payloads.get(source_id)
+    if payload is None:
+        return jsonify({"ok": False, "payload": None})
+    return jsonify({"ok": True, "payload": payload})
+
+
 @app.route("/api/test/<source_id>", methods=["POST"])
 def api_test(source_id):
     err = require_admin()
@@ -384,15 +501,21 @@ def api_test(source_id):
     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(),
-    }
+    with _lock:
+        sample = _last_payloads.get(source_id)
+    used_real = sample is not None
+    if sample is None:
+        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)
+    label = "last received event" if used_real else "sample payload"
+    content = f"🧪 **Test** ({label}) — {source.get('name', source_id)}\n{content}"
+    image = resolve_image(source, sample)
+    ok, detail = discord_send(source.get("channel_id", ""), content, image=image)
     _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))
@@ -440,6 +563,8 @@ def receive_hook(source_id):
         return jsonify({"error": "invalid secret"}), 401
 
     payload = extract_payload(request)
+    with _lock:
+        _last_payloads[source_id] = payload  # feeds the UI template builder
 
     if not passes_filters(source, payload):
         with _lock:
@@ -452,7 +577,8 @@ def receive_hook(source_id):
     if not content.strip():
         content = default_format(source.get("name", source_id), payload)
 
-    ok, detail = discord_send(source.get("channel_id", ""), content)
+    image = resolve_image(source, payload)
+    ok, detail = discord_send(source.get("channel_id", ""), content, image=image)
     with _lock:
         _stats["relayed" if ok else "failed"] += 1
     _record(source.get("name", source_id), "relayed" if ok else "failed", detail)
@@ -531,4 +657,4 @@ if __name__ == "__main__":
     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)
+    app.run(host=CONFIG["listen_host"], port=int(CONFIG["listen_port"]), threaded=True)

+ 131 - 3
index.html

@@ -96,6 +96,20 @@
 
   .strip-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px;align-items:center}
 
+  /* ---------- template builder chips ---------- */
+  .chips-head{display:flex;align-items:center;gap:8px}
+  .chips-head .btn{margin-left:auto}
+  .chip-tray{display:flex;flex-wrap:wrap;gap:6px;padding:9px;background:var(--panel);
+    border:1px dashed var(--line);border-radius:4px;min-height:38px;max-height:132px;overflow:auto}
+  .chip-tray .none{font-family:var(--mono);font-size:11px;color:var(--faint);align-self:center}
+  .chip{font-family:var(--mono);font-size:11px;padding:3px 9px;border-radius:10px;cursor:grab;user-select:none;
+    background:var(--amber-dim);border:1px solid var(--amber);color:var(--text);max-width:100%;
+    overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .chip:active{cursor:grabbing}
+  .chip:hover{filter:brightness(1.2)}
+  .chip.img{background:var(--blurple-dim);border-color:var(--blurple)}
+  .droppable.dragover{outline:2px dashed var(--amber);outline-offset:1px}
+
   /* ---------- 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}
@@ -361,12 +375,51 @@ function uniqueId(base){
 $("#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:[] });
+                     enabled:true, template:"", filters:[], image_path:"", image_mode:"attach" });
   renderStrips(id); setDirty(true);
 });
 
 function hookURL(id){ return location.origin + "/hook/" + id; }
 
+/* ---------- template builder helpers ---------- */
+function flatten(obj, prefix="", out=[]){
+  if (out.length >= 80) return out;
+  if (Array.isArray(obj)) obj.slice(0,10).forEach((v,i)=>flatten(v, prefix+"["+i+"]", out));
+  else if (obj && typeof obj === "object")
+    Object.keys(obj).forEach(k=>flatten(obj[k], prefix ? prefix+"."+k : k, out));
+  else out.push({path: prefix, value: obj});
+  return out;
+}
+function isImageish(path, value){
+  if (typeof value !== "string") return false;
+  if (/\.(jpe?g|png|gif|webp)(\?|#|$)/i.test(value)) return true;
+  if (value.startsWith("data:image")) return true;
+  if (value.length > 400 && /^[A-Za-z0-9+/]{60}/.test(value)) return true;   // raw base64 blob
+  return /photo|image|img|picture|thumbnail|snapshot/i.test(path) && /^https?:\/\//.test(value);
+}
+function preview(v){
+  const s = String(v);
+  return s.length > 120 ? s.slice(0,120)+"…" : s;
+}
+function insertAtCaret(el, text){
+  const s = el.selectionStart ?? el.value.length, e = el.selectionEnd ?? s;
+  el.value = el.value.slice(0,s) + text + el.value.slice(e);
+  el.selectionStart = el.selectionEnd = s + text.length;
+  el.dispatchEvent(new Event("input", {bubbles:true}));
+  el.focus();
+}
+function makeDropTarget(el){
+  el.classList.add("droppable");
+  // Browsers insert dragged text into inputs/textareas natively at the drop
+  // caret; we only add the highlight and make sure state syncs afterwards.
+  el.addEventListener("dragenter", ()=> el.classList.add("dragover"));
+  el.addEventListener("dragleave", ()=> el.classList.remove("dragover"));
+  el.addEventListener("drop", ()=>{
+    el.classList.remove("dragover");
+    setTimeout(()=> el.dispatchEvent(new Event("input", {bubbles:true})), 0);
+  });
+}
+
 function copyText(text, btn){
   const done = ()=>{ const old=btn.textContent; btn.textContent="copied"; setTimeout(()=>btn.textContent=old,1200); };
   if (navigator.clipboard && navigator.clipboard.writeText)
@@ -422,7 +475,10 @@ function buildStrip(src, idx, open){
   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");
+  const toggleOpen = ()=>{
+    strip.classList.toggle("open");
+    if (strip.classList.contains("open") && !sampleLoaded) loadSample();
+  };
   head.addEventListener("click", toggleOpen);
   head.addEventListener("keydown", e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); toggleOpen(); }});
 
@@ -468,9 +524,80 @@ function buildStrip(src, idx, open){
   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); });
+  makeDropTarget(ta);
   body.appendChild(field("Markdown template", ta,
     "Discord Markdown plus {dotted.paths[0]} placeholders resolved from the incoming JSON payload."));
 
+  /* payload chips — drag & drop template builder */
+  const chipField = document.createElement("div"); chipField.className = "field";
+  const chipsHead = document.createElement("div"); chipsHead.className = "chips-head";
+  const chipLabel = document.createElement("label");
+  chipLabel.textContent = "Payload keys from the last received event";
+  chipLabel.style.marginBottom = "0";
+  const chipRefresh = document.createElement("button");
+  chipRefresh.className = "btn tiny ghost"; chipRefresh.textContent = "↻ refresh";
+  chipsHead.append(chipLabel, chipRefresh);
+  const tray = document.createElement("div"); tray.className = "chip-tray";
+  tray.innerHTML = '<span class="none">Open this source after it has received an event to build from real data.</span>';
+  const chipHint = document.createElement("div"); chipHint.className = "hint";
+  chipHint.textContent = "Drag a chip into the template (or the image field below) — or click to insert at the cursor. Blue chips look like images.";
+  chipField.append(chipsHead, tray, chipHint);
+  body.appendChild(chipField);
+
+  let sampleLoaded = false;
+  async function loadSample(){
+    tray.innerHTML = '<span class="none">Loading last event…</span>';
+    try {
+      const r = await api("/api/sample/"+encodeURIComponent(src.id));
+      tray.innerHTML = "";
+      if (!r.ok || !r.payload){
+        tray.innerHTML = '<span class="none">No events received yet — send one to '+hookURL(src.id)+' and refresh.</span>';
+        return;
+      }
+      const entries = flatten(r.payload);
+      if (!entries.length){
+        tray.innerHTML = '<span class="none">Last event had no usable keys.</span>'; return;
+      }
+      for (const {path, value} of entries){
+        const chip = document.createElement("span");
+        chip.className = "chip" + (isImageish(path, value) ? " img" : "");
+        chip.textContent = path;
+        chip.title = path + " = " + preview(value) +
+          (chip.classList.contains("img") ? "\n(drag into the image field to attach this picture)" : "");
+        chip.draggable = true;
+        chip.addEventListener("dragstart", e=>{
+          e.dataTransfer.setData("text/plain", "{"+path+"}");
+          e.dataTransfer.effectAllowed = "copy";
+        });
+        chip.addEventListener("click", ()=> insertAtCaret(ta, "{"+path+"}"));
+        tray.appendChild(chip);
+      }
+    } catch(e){
+      tray.innerHTML = '<span class="none">Could not load the last event.</span>';
+    }
+    sampleLoaded = true;
+  }
+  chipRefresh.addEventListener("click", loadSample);
+
+  /* image embedding */
+  const g3 = document.createElement("div"); g3.className = "grid2";
+  const imgIn = textInput(src.image_path, v=>{ src.image_path = v.replace(/[{}]/g,"").trim(); },
+    "e.g. finish_photo_url or image");
+  makeDropTarget(imgIn);
+  g3.appendChild(field("Image — payload path (optional)", imgIn,
+    "Path to an image URL or base64 image data in the payload. Drop a blue chip here."));
+  const modeSel = document.createElement("select");
+  for (const [val, label] of [["attach","fetch & upload (works for LAN URLs + base64)"],
+                              ["embed","embed link (Discord fetches the URL)"]]){
+    const o = document.createElement("option"); o.value = val; o.textContent = label;
+    if ((src.image_mode || "attach") === val) o.selected = true;
+    modeSel.appendChild(o);
+  }
+  modeSel.addEventListener("change", ()=>{ src.image_mode = modeSel.value; setDirty(true); });
+  g3.appendChild(field("Image delivery", modeSel,
+    "LAN addresses like 192.168.x.x are unreachable for Discord — use fetch & upload for those."));
+  body.appendChild(g3);
+
   /* filters */
   const fwrap = document.createElement("div"); fwrap.className = "filters";
   const flabel = document.createElement("div"); flabel.className = "field";
@@ -528,6 +655,7 @@ function buildStrip(src, idx, open){
   body.appendChild(actions);
 
   strip.append(head, body);
+  if (open) loadSample();
   return strip;
 }
 
@@ -539,4 +667,4 @@ function buildStrip(src, idx, open){
 })();
 </script>
 </body>
-</html>
+</html>