Files
Ethanfel 6f5fe892da Webhook: back off + retry on 429, and report the real status
Discord rate-limits a webhook after a burst of sends. _upload_to_webhook
now honours the 429 retry_after and retries (up to 4×), so a batch of
images goes through instead of failing. The frontend also reports the
webhook's actual status (j.status) — previously it showed the route's
200 ("Send failed: HTTP 200"), masking the real 429.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:06:12 +02:00

214 lines
8.5 KiB
Python

"""
ComfyPeeper Companion — bridges ComfyUI with the ComfyPeeper Discord plugin.
Drop this folder into ComfyUI/custom_nodes/ (as `comfypeeper`) and restart ComfyUI.
It adds three routes on the ComfyUI server:
GET /comfypeeper/info advertise a friendly name + capabilities (so the plugin can
label this server and light up companion features)
POST /comfypeeper/load { workflow } -> push the workflow to open editor tabs, which
the bundled frontend extension loads via app.loadGraphData()
POST /comfypeeper/send { filename, subfolder, type } -> upload that output image to
the configured Discord webhook (the image carries its workflow
in PNG metadata, so ComfyPeeper detects it on the Discord side)
Configure by copying config.example.json -> config.json and setting `name` (and
`discord_webhook` if you want "Send to Discord").
"""
import asyncio
import io
import json
import os
import aiohttp
from aiohttp import web
import folder_paths
from server import PromptServer
VERSION = "1.0.0"
_DIR = os.path.dirname(os.path.realpath(__file__))
_CONFIG_PATH = os.path.join(_DIR, "config.json")
def _config():
try:
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
cfg = json.load(f)
except Exception:
cfg = {}
return {
"name": (cfg.get("name") or "ComfyUI").strip(),
"discord_webhook": (cfg.get("discord_webhook") or "").strip(),
}
def _resolve_image_path(filename, subfolder, type_):
"""Resolve a /view-style image ref to a file path, clamped inside the type's base dir."""
if not filename:
return None
base = folder_paths.get_directory_by_type(type_ or "output")
if not base:
return None
base = os.path.abspath(base)
target = os.path.abspath(os.path.join(base, subfolder or "", filename))
# block path traversal outside the base directory
if os.path.commonpath([base, target]) != base:
return None
return target if os.path.isfile(target) else None
async def _upload_to_webhook(webhook, filename, data, content="", _attempt=0):
"""POST image bytes to a Discord webhook (multipart). Respects 429 rate limits. Returns (ok, status, body)."""
form = aiohttp.FormData()
form.add_field("payload_json", json.dumps({"content": content}))
form.add_field("files[0]", data, filename=filename, content_type="application/octet-stream")
async with aiohttp.ClientSession() as session:
async with session.post(webhook, data=form) as resp:
if resp.status == 429 and _attempt < 4:
# Discord rate-limited the webhook — wait the requested backoff and retry
retry = 1.0
try:
retry = float((await resp.json()).get("retry_after", retry))
except Exception:
try:
retry = float(resp.headers.get("Retry-After", retry))
except Exception:
pass
await asyncio.sleep(min(retry + 0.25, 15))
return await _upload_to_webhook(webhook, filename, data, content, _attempt + 1)
return resp.status in (200, 204), resp.status, (await resp.text())[:300]
routes = PromptServer.instance.routes
@routes.get("/comfypeeper/info")
async def comfypeeper_info(_request):
cfg = _config()
caps = ["load"]
if cfg["discord_webhook"]:
caps.append("send")
return web.json_response({"app": "ComfyPeeper", "name": cfg["name"], "version": VERSION, "caps": caps})
@routes.post("/comfypeeper/load")
async def comfypeeper_load(request):
try:
data = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
workflow = data.get("workflow")
if workflow is None:
return web.json_response({"ok": False, "error": "no workflow"}, status=400)
# broadcast to every connected tab; the frontend extension calls app.loadGraphData()
PromptServer.instance.send_sync("comfypeeper.load", {"workflow": workflow})
return web.json_response({"ok": True})
@routes.post("/comfypeeper/send")
async def comfypeeper_send(request):
webhook = _config()["discord_webhook"]
if not webhook:
return web.json_response({"ok": False, "error": "no webhook configured"}, status=400)
try:
data = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "invalid json"}, status=400)
path = _resolve_image_path(data.get("filename"), data.get("subfolder", ""), data.get("type", "output"))
if not path:
return web.json_response({"ok": False, "error": "image not found"}, status=404)
try:
with open(path, "rb") as f:
file_bytes = f.read()
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
try:
ok, status, body = await _upload_to_webhook(webhook, os.path.basename(path), file_bytes, (data.get("content") or "").strip())
return web.json_response({"ok": ok, "status": status, "data": body})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
class SendToDiscord:
"""Output node: send each incoming image (with workflow metadata) to the Discord webhook."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"images": ("IMAGE",)},
"optional": {
"message": ("STRING", {"default": "", "multiline": True}),
"filename_prefix": ("STRING", {"default": "ComfyPeeper"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ()
FUNCTION = "send"
OUTPUT_NODE = True
CATEGORY = "ComfyPeeper"
def send(self, images, message="", filename_prefix="ComfyPeeper", prompt=None, extra_pnginfo=None):
webhook = _config()["discord_webhook"]
if not webhook:
print("[ComfyPeeper] SendToDiscord: no discord_webhook in config.json — skipping")
return {"ui": {"images": []}}
import numpy as np # lazy: keep the routes working even if image libs are unusual
from PIL import Image
from PIL.PngImagePlugin import PngInfo
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
ui_images = []
for i, image in enumerate(images):
arr = np.clip(255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8)
img = Image.fromarray(arr)
# embed workflow + prompt into the PNG, same as ComfyUI's SaveImage, so ComfyPeeper detects it
meta = PngInfo()
try:
if prompt is not None:
meta.add_text("prompt", json.dumps(prompt))
for k, v in (extra_pnginfo or {}).items():
meta.add_text(k, json.dumps(v))
except Exception as e:
print(f"[ComfyPeeper] SendToDiscord: metadata embed failed: {e}")
buf = io.BytesIO()
img.save(buf, format="PNG", pnginfo=meta, compress_level=4)
data = buf.getvalue()
# run the upload on the server's event loop (this node runs in a worker thread)
try:
fut = asyncio.run_coroutine_threadsafe(
_upload_to_webhook(webhook, f"{filename_prefix}_{i:03d}.png", data, (message or "").strip()),
PromptServer.instance.loop,
)
ok, status, _ = fut.result(timeout=60)
if not ok:
print(f"[ComfyPeeper] SendToDiscord: webhook returned {status}")
except Exception as e:
print(f"[ComfyPeeper] SendToDiscord: send failed: {e}")
# drop a temp copy so the node previews what it sent
try:
tmp_name = f"comfypeeper_{i:03d}.png"
with open(os.path.join(temp_dir, tmp_name), "wb") as f:
f.write(data)
ui_images.append({"filename": tmp_name, "subfolder": "", "type": "temp"})
except Exception:
pass
return {"ui": {"images": ui_images}}
WEB_DIRECTORY = "./web"
NODE_CLASS_MAPPINGS = {"ComfyPeeperSendToDiscord": SendToDiscord}
NODE_DISPLAY_NAME_MAPPINGS = {"ComfyPeeperSendToDiscord": "Send to Discord (ComfyPeeper)"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
print(f"[ComfyPeeper] companion loaded (v{VERSION}) — name: {_config()['name']!r}")