6f5fe892da
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>
214 lines
8.5 KiB
Python
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}")
|