docs: finalize F31 auto-delivery record and constraints

This commit is contained in:
rookiestar28
2026-02-06 21:19:48 +08:00
parent 95929ad301
commit b887e25ead
24 changed files with 1123 additions and 90 deletions
+1
View File
@@ -24,6 +24,7 @@ openclaw_state/
*.log
test_output.txt
test_auth_out.txt
connector_state.json*
.DS_Store
.vscode/
.idea/
+5 -3
View File
@@ -213,14 +213,16 @@ Callback delivery allowlist (sidecar HTTP adapter):
## Templates
Templates live in `data/templates/` and are loaded from `data/templates/manifest.json`.
Templates live in `data/templates/`.
- Only templates listed in the manifest are usable.
- Each template declares `allowed_inputs` and optional defaults.
- Any `data/templates/<template_id>.json` file is runnable (template ID = filename stem).
- `data/templates/manifest.json` is optional metadata (e.g. defaults).
- Rendering performs **strict placeholder substitution**:
- Only exact string values matching `{{key}}` are replaced
- Partial substitutions (e.g. `"foo {{bar}}"`) are intentionally not supported
For the full step-by-step guide (where to put exported workflow JSON, how to author `manifest.json`, how to verify `/openclaw/templates`, and how to use `/run`), see `tests/TEST_SOP.md`.
## Execution Budgets
Queue submissions are protected by concurrency caps and render size budgets (`services/execution_budgets.py`).
+8 -6
View File
@@ -147,15 +147,17 @@ def _register_routes_once():
source="unknown",
):
"""Submit function for scheduler and trigger-triggered runs."""
from .services.idempotency_store import get_store
# NOTE: Use IdempotencyStore API (check_and_record/update_prompt_id).
# Avoid legacy get_store/get/set usage; wrong API here breaks route registration at runtime.
from .services.idempotency_store import IdempotencyStore
from .services.queue_submit import submit_prompt
from .services.templates import get_template_service
# Check idempotency
store = get_store()
existing = store.get(idempotency_key)
if existing:
return {"prompt_id": existing.get("prompt_id"), "deduped": True}
store = IdempotencyStore()
is_dup, existing_prompt_id = store.check_and_record(idempotency_key)
if is_dup:
return {"prompt_id": existing_prompt_id, "deduped": True}
# Render template
tmpl_svc = get_template_service()
@@ -174,7 +176,7 @@ def _register_routes_once():
# Store for dedupe
if result.get("prompt_id"):
store.set(idempotency_key, {"prompt_id": result["prompt_id"]})
store.update_prompt_id(idempotency_key, result["prompt_id"])
return result
+8
View File
@@ -21,6 +21,7 @@ PACK_NAME = PACK_VERSION = PACK_START_TIME = LOG_FILE = get_api_key = None # ty
metrics = tail_log = require_observability_access = check_rate_limit = trace_store = None # type: ignore
webhook_handler = webhook_submit_handler = webhook_validate_handler = capabilities_handler = preflight_handler = None # type: ignore
config_get_handler = config_put_handler = llm_test_handler = llm_models_handler = None # type: ignore
templates_list_handler = None # type: ignore
secrets_status_handler = secrets_put_handler = secrets_delete_handler = None # type: ignore
list_checkpoints_handler = create_checkpoint_handler = get_checkpoint_handler = delete_checkpoint_handler = None # type: ignore
redact_text = None # type: ignore
@@ -49,6 +50,7 @@ if web is not None:
secrets_put_handler,
secrets_status_handler,
)
from ..api.templates import templates_list_handler
from ..api.webhook import webhook_handler
from ..api.webhook_submit import webhook_submit_handler
from ..api.webhook_validate import webhook_validate_handler
@@ -89,6 +91,7 @@ if web is not None:
secrets_put_handler,
secrets_status_handler,
)
from api.templates import templates_list_handler
from api.webhook import webhook_handler
from api.webhook_submit import webhook_submit_handler
from api.webhook_validate import webhook_validate_handler
@@ -456,6 +459,11 @@ def register_routes(server) -> None:
f"{prefix}/llm/models",
llm_models_handler,
), # F20+: Remote model list (best-effort)
(
"GET",
f"{prefix}/templates",
templates_list_handler,
), # F29: Template quick list for chat connectors
(
"POST",
f"{prefix}/preflight",
+13 -7
View File
@@ -12,10 +12,16 @@ from ..services.scheduler.models import Schedule, TriggerType
from ..services.scheduler.storage import get_schedule_store
from ..services.templates import is_template_allowed
try:
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
#
# IMPORTANT: Avoid a broad `try/except ImportError` here. Falling back to `services.*` in ComfyUI
# can silently import another pack's module and break auth/approval semantics.
if __package__ and "." in __package__:
from ..services.webhook_auth import AuthError
except ImportError:
from services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.webhook_auth import AuthError # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.schedules")
@@ -112,8 +118,8 @@ class ScheduleHandlers:
template_id = data["template_id"]
if not self._template_checker(template_id):
return web.json_response(
{"error": f"template_id '{template_id}' is not in allowlist"},
status=403,
{"error": f"template_id '{template_id}' not found"},
status=404,
)
# Build schedule
@@ -165,8 +171,8 @@ class ScheduleHandlers:
template_id = data["template_id"]
if not self._template_checker(template_id):
return web.json_response(
{"error": f"template_id '{template_id}' is not in allowlist"},
status=403,
{"error": f"template_id '{template_id}' not found"},
status=404,
)
existing.template_id = template_id
+112
View File
@@ -0,0 +1,112 @@
"""
Templates API (F29 support).
Provides a lightweight endpoint to list template IDs that can be used
with `/openclaw/triggers/fire` (and by chat connectors via `/run <template_id> ...`).
"""
from __future__ import annotations
import logging
try:
from aiohttp import web # type: ignore
except ImportError: # pragma: no cover (optional for unit tests)
web = None # type: ignore
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
if __package__ and "." in __package__:
from ..services.access_control import require_observability_access
from ..services.rate_limit import check_rate_limit
from ..services.templates import get_template_service
else: # pragma: no cover (test-only import mode)
from services.access_control import require_observability_access # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.templates import get_template_service # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.templates")
def _ensure_templates_api_deps_ready() -> tuple[bool, str | None]:
"""
Defensive guard against a recurring regression class:
if import discipline is broken, these symbols can become `None`,
causing aiohttp to emit noisy tracebacks and clients to see ERR_INVALID_RESPONSE.
"""
missing = []
if not callable(require_observability_access):
missing.append("require_observability_access")
if not callable(check_rate_limit):
missing.append("check_rate_limit")
if not callable(get_template_service):
missing.append("get_template_service")
if missing:
return (
False,
"Backend not fully initialized (missing route dependencies: "
+ ", ".join(missing)
+ ").",
)
return True, None
async def templates_list_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/templates (legacy: /moltbot/templates)
Returns the templates visible to the backend (file-based discovery + optional manifest metadata).
This is safe to expose under the observability boundary because it contains
no secrets and does not return workflow bodies.
"""
if web is None:
raise RuntimeError("aiohttp not available")
ok, init_error = _ensure_templates_api_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
allowed, error = require_observability_access(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)
# Reuse the admin bucket to avoid unbounded enumeration from remote callers.
if not check_rate_limit(request, "admin"):
return web.json_response(
{"ok": False, "error": "Rate limit exceeded"},
status=429,
headers={"Retry-After": "60"},
)
try:
svc = get_template_service()
items = []
# Prefer runtime discovery (file-based templates) so operators don't need
# to maintain a separate allowlist file.
for template_id in svc.get_debug_info().get("discovered_template_ids", []): # type: ignore[call-arg]
cfg = svc.get_template_config(template_id) # type: ignore[arg-type]
if cfg is None:
continue
items.append(
{
"id": template_id,
"allowed_inputs": list(cfg.allowed_inputs or []),
"defaults": dict(cfg.defaults or {}),
}
)
items.sort(key=lambda x: x["id"])
resp: dict = {"ok": True, "templates": items, "count": len(items)}
# Optional diagnostics. This reveals absolute paths, so keep it opt-in.
debug = request.query.get("debug", "").strip() in ("1", "true", "yes")
if debug:
try:
resp["debug"] = svc.get_debug_info() # type: ignore[attr-defined]
except Exception:
# If TemplateService interface changes, don't break the endpoint.
resp["debug"] = {"error": "debug_info_unavailable"}
return web.json_response(resp)
except Exception as e:
logger.exception("Failed to list templates")
return web.json_response({"ok": False, "error": str(e)}, status=500)
+37 -11
View File
@@ -12,17 +12,26 @@ from typing import Optional
from aiohttp import web
try:
# Import discipline:
# - ComfyUI runtime: this pack is loaded as a package; MUST use package-relative imports to avoid
# collisions with other custom nodes or other top-level modules named `services`.
# - Unit tests: modules may be imported as top-level (e.g. `api.*`), so allow top-level fallbacks.
#
# IMPORTANT (recurring production bug):
# Do NOT wrap these imports in a broad `try/except ImportError` without checking `__package__`.
# If the pack is loaded in a way that makes relative imports fail, falling back to `from services...`
# can silently import the WRONG module (another custom node or ComfyUI-adjacent package), causing
# template allowlists to appear "missing" even when `data/templates/manifest.json` is correct.
if __package__ and "." in __package__:
from ..services.execution_budgets import BudgetExceededError
from ..services.templates import is_template_allowed
from ..services.trace import generate_trace_id
from ..services.webhook_auth import AuthError
except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.execution_budgets import BudgetExceededError
from services.templates import is_template_allowed
from services.trace import generate_trace_id
from services.webhook_auth import AuthError
else: # pragma: no cover (test-only import mode)
from services.execution_budgets import BudgetExceededError # type: ignore
from services.templates import is_template_allowed # type: ignore
from services.trace import generate_trace_id # type: ignore
from services.webhook_auth import AuthError # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.triggers")
@@ -117,8 +126,8 @@ class TriggerHandlers:
# Check template allowlist
if not self._template_checker(template_id):
return web.json_response(
{"error": f"template_id '{template_id}' is not in allowlist"},
status=403,
{"error": f"template_id '{template_id}' not found"},
status=404,
)
# Extract optional fields
@@ -162,7 +171,17 @@ class TriggerHandlers:
callback: Optional[dict],
) -> web.Response:
"""Create an approval request instead of immediate execution."""
from services.approvals import ApprovalSource, get_approval_service
# IMPORTANT (recurring production bug):
# In ComfyUI runtime, do NOT import `services.*` as a fallback here.
# If another custom node exposes a top-level `services` package, you'll import the wrong
# module and create hard-to-debug runtime mismatches (approvals/allowlists/etc).
if __package__ and "." in __package__:
from ..services.approvals import ApprovalSource, get_approval_service
else: # pragma: no cover (test-only import mode)
from services.approvals import ( # type: ignore
ApprovalSource,
get_approval_service,
)
service = get_approval_service()
@@ -265,7 +284,14 @@ async def execute_approved_trigger(
Raises:
ValueError: If approval not found or not approved
"""
from services.approvals import ApprovalStatus, get_approval_service
# IMPORTANT: See note above about avoiding `services.*` imports in ComfyUI runtime.
if __package__ and "." in __package__:
from ..services.approvals import ApprovalStatus, get_approval_service
else: # pragma: no cover (test-only import mode)
from services.approvals import ( # type: ignore
ApprovalStatus,
get_approval_service,
)
service = get_approval_service()
approval = service.get(approval_id)
+19 -13
View File
@@ -8,7 +8,14 @@ import logging
from aiohttp import web
try:
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
#
# IMPORTANT (recurring production bug):
# Do NOT wrap these imports in a broad `try/except ImportError`. In ComfyUI, that can silently
# import another pack's top-level `services` module and break allowlists/auth in surprising ways.
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.callback_delivery import start_callback_watch
from ..services.execution_budgets import BudgetExceededError
@@ -20,19 +27,18 @@ try:
from ..services.trace import get_effective_trace_id
from ..services.trace_store import trace_store
from ..services.webhook_auth import require_auth
except ImportError:
# Handle path issues for testing or different contexts
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.callback_delivery import start_callback_watch
from services.execution_budgets import BudgetExceededError
from services.idempotency_store import IdempotencyStore
from services.metrics import metrics
from services.queue_submit import submit_prompt
from services.rate_limit import check_rate_limit
from services.templates import get_template_service
from services.trace import get_effective_trace_id
from services.trace_store import trace_store
from services.webhook_auth import require_auth
from services.callback_delivery import start_callback_watch # type: ignore
from services.execution_budgets import BudgetExceededError # type: ignore
from services.idempotency_store import IdempotencyStore # type: ignore
from services.metrics import metrics # type: ignore
from services.queue_submit import submit_prompt # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.templates import get_template_service # type: ignore
from services.trace import get_effective_trace_id # type: ignore
from services.trace_store import trace_store # type: ignore
from services.webhook_auth import require_auth # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_submit")
+18 -8
View File
@@ -28,7 +28,14 @@ try:
except ModuleNotFoundError: # pragma: no cover
web = None # type: ignore
try:
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
#
# IMPORTANT (recurring production bug):
# Do NOT wrap these imports in a broad `try/except ImportError`. In ComfyUI, that can silently
# import another pack's top-level `services` module and break allowlists/auth in surprising ways.
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.execution_budgets import BudgetExceededError, check_render_size
from ..services.metrics import metrics
@@ -36,14 +43,17 @@ try:
from ..services.templates import get_template_service
from ..services.trace import get_effective_trace_id
from ..services.webhook_auth import require_auth
except ImportError:
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.execution_budgets import BudgetExceededError, check_render_size
from services.metrics import metrics
from services.rate_limit import check_rate_limit
from services.templates import get_template_service
from services.trace import get_effective_trace_id
from services.webhook_auth import require_auth
from services.execution_budgets import ( # type: ignore
BudgetExceededError,
check_render_size,
)
from services.metrics import metrics # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.templates import get_template_service # type: ignore
from services.trace import get_effective_trace_id # type: ignore
from services.webhook_auth import require_auth # type: ignore
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_validate")
+18 -2
View File
@@ -12,6 +12,7 @@ from .openclaw_client import OpenClawClient
from .platforms.discord_gateway import DiscordGateway
from .platforms.line_webhook import LINEWebhookServer
from .platforms.telegram_polling import TelegramPolling
from .results_poller import ResultsPoller
from .router import CommandRouter
# Configure logging
@@ -24,7 +25,7 @@ logger = logging.getLogger("connector")
async def main():
logger.info("Initializing OpenClaw Connector (Phase 3)...")
logger.info("Initializing OpenClaw Connector (Phase 5)...")
# 1. Config
try:
@@ -42,14 +43,25 @@ async def main():
client = OpenClawClient(config)
await client.start() # Start session
router = CommandRouter(config, client)
# Shared Platforms Registry
platforms = {}
# Initialize Poller
poller = ResultsPoller(config, client, platforms)
# Initialize Router with Poller
router = CommandRouter(config, client, poller=poller)
tasks = []
# Start Poller
tasks.append(asyncio.create_task(poller.start()))
line_server = None
# 3. Platforms
if config.telegram_bot_token:
tg = TelegramPolling(config, router)
platforms["telegram"] = tg
tasks.append(asyncio.create_task(tg.start()))
else:
logger.info(
@@ -58,12 +70,14 @@ async def main():
if config.discord_bot_token:
dc = DiscordGateway(config, router)
platforms["discord"] = dc
tasks.append(asyncio.create_task(dc.start()))
else:
logger.info("Discord not configured (OPENCLAW_CONNECTOR_DISCORD_TOKEN missing)")
if config.line_channel_secret and config.line_channel_access_token:
line_server = LINEWebhookServer(config, router)
platforms["line"] = line_server
await line_server.start()
# If only LINE is active, tasks will be empty. Add a sleeper to keep loop alive.
if not tasks:
@@ -100,6 +114,8 @@ async def main():
finally:
if line_server:
await line_server.stop()
if poller:
await poller.stop()
await client.close()
logger.info("Connector stopped.")
+11
View File
@@ -14,6 +14,12 @@ class ConnectorConfig:
# OpenClaw Connection
openclaw_url: str = "http://127.0.0.1:8188"
admin_token: Optional[str] = None # To call admin endpoints
# Results Delivery
delivery_enabled: bool = True
delivery_max_images: int = 4
delivery_max_bytes: int = 10 * 1024 * 1024 # 10MB
delivery_timeout_sec: int = 600
# Telegram
telegram_bot_token: Optional[str] = None
@@ -53,6 +59,11 @@ def load_config() -> ConnectorConfig:
cfg.debug = os.environ.get("OPENCLAW_CONNECTOR_DEBUG", "0") == "1"
cfg.state_path = os.environ.get("OPENCLAW_CONNECTOR_STATE_PATH")
# Delivery
cfg.delivery_max_images = int(os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", "4"))
cfg.delivery_max_bytes = int(os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", str(10 * 1024 * 1024)))
cfg.delivery_timeout_sec = int(os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", "600"))
# Telegram
cfg.telegram_bot_token = os.environ.get("OPENCLAW_CONNECTOR_TELEGRAM_TOKEN")
if t_users := os.environ.get("OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS"):
+21
View File
@@ -23,3 +23,24 @@ class CommandResponse:
text: str
files: List[str] = field(default_factory=list) # Local paths to upload
buttons: List[dict] = field(default_factory=list) # Simple quick replies
class Platform:
"""Abstract base class for chat platforms."""
async def start(self):
"""Start the platform connection/polling."""
pass
async def stop(self):
"""Stop/cleanup."""
pass
async def send_image(self, channel_id: str, image_data: bytes, filename: str = "image.png", caption: Optional[str] = None):
"""Send an image to the channel."""
pass
async def send_message(self, channel_id: str, text: str):
"""Send a text message to the channel."""
pass
+25
View File
@@ -142,6 +142,31 @@ class OpenClawClient:
# Remediation: Cancel -> Interrupt (Global)
return await self._request("POST", "/api/interrupt", {})
async def get_view(self, filename: str, subfolder: str = "", type: str = "output") -> Optional[bytes]:
"""Download image/file from ComfyUI /view endpoint."""
params = {"filename": filename, "subfolder": subfolder, "type": type}
url = f"{self.base_url}/view"
session = self.session
local_session = False
if not session:
session = _create_session()
local_session = True
try:
async with session.get(url, params=params, headers=self.headers) as resp:
if resp.status == 200:
return await resp.read()
else:
logger.warning(f"get_view failed: {resp.status}")
return None
except Exception as e:
logger.error(f"get_view error: {e}")
return None
finally:
if local_session:
await session.close()
# --- Approvals ---
async def get_approvals(self) -> dict:
+62 -6
View File
@@ -6,7 +6,9 @@ WebSocket connection to Discord Gateway (simplified) with Rate Limit Handling.
import asyncio
import json
import logging
import logging
import time
from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
@@ -135,12 +137,10 @@ class DiscordGateway:
if channel_id in self.config.discord_allowed_channels:
is_allowed = True
if not is_allowed:
if self.config.debug:
logger.debug(
f"Ignored Discord message user={user_id} chan={channel_id}"
)
return
if not is_allowed and self.config.debug:
logger.debug(
f"Untrusted Discord message user={user_id} chan={channel_id} (will require approval)"
)
# Build Request
req = CommandRequest(
@@ -201,3 +201,59 @@ class DiscordGateway:
)
break
async def send_image(self, channel_id: str, image_data: bytes, filename: str = "image.png", caption: Optional[str] = None):
"""Send image via Discord API."""
if not self.session:
return
import aiohttp
url = f"https://discord.com/api/v10/channels/{channel_id}/messages"
headers = {
"Authorization": f"Bot {self.token}",
# Do NOT set Content-Type; FormData handling does it
}
data = aiohttp.FormData()
if caption:
data.add_field("payload_json", json.dumps({"content": caption}))
data.add_field("files[0]", image_data, filename=filename, content_type="image/png")
try:
async with self.session.post(url, headers=headers, data=data) as resp:
if resp.status != 200:
err = await resp.text()
logger.error(f"Discord send_image failed: {resp.status} {err}")
except Exception as e:
logger.error(f"Discord send_image error: {e}")
async def send_message(self, channel_id: str, text: str):
"""Send text message."""
if not self.session:
return
import aiohttp
url = f"https://discord.com/api/v10/channels/{channel_id}/messages"
headers = {
"Authorization": f"Bot {self.token}",
"Content-Type": "application/json" # Explicit for JSON
}
# Simple Length Limit
if len(text) > 1900:
text = text[:1900] + "..."
payload = {"content": text}
try:
async with self.session.post(url, headers=headers, json=payload) as r:
if r.status != 200:
# Ignore 429 for now in this simple implementation or copy logic?
# Copying simple logging
err = await r.text()
logger.error(f"Discord send_message failed: {r.status} {err}")
except Exception as e:
logger.error(f"Discord send_message error: {e}")
+39 -5
View File
@@ -153,17 +153,16 @@ class LINEWebhookServer:
is_allowed = True
if not is_allowed:
# Remediation: Explicit logging for empty list or reject
msg = f"Ignored LINE message from user={user_id} in channel={channel_id}."
# Informational only: untrusted messages are accepted but will require approval.
msg = f"Untrusted LINE message from user={user_id} in channel={channel_id}."
if (
not self.config.line_allowed_users
and not self.config.line_allowed_groups
):
msg += " (Allow lists are empty! Configure OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS/GROUPS)"
msg += " (Allow lists are empty; all users will require approval)"
else:
msg += " (Not in allowlist)"
msg += " (Not in allowlist; approval required)"
logger.warning(msg)
return
req = CommandRequest(
platform="line",
@@ -214,3 +213,38 @@ class LINEWebhookServer:
)
except Exception as e:
logger.error(f"LINE reply exception: {e}")
async def send_image(self, channel_id: str, image_data: bytes, filename: str = "image.png", caption: Optional[str] = None):
"""
Send image via LINE.
NOTE: LINE requires a public HTTPS URL for images.
Raw bytes upload is not supported in the standard Push API the same way.
This stub logs a warning until we implement a public hosting shim or use Imgur/S3.
"""
logger.warning("LINE send_image not implemented (requires public URL). Skipping.")
async def send_message(self, channel_id: str, text: str):
"""Send push message."""
aiohttp, _ = _import_aiohttp_web()
if not aiohttp or not self.session:
return
url = "https://api.line.me/v2/bot/message/push"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.config.line_channel_access_token}",
}
body = {
"to": channel_id,
"messages": [{"type": "text", "text": text[:2000]}] # LINE limit handling
}
try:
async with self.session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
err = await resp.text()
logger.error(f"LINE send_message failed: {resp.status} {err}")
except Exception as e:
logger.error(f"LINE send_message error: {e}")
+63 -7
View File
@@ -6,6 +6,7 @@ Long-polling implementation for Telegram Bot API.
import asyncio
import logging
import time
from typing import Optional
from ..config import ConnectorConfig
from ..contract import CommandRequest, CommandResponse
@@ -62,12 +63,28 @@ class TelegramPolling:
async with self.session.get(url, params=params) as resp:
if resp.status != 200:
logger.error(f"Telegram API Error {resp.status}")
# IMPORTANT (debuggability): Telegram frequently returns actionable details in the body
# for non-200 responses (e.g. 409 conflict: "terminated by other getUpdates request",
# or "webhook is active"). Log the response text in debug mode to speed up diagnosis.
try:
body = await resp.text()
except Exception:
body = ""
if body:
logger.error(f"Telegram API Error {resp.status}: {body}")
else:
logger.error(f"Telegram API Error {resp.status}")
await asyncio.sleep(5)
return
data = await resp.json()
if not data.get("ok"):
# Telegram sometimes returns `ok=false` with a useful description even on 200.
# Keep logs concise, but include enough context to fix config issues quickly.
desc = (
data.get("description") or data.get("error_code") or "unknown_error"
)
logger.warning(f"Telegram API returned ok=false: {desc}")
return
updates = data.get("result", [])
@@ -97,12 +114,10 @@ class TelegramPolling:
if chat_id in self.config.telegram_allowed_chats:
is_allowed = True
if not is_allowed:
if self.config.debug:
logger.debug(
f"Ignored Telegram message from unauthorized user={user_id} chat={chat_id}"
)
return
if not is_allowed and self.config.debug:
logger.debug(
f"Untrusted Telegram message user={user_id} chat={chat_id} (will require approval)"
)
# Build Request
req = CommandRequest(
@@ -139,3 +154,44 @@ class TelegramPolling:
)
except Exception as e:
logger.error(f"Telegram send exception: {e}")
async def send_image(self, channel_id: str, image_data: bytes, filename: str = "image.png", caption: Optional[str] = None):
"""Send photo via Telegram sendPhoto."""
if not self.session:
return
import aiohttp # Lazy import safe here as we have session
url = f"{self.base_url}/sendPhoto"
data = aiohttp.FormData()
data.add_field("chat_id", channel_id)
if caption:
data.add_field("caption", caption)
data.add_field("photo", image_data, filename=filename, content_type="image/png")
try:
async with self.session.post(url, data=data) as resp:
if resp.status != 200:
err = await resp.text()
logger.error(f"Telegram send_image failed: {resp.status} {err}")
except Exception as e:
logger.error(f"Telegram send_image error: {e}")
async def send_message(self, channel_id: str, text: str):
"""Send text message."""
if not self.session:
return
# Reuse internal logic logic but public
# Using simplified direct call
url = f"{self.base_url}/sendMessage"
payload = {"chat_id": channel_id, "text": text}
try:
async with self.session.post(url, json=payload) as r:
if r.status != 200:
err = await r.text()
logger.error(f"Telegram send_message failed: {r.status} {err}")
except Exception as e:
logger.error(f"Telegram send_message error: {e}")
+161
View File
@@ -0,0 +1,161 @@
import asyncio
import logging
import time
from typing import Dict, Optional
from .config import ConnectorConfig
from .contract import Platform
from .openclaw_client import OpenClawClient
logger = logging.getLogger(__name__)
class ResultsPoller:
"""
Polls ComfyUI history for completed jobs and triggers delivery.
"""
def __init__(
self,
config: ConnectorConfig,
client: OpenClawClient,
platforms: Dict[str, Platform],
):
self.config = config
self.client = client
self.platforms = platforms # map "telegram" -> TelegramPolling, etc.
self.queue = asyncio.Queue() # (prompt_id, platform_name, channel_id, sender_id)
self.active_polls = {} # prompt_id -> task
self.active_polls = {} # prompt_id -> task
async def start(self):
"""Start the main queue consumer."""
logger.info("ResultsPoller started.")
while True:
item = await self.queue.get()
try:
prompt_id, platform_name, channel_id, sender_id = item
# Spawn a background poll for this job
task = asyncio.create_task(
self._poll_job(prompt_id, platform_name, channel_id, sender_id)
)
self.active_polls[prompt_id] = task
task.add_done_callback(lambda t, pid=prompt_id: self.active_polls.pop(pid, None))
finally:
self.queue.task_done()
async def stop(self):
"""Graceful shutdown."""
logger.info("ResultsPoller stopping...")
for task in self.active_polls.values():
task.cancel()
if self.active_polls:
await asyncio.gather(*self.active_polls.values(), return_exceptions=True)
logger.info("ResultsPoller stopped.")
def track_job(self, prompt_id: str, platform_name: str, channel_id: str, sender_id: str):
"""Enqueue a job for result monitoring."""
if not prompt_id:
return
logger.info(f"Tracking job {prompt_id} for {platform_name} in {channel_id}")
self.queue.put_nowait((prompt_id, platform_name, channel_id, sender_id))
async def _poll_job(
self, prompt_id: str, platform_name: str, channel_id: str, sender_id: str
):
"""Poll history with backoff until complete or timeout."""
start_time = time.time()
delay = 1.0
while (time.time() - start_time) < self.config.delivery_timeout_sec:
# Check history
try:
hist = await self.client.get_history(prompt_id)
if hist.get("ok"):
# ComfyUI /history/{prompt_id} -> { "prompt_id": { ... } }
data = hist.get("data", {})
if prompt_id in data:
job_data = data[prompt_id]
await self._deliver_results(
prompt_id, job_data, platform_name, channel_id
)
return
except Exception as e:
logger.debug(f"Poll check failed for {prompt_id}: {e}")
# Backoff
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
raise
delay = min(delay * 2, 15) # Cap at 15s
logger.warning(f"Job {prompt_id} timed out waiting for results.")
await self._send_text(platform_name, channel_id, f"⚠️ Job {prompt_id} timed out waiting for results.")
async def _deliver_results(
self, prompt_id: str, job_data: dict, platform_name: str, channel_id: str
):
"""Download images and send to platform."""
outputs = job_data.get("outputs", {})
if not outputs:
logger.info(f"Job {prompt_id} has no outputs.")
await self._send_text(platform_name, channel_id, f"✅ Job {prompt_id} finished (No output images).")
return
images_to_send = []
# Flatten outputs
for node_id, node_output in outputs.items():
if "images" in node_output:
images_to_send.extend(node_output["images"])
# Check if actual images were found (filter non-outputs)
if not images_to_send:
logger.info(f"Job {prompt_id} finished but no images found.")
await self._send_text(platform_name, channel_id, f"✅ Job {prompt_id} finished (No images).")
return
images_to_send = images_to_send[: self.config.delivery_max_images]
logger.info(f"Delivering {len(images_to_send)} images for {prompt_id}")
platform = self.platforms.get(platform_name)
if not platform:
logger.error(f"Platform {platform_name} not loaded.")
return
for img_info in images_to_send:
filename = img_info.get("filename")
subfolder = img_info.get("subfolder", "")
img_type = img_info.get("type", "output")
# Download content
content = await self.client.get_view(filename, subfolder, img_type)
if not content:
logger.warning(f"Failed to download {filename}")
continue
# Check size
if len(content) > self.config.delivery_max_bytes:
logger.warning(f"Image {filename} too large ({len(content)} bytes). Skipping.")
await self._send_text(platform_name, channel_id, f"⚠️ Image {filename} skipped (too large).")
continue
# Send with error handling
try:
await platform.send_image(channel_id, content, filename=filename)
except Exception as e:
logger.error(f"Failed to deliver image to {platform_name}: {e}")
# Fallback text
await self._send_text(platform_name, channel_id, f"⚠️ Failed to send image: {filename}")
async def _send_text(self, platform_name: str, channel_id: str, text: str):
platform = self.platforms.get(platform_name)
if platform:
try:
await platform.send_message(channel_id, text)
except Exception as e:
logger.error(f"Failed to send text to {platform_name}: {e}")
+65 -6
View File
@@ -12,13 +12,17 @@ from .contract import CommandRequest, CommandResponse
from .openclaw_client import OpenClawClient
from .state import ConnectorState
if False: # Type hinting only
from .results_poller import ResultsPoller
logger = logging.getLogger(__name__)
class CommandRouter:
def __init__(self, config: ConnectorConfig, client: OpenClawClient):
def __init__(self, config: ConnectorConfig, client: OpenClawClient, poller: "ResultsPoller" = None):
self.config = config
self.client = client
self.poller = poller
self.state = ConnectorState(path=self.config.state_path)
async def handle(self, req: CommandRequest) -> CommandResponse:
@@ -42,7 +46,7 @@ class CommandRouter:
handlers = {
("/status", "status"): (self._handle_status, False),
("/help", "help", "/start"): (self._handle_help, False),
("/run", "run"): (self._handle_run, True),
("/run", "run"): (self._handle_run, False),
("/interrupt", "interrupt", "/cancel", "cancel", "/stop"): (
self._handle_interrupt,
True,
@@ -90,6 +94,50 @@ class CommandRouter:
def _is_admin(self, user_id: str) -> bool:
return str(user_id) in self.config.admin_users
def _is_trusted(self, req: CommandRequest) -> bool:
"""
Trusted users can execute /run immediately.
Untrusted users are routed to approval flow.
"""
if self._is_admin(req.sender_id):
return True
platform = (req.platform or "").lower()
sender_id = str(req.sender_id)
channel_id = str(req.channel_id)
if platform == "telegram":
try:
uid = int(sender_id)
except Exception:
uid = None
try:
cid = int(channel_id)
except Exception:
cid = None
if uid is not None and uid in self.config.telegram_allowed_users:
return True
if cid is not None and cid in self.config.telegram_allowed_chats:
return True
return False
if platform == "discord":
if sender_id in self.config.discord_allowed_users:
return True
if channel_id in self.config.discord_allowed_channels:
return True
return False
if platform == "line":
if sender_id in self.config.line_allowed_users:
return True
if channel_id in self.config.line_allowed_groups:
return True
return False
# Unknown platform: trust only admins
return False
# --- Handlers ---
async def _handle_status(
@@ -130,11 +178,11 @@ class CommandRouter:
)
# Parse flags
require_approval = False
explicit_approval = False
clean_args = []
for arg in args:
if arg in ("--require-approval", "--approval", "-a"):
require_approval = True
explicit_approval = True
else:
clean_args.append(arg)
@@ -148,6 +196,9 @@ class CommandRouter:
k, v = arg.split("=", 1)
inputs[k.strip()] = v.strip()
trusted = self._is_trusted(req)
require_approval = explicit_approval or (not trusted)
res = await self.client.submit_job(
template_id, inputs, require_approval=require_approval
)
@@ -163,6 +214,9 @@ class CommandRouter:
return CommandResponse(text=msg)
else:
prompt_id = data.get("prompt_id", "unknown")
if self.poller:
self.poller.track_job(prompt_id, req.platform, req.channel_id, req.sender_id)
return CommandResponse(
text=f"[Job Submitted]\nID: {prompt_id}\nTemplate: {template_id}\nTrace: {trace_id}"
)
@@ -232,7 +286,12 @@ class CommandRouter:
# Phase 4: Show execution result
if "prompt_id" in data:
msg += f"\nExecuted: {data['prompt_id']}"
pid = data['prompt_id']
msg += f"\nExecuted: {pid}"
if self.poller:
# Approval request might have come from different flow, but usually user invoking /approve
# wants the result. Using current req context is safest assumption for "ChatOps".
self.poller.track_job(pid, req.platform, req.channel_id, req.sender_id)
elif data.get("executed") is False:
msg += "\n(Not Executed)"
if err := data.get("execution_error"):
@@ -297,7 +356,7 @@ class CommandRouter:
text=(
"OpenClaw Connector\n"
"/status - Check system health and queue\n"
"/run <template> [k=v] - Run a generation (Admin)\n"
"/run <template> [k=v] - Run a generation (trusted users auto-exec; others require approval)\n"
"/stop - Global Interrupt (Admin)\n"
"/history <id> - Job details\n"
"/jobs - Queue summary\n"
+6 -1
View File
@@ -49,7 +49,12 @@ async def submit_prompt(
Raises:
BudgetExceededError: If concurrency or size budgets are exceeded
"""
from services.execution_budgets import check_render_size, get_limiter
# NOTE: Must try relative import first. In ComfyUI runtime, `services` is not a top-level module.
# Keeping this order prevents "No module named 'services.execution_budgets'" during queue submit.
try:
from .execution_budgets import check_render_size, get_limiter
except ImportError:
from services.execution_budgets import check_render_size, get_limiter # type: ignore
# R33: Check render size budget
check_render_size(prompt_workflow, trace_id=trace_id)
+108 -11
View File
@@ -2,7 +2,7 @@
Template Service (R8/F5).
Loads manifest and renders templates for execution.
- Enforces strict allowlist from manifest
- Templates are runnable by ID
- Uses safe_io to load files
- Renders workflow JSON with safe inputs
"""
@@ -39,30 +39,128 @@ class TemplateService:
def __init__(self, templates_root: str = TEMPLATES_ROOT):
self.templates_root = templates_root
self.manifest: Dict[str, TemplateConfig] = {}
self._manifest_abspath = os.path.join(self.templates_root, MANIFEST_PATH)
self._manifest_mtime: Optional[float] = None
self._last_load_error: Optional[str] = None
self._load_manifest()
def _maybe_reload_manifest(self) -> None:
"""
Lightweight hot-reload:
if manifest.json changes on disk, reload it.
This prevents a common support pitfall where users edit `manifest.json`
but forget to restart ComfyUI (or restart the UI but not the backend).
"""
try:
mtime = os.path.getmtime(self._manifest_abspath)
except Exception:
return
if self._manifest_mtime is None or mtime > self._manifest_mtime:
self._load_manifest()
def _discover_template_ids(self) -> List[str]:
"""
Discover runnable template IDs from disk.
Policy:
- Any `data/templates/<template_id>.json` present on disk is considered runnable.
- `manifest.json` is optional and only used for per-template metadata (defaults, etc).
Safety boundary is enforced elsewhere:
- path traversal protection (`safe_io.resolve_under_root`)
- strict placeholder substitution (no partial replacements)
- request size limits and execution budgets
"""
try:
entries = os.listdir(self.templates_root)
except Exception:
entries = []
ids: set[str] = set()
for name in entries:
if not name.endswith(".json"):
continue
if name == MANIFEST_PATH:
continue
if name.startswith("."):
continue
ids.add(os.path.splitext(name)[0])
ids.update(self.manifest.keys())
return sorted(ids)
def _load_manifest(self):
"""Load and validate the template manifest."""
try:
self._last_load_error = None
data = safe_read_json(self.templates_root, MANIFEST_PATH)
if data.get("version") != 1:
logger.error(f"Unsupported manifest version: {data.get('version')}")
self._last_load_error = (
f"unsupported_manifest_version:{data.get('version')}"
)
return
self.manifest.clear()
for t_id, t_cfg in data.get("templates", {}).items():
self.manifest[t_id] = TemplateConfig(
path=t_cfg["path"],
allowed_inputs=t_cfg.get("allowed_inputs", []),
defaults=t_cfg.get("defaults", {}),
)
logger.info(f"Loaded {len(self.manifest)} templates from manifest")
try:
self._manifest_mtime = os.path.getmtime(self._manifest_abspath)
except Exception:
self._manifest_mtime = None
logger.info(
f"Loaded {len(self.manifest)} templates from manifest: {self._manifest_abspath}"
)
except FileNotFoundError:
logger.warning("Manifest not found, no templates available")
self._last_load_error = "manifest_not_found"
except Exception as e:
logger.error(f"Failed to load manifest: {e}")
# IMPORTANT (recurring support issue):
# When users report "template_id missing" even after editing manifest.json,
# the FIRST thing to verify is which manifest path was loaded by the running pack.
# Keep `_manifest_abspath` + `_last_load_error` so `/openclaw/templates?debug=1`
# can prove what file was actually read in the current runtime.
self._last_load_error = f"manifest_load_failed:{type(e).__name__}:{e}"
logger.error(f"Failed to load manifest ({self._manifest_abspath}): {e}")
def get_debug_info(self) -> Dict[str, Any]:
"""
Diagnostics-only metadata for support/debug tooling.
Do not expose this to untrusted callers without an access boundary.
"""
return {
"templates_root": self.templates_root,
"manifest_abspath": self._manifest_abspath,
"manifest_mtime": self._manifest_mtime,
"template_ids": sorted(list(self.manifest.keys())),
"template_count": len(self.manifest),
"discovered_template_ids": self._discover_template_ids(),
"last_load_error": self._last_load_error,
}
def get_template_config(self, template_id: str) -> Optional[TemplateConfig]:
return self.manifest.get(template_id)
self._maybe_reload_manifest()
cfg = self.manifest.get(template_id)
if cfg is not None:
return cfg
# No manifest entry: treat `<template_id>.json` as runnable if present.
rel_path = f"{template_id}.json"
try:
abs_path = resolve_under_root(self.templates_root, rel_path)
except Exception:
return None
if not os.path.isfile(abs_path):
return None
return TemplateConfig(path=rel_path, allowed_inputs=[], defaults={})
def render_template(
self, template_id: str, inputs: Dict[str, Any]
@@ -71,7 +169,7 @@ class TemplateService:
Render a template into a ComfyUI prompt workflow.
Args:
template_id: allowlisted template ID
template_id: template ID
inputs: input values to inject
Returns:
@@ -84,10 +182,9 @@ class TemplateService:
if not config:
raise ValueError(f"Unknown template: {template_id}")
# Validate inputs against allowlist
for key in inputs:
if key not in config.allowed_inputs:
raise ValueError(f"Input not allowed: {key}")
# NOTE:
# We intentionally do NOT enforce a per-template input allowlist.
# Unused keys have no effect because substitutions only happen on exact placeholders.
# Load template workflow
# Config path is relative to templates_root (or absolute if inside root? assume relative to manifest)
@@ -126,7 +223,7 @@ class TemplateService:
#
# Let's try to inject into standard Primitive nodes or specific nodes if we can identify them.
# BUT wait, the plan implies we *can* submit the inputs.
# "Render a workflow template... using only allowlisted inputs."
# "Render a workflow template... using only safe substitutions."
#
# If the template is a saved API format (Graph), it has node IDs.
# If we just want to pass the inputs to a wrapper node (like MoltBot nodes), that's easier.
@@ -166,5 +263,5 @@ def get_template_service() -> TemplateService:
def is_template_allowed(template_id: str) -> bool:
"""Return True if template_id exists in the manifest allowlist."""
"""Return True if template_id is runnable (manifest entry or `<id>.json` exists)."""
return get_template_service().get_template_config(template_id) is not None
+165
View File
@@ -60,6 +60,171 @@ npm test
For OS-specific E2E setup (Windows/WSL temp-dir shims), see `tests/E2E_TESTING_SOP.md`.
## Chat Connector (Telegram / Discord / LINE) — Manual Test SOP
The chat connector runs as a **separate process** and talks to your local ComfyUI/OpenClaw via HTTP.
### Prereq: use the correct Python interpreter
The connector requires `aiohttp`. A common failure mode on Windows is:
- `pip show aiohttp` succeeds (installed in your conda env)
- but `python3 -m connector` uses a different Python (e.g. system Python) and crashes with `ModuleNotFoundError: aiohttp`
Sanity check:
```powershell
python -c "import sys; print(sys.executable)"
python -c "import aiohttp; print(aiohttp.__version__)"
```
Run the connector with **the same** interpreter:
```powershell
python -m connector
```
### Common env (all platforms)
- `OPENCLAW_CONNECTOR_URL`: ComfyUI base URL (default: `http://127.0.0.1:8188`)
- `OPENCLAW_CONNECTOR_ADMIN_TOKEN`: optional; required for admin endpoints if your server enforces it
- `OPENCLAW_CONNECTOR_DEBUG=1`: verbose logs (recommended while setting up allowlists)
### 1) Telegram (recommended first: no webhook/HTTPS required)
Minimum:
```powershell
$env:OPENCLAW_CONNECTOR_TELEGRAM_TOKEN="123456:ABC..."
$env:OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS="123456789" # your Telegram user_id
$env:OPENCLAW_CONNECTOR_ADMIN_USERS="123456789" # for admin-only commands
python -m connector
```
Test commands (in Telegram chat with the bot):
- `/help`
- `/status`
- `/jobs`
- `/run <template_id> key=value --approval`
- `/approvals`
- `/approve <approval_id>`
### 2) Discord (no webhook/HTTPS required; requires Message Content Intent)
In Discord Developer Portal, enable **Message Content Intent** for your bot, otherwise the connector can connect but wont receive message text.
Minimum:
```powershell
$env:OPENCLAW_CONNECTOR_DISCORD_TOKEN="discord_bot_token"
$env:OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS="your_discord_user_id"
$env:OPENCLAW_CONNECTOR_ADMIN_USERS="your_discord_user_id"
python -m connector
```
Optional allowlist by channel instead:
```powershell
$env:OPENCLAW_CONNECTOR_DISCORD_ALLOWED_CHANNELS="your_channel_id"
```
### 3) LINE (requires a public HTTPS webhook URL)
LINE is webhook-based: LINE servers must be able to `POST` into your connector.
Localhost (`127.0.0.1`) is not reachable from LINE, so you typically need **Cloudflare Tunnel** or **ngrok**.
Minimum:
```powershell
$env:OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET="line_channel_secret"
$env:OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN="line_channel_access_token"
$env:OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS="your_line_user_id"
$env:OPENCLAW_CONNECTOR_ADMIN_USERS="your_line_user_id"
python -m connector
```
Optional bind/port/path:
```powershell
$env:OPENCLAW_CONNECTOR_LINE_BIND="127.0.0.1"
$env:OPENCLAW_CONNECTOR_LINE_PORT="8099"
$env:OPENCLAW_CONNECTOR_LINE_PATH="/line/webhook"
```
After starting the connector, expose it via tunnel and set the LINE webhook URL to:
`https://<public-host>/line/webhook`
If messages are ignored, enable debug and check allowlist logs (user/group/room IDs).
## Templates + `/run` — Authoring & Validation SOP
`/run` does **not** take a ComfyUI “workflow id”. It takes a **`template_id`** that maps to a JSON workflow file.
### Where templates live
In this repo (and in your ComfyUI install), templates are loaded from:
- `data/templates/*.json` (the exported ComfyUI workflow in API format)
- `data/templates/manifest.json` (optional metadata: defaults, etc)
### Step-by-step: create a new template
1) Export a workflow JSON from ComfyUI (API format)
- Build your workflow in ComfyUI
- Export the workflow JSON (API format) to a file, e.g. `z.json`
2) Copy the exported file into the template directory
- Place it at: `data/templates/z.json`
3) Replace input values with placeholders
The renderer performs **strict placeholder substitution**:
- ✅ supported: a JSON string value exactly equal to `{{key}}`
- Example: `"text": "{{positive_prompt}}"`
- ❌ not supported: partial substitutions
- Example: `"text": "Prompt: {{positive_prompt}}"` (will not be replaced)
So for each field you want to make configurable via chat/webhook, replace the value with a placeholder:
- `{{positive_prompt}}`
- `{{negative_prompt}}`
- `{{seed}}`
- etc.
4) Add an entry to `manifest.json`
This step is **optional**. If you want defaults/metadata, add a new entry under `templates` in `data/templates/manifest.json`:
```json
"your_template_id": {
"path": "z.json",
"allowed_inputs": ["positive_prompt"],
"defaults": {}
}
```
Rules:
- `your_template_id` becomes the identifier used by `/run your_template_id ...` (typically match the file name, e.g. `z`)
- `allowed_inputs` is **metadata only** (not enforced); it can be used by UIs/tools for hints
- `defaults` is optional but recommended (use `{}` if none)
- JSON cannot contain trailing commas
5) Restart ComfyUI
Not strictly required (the backend hot-reloads `manifest.json`), but restarting ComfyUI is still recommended after significant template changes.
### Validate templates are visible
Use the template quick-list endpoint:
- `GET /openclaw/templates`
- `GET /api/openclaw/templates` (browser-friendly)
- Diagnostics (when a template is unexpectedly missing):
- `GET /api/openclaw/templates?debug=1` (shows which `manifest.json` path was actually loaded)
Expected response:
- `ok: true`
- `templates: [{ id, allowed_inputs, defaults }, ...]`
### Use `/run` from chat
Once the template appears in `/openclaw/templates`, you can run it via chat:
- Run immediately:
- `/run your_template_id positive_prompt="a cat" seed=123`
- Request approval:
- `/run your_template_id positive_prompt="a cat" seed=123 --approval`
Unused keys have no effect unless the workflow contains a matching `{{key}}` placeholder.
## WSL / Restricted Environments
If `pre-commit` fails due to cache permissions, run with a writable cache directory:
```bash
@@ -120,6 +120,23 @@ class TestCommandRouterPhase2(unittest.TestCase):
"my-template", {}, require_approval=True
)
def test_run_requires_approval_for_untrusted_user(self):
# Non-admin, not in allowlist => approval required
self.client.submit_job.return_value = {
"ok": True,
"data": {
"pending": True,
"approval_id": "apr-untrusted",
"trace_id": "tid-u",
},
}
req = self._req("/run my-template prompt=hi", sender="123")
resp = asyncio.run(self.router.handle(req))
self.assertIn("Approval", resp.text)
self.client.submit_job.assert_called_with(
"my-template", {"prompt": "hi"}, require_approval=True
)
def test_admin_gating_deny(self):
# User 123 is not admin
req = self._req("/approvals", sender="123")
+126
View File
@@ -0,0 +1,126 @@
import asyncio
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from connector.config import ConnectorConfig
from connector.contract import Platform
from connector.results_poller import ResultsPoller
class MockPlatform(Platform):
async def send_image(self, channel_id, image_data, filename="image.png", caption=None):
pass
async def send_message(self, channel_id, text):
pass
class TestResultsPoller(unittest.TestCase):
def setUp(self):
self.config = ConnectorConfig()
self.config.delivery_timeout_sec = 5 # short timeout for tests
self.client = MagicMock()
self.client.get_history = AsyncMock()
self.client.get_view = AsyncMock()
self.mock_platform = MockPlatform()
self.mock_platform.send_image = AsyncMock()
self.mock_platform.send_message = AsyncMock()
self.platforms = {"test_plat": self.mock_platform}
self.poller = ResultsPoller(self.config, self.client, self.platforms)
def test_track_job(self):
self.poller.track_job("p-1", "test_plat", "c-1", "u-1")
self.assertEqual(self.poller.queue.qsize(), 1)
item = self.poller.queue.get_nowait()
self.assertEqual(item, ("p-1", "test_plat", "c-1", "u-1"))
@patch("connector.results_poller.time")
@patch("connector.results_poller.asyncio.sleep", new_callable=AsyncMock)
def test_poll_job_success(self, mock_sleep, mock_time):
mock_time.time.side_effect = [0, 1, 2, 3]
self.client.get_history.side_effect = [
{"ok": True, "data": {}},
{"ok": True, "data": {"p-1": {"outputs": {"node-1": {"images": [{"filename": "f.png", "type": "output"}]}}}}}
]
self.client.get_view.return_value = b"image_bytes"
asyncio.run(self.poller._poll_job("p-1", "test_plat", "c-1", "u-1"))
self.assertEqual(self.client.get_history.call_count, 2)
self.client.get_view.assert_called_with("f.png", "", "output")
self.mock_platform.send_image.assert_called_with("c-1", b"image_bytes", filename="f.png")
@patch("connector.results_poller.time")
@patch("connector.results_poller.asyncio.sleep", new_callable=AsyncMock)
def test_poll_job_no_outputs(self, mock_sleep, mock_time):
# Scenario: Job finished, but "outputs" is empty or has no images.
mock_time.time.side_effect = [0, 1, 2]
self.client.get_history.side_effect = [
{"ok": True, "data": {"p-empty": {"outputs": {}}}} # Completed, empty outputs
]
asyncio.run(self.poller._poll_job("p-empty", "test_plat", "c-1", "u-1"))
self.mock_platform.send_image.assert_not_called()
self.mock_platform.send_message.assert_called_once()
args = self.mock_platform.send_message.call_args
self.assertIn("No output images", args[0][1])
@patch("connector.results_poller.time")
@patch("connector.results_poller.asyncio.sleep", new_callable=AsyncMock)
def test_poll_job_timeout(self, mock_sleep, mock_time):
self.config.delivery_timeout_sec = 2
mock_time.time.side_effect = [0, 1, 3]
self.client.get_history.return_value = {"ok": True, "data": {}}
asyncio.run(self.poller._poll_job("p-timeout", "test_plat", "c-1", "u-1"))
self.mock_platform.send_image.assert_not_called()
self.mock_platform.send_message.assert_called_once()
self.assertIn("timed out", self.mock_platform.send_message.call_args[0][1])
def test_deliver_results_limits(self):
self.config.delivery_max_images = 1
job_data = {
"outputs": {
"n1": {
"images": [
{"filename": "1.png", "type": "output"},
{"filename": "2.png", "type": "output"}
]
}
}
}
self.client.get_view.return_value = b"123"
asyncio.run(self.poller._deliver_results("p-1", job_data, "test_plat", "c-1"))
self.assertEqual(self.mock_platform.send_image.call_count, 1)
args = self.mock_platform.send_image.call_args
self.assertEqual(args[1]["filename"], "1.png")
def test_deliver_results_send_failure(self):
# Scenario: Platform.send_image raises Exception
job_data = {
"outputs": { "n1": { "images": [{"filename": "f.png", "type": "output"}] } }
}
self.client.get_view.return_value = b"bytes"
self.mock_platform.send_image.side_effect = Exception("Network Error")
asyncio.run(self.poller._deliver_results("p-fail", job_data, "test_plat", "c-1"))
# Should catch exception and try sending fallback text
self.mock_platform.send_message.assert_called()
self.assertIn("Failed to send image", self.mock_platform.send_message.call_args[0][1])
if __name__ == "__main__":
unittest.main()
+15 -4
View File
@@ -34,6 +34,11 @@ class TestTemplateService(unittest.TestCase):
with open(os.path.join(self.test_dir, "t1.json"), "w") as f:
json.dump(self.template_data, f)
# Create an additional template file that is NOT in the manifest.
# Policy: `<template_id>.json` on disk should be runnable even without a manifest entry.
with open(os.path.join(self.test_dir, "t2.json"), "w") as f:
json.dump({"node1": {"inputs": {"text": "{{input_any}}"}}}, f)
self.service = TemplateService(templates_root=self.test_dir)
def tearDown(self):
@@ -51,10 +56,11 @@ class TestTemplateService(unittest.TestCase):
with self.assertRaises(ValueError):
self.service.render_template("unknown", {})
def test_not_allowed_input(self):
"""Test forbidden input raises ValueError."""
with self.assertRaises(ValueError):
self.service.render_template("t1", {"forbidden": "val"})
def test_extra_input_is_ignored(self):
"""Extra inputs should not raise (policy: no per-template input allowlist)."""
rendered = self.service.render_template("t1", {"forbidden": "val"})
# Placeholder remains because `forbidden` does not match any placeholder in the template.
self.assertEqual(rendered["node1"]["inputs"]["text"], "{{input1}}")
def test_render_substitution(self):
"""Test variable substitution."""
@@ -73,6 +79,11 @@ class TestTemplateService(unittest.TestCase):
rendered["node1"]["inputs"]["text"], "prefix {{input1}} suffix"
)
def test_file_based_template_without_manifest_entry(self):
"""Templates present as `<id>.json` should be runnable even if not in manifest.json."""
rendered = self.service.render_template("t2", {"input_any": "hello"})
self.assertEqual(rendered["node1"]["inputs"]["text"], "hello")
if __name__ == "__main__":
unittest.main()