|
@@ -17,6 +17,8 @@ Repo: https://gogs.av2x.dev/av2x/Dis2Hook
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
import atexit
|
|
import atexit
|
|
|
|
|
+import base64
|
|
|
|
|
+import binascii
|
|
|
import json
|
|
import json
|
|
|
import os
|
|
import os
|
|
|
import re
|
|
import re
|
|
@@ -31,7 +33,7 @@ from datetime import datetime, timezone
|
|
|
import requests
|
|
import requests
|
|
|
from flask import Flask, jsonify, request, send_from_directory
|
|
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__))
|
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
TOKEN_FILE = os.path.join(BASE_DIR, "token.json")
|
|
TOKEN_FILE = os.path.join(BASE_DIR, "token.json")
|
|
@@ -50,6 +52,7 @@ _lock = threading.RLock()
|
|
|
_started_at = time.time()
|
|
_started_at = time.time()
|
|
|
_stats = {"received": 0, "relayed": 0, "filtered": 0, "failed": 0}
|
|
_stats = {"received": 0, "relayed": 0, "filtered": 0, "failed": 0}
|
|
|
_activity = deque(maxlen=50) # ring buffer of recent events for the UI
|
|
_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}
|
|
_bot_identity = {"checked": 0, "ok": False, "name": None, "id": None}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -137,8 +140,13 @@ def get_source(source_id):
|
|
|
# Discord Bot API
|
|
# 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:
|
|
if not channel_id:
|
|
|
return False, "no channel configured"
|
|
return False, "no channel configured"
|
|
|
if len(content) > DISCORD_MSG_LIMIT:
|
|
if len(content) > DISCORD_MSG_LIMIT:
|
|
@@ -149,9 +157,21 @@ def discord_send(channel_id, content):
|
|
|
"User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}",
|
|
"User-Agent": f"Dis2Hook/{DIS2HOOK_VERSION}",
|
|
|
}
|
|
}
|
|
|
body = {"content": content, "allowed_mentions": {"parse": []}}
|
|
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):
|
|
for attempt in (1, 2):
|
|
|
try:
|
|
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:
|
|
except requests.RequestException as e:
|
|
|
return False, f"network error: {e}"
|
|
return False, f"network error: {e}"
|
|
|
if r.status_code == 429 and attempt == 1:
|
|
if r.status_code == 429 and attempt == 1:
|
|
@@ -162,7 +182,7 @@ def discord_send(channel_id, content):
|
|
|
time.sleep(min(wait, 5.0))
|
|
time.sleep(min(wait, 5.0))
|
|
|
continue
|
|
continue
|
|
|
if 200 <= r.status_code < 300:
|
|
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, f"discord api {r.status_code}: {r.text[:150]}"
|
|
|
return False, "rate limited"
|
|
return False, "rate limited"
|
|
|
|
|
|
|
@@ -233,7 +253,15 @@ def render_template(template, payload):
|
|
|
|
|
|
|
|
|
|
|
|
|
def default_format(source_name, 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:
|
|
if len(pretty) > 1700:
|
|
|
pretty = pretty[:1700] + "\n…"
|
|
pretty = pretty[:1700] + "\n…"
|
|
|
return f"**{source_name}** received an event:\n```json\n{pretty}\n```"
|
|
return f"**{source_name}** received an event:\n```json\n{pretty}\n```"
|
|
@@ -258,6 +286,79 @@ def passes_filters(source, payload):
|
|
|
return True
|
|
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
|
|
# Auth helpers
|
|
|
# ---------------------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|
|
@@ -363,6 +464,8 @@ def validate_config(data):
|
|
|
problems.append(f"{label}: secret must not be empty")
|
|
problems.append(f"{label}: secret must not be empty")
|
|
|
if not re.match(r"^\d{5,25}$", str(s.get("channel_id", ""))):
|
|
if not re.match(r"^\d{5,25}$", str(s.get("channel_id", ""))):
|
|
|
problems.append(f"{label}: channel ID must be numeric")
|
|
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"):
|
|
for section in ("heartbeat", "status"):
|
|
|
sec = data.get(section, {})
|
|
sec = data.get(section, {})
|
|
|
if sec.get("enabled") and not re.match(r"^\d{5,25}$", str(sec.get("channel_id", ""))):
|
|
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
|
|
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"])
|
|
@app.route("/api/test/<source_id>", methods=["POST"])
|
|
|
def api_test(source_id):
|
|
def api_test(source_id):
|
|
|
err = require_admin()
|
|
err = require_admin()
|
|
@@ -384,15 +501,21 @@ def api_test(source_id):
|
|
|
source = get_source(source_id)
|
|
source = get_source(source_id)
|
|
|
if not source:
|
|
if not source:
|
|
|
return jsonify({"error": "unknown source"}), 404
|
|
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()
|
|
template = (source.get("template") or "").strip()
|
|
|
content = render_template(template, sample) if template else default_format(source.get("name", source_id), sample)
|
|
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}")
|
|
_record(source.get("name", source_id), "relayed" if ok else "failed", f"test: {detail}")
|
|
|
return (jsonify({"ok": True}) if ok
|
|
return (jsonify({"ok": True}) if ok
|
|
|
else (jsonify({"ok": False, "error": detail}), 502))
|
|
else (jsonify({"ok": False, "error": detail}), 502))
|
|
@@ -440,6 +563,8 @@ def receive_hook(source_id):
|
|
|
return jsonify({"error": "invalid secret"}), 401
|
|
return jsonify({"error": "invalid secret"}), 401
|
|
|
|
|
|
|
|
payload = extract_payload(request)
|
|
payload = extract_payload(request)
|
|
|
|
|
+ with _lock:
|
|
|
|
|
+ _last_payloads[source_id] = payload # feeds the UI template builder
|
|
|
|
|
|
|
|
if not passes_filters(source, payload):
|
|
if not passes_filters(source, payload):
|
|
|
with _lock:
|
|
with _lock:
|
|
@@ -452,7 +577,8 @@ def receive_hook(source_id):
|
|
|
if not content.strip():
|
|
if not content.strip():
|
|
|
content = default_format(source.get("name", source_id), payload)
|
|
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:
|
|
with _lock:
|
|
|
_stats["relayed" if ok else "failed"] += 1
|
|
_stats["relayed" if ok else "failed"] += 1
|
|
|
_record(source.get("name", source_id), "relayed" if ok else "failed", detail)
|
|
_record(source.get("name", source_id), "relayed" if ok else "failed", detail)
|
|
@@ -531,4 +657,4 @@ if __name__ == "__main__":
|
|
|
atexit.register(on_shutdown)
|
|
atexit.register(on_shutdown)
|
|
|
send_status(f"🟢 **Dis2Hook** v{DIS2HOOK_VERSION} online.")
|
|
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)
|