mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat: the claw now supports WhatsApp
This commit is contained in:
@@ -12,6 +12,8 @@ ComfyUI-OpenClaw is a **security-first** ComfyUI custom node pack that adds:
|
||||
This project is intentionally **not** a general-purpose “assistant platform” with broad remote execution surfaces.
|
||||
It is designed to make **ComfyUI a reliable automation target** with an explicit admin boundary and hardened defaults.
|
||||
|
||||
**Current release**: `v0.2.1`
|
||||
|
||||
**Security stance (how this project differs from convenience-first automation packs):**
|
||||
|
||||
- Localhost-first defaults; remote access is opt-in
|
||||
@@ -398,12 +400,12 @@ python3 -m unittest discover -s tests -p "test_*.py"
|
||||
|
||||
## 🎮 Remote Control (Connector)
|
||||
|
||||
OpenClaw includes a standalone **Connector** process that allows you to control your local instance securely via **Telegram** or **Discord** without exposing it to the public internet.
|
||||
OpenClaw includes a standalone **Connector** process that allows you to control your local instance securely via **Telegram**, **Discord**, **LINE**, or **WhatsApp**.
|
||||
|
||||
- **Status & Queue**: Check job progress remotely.
|
||||
- **Run Jobs**: Submit templates via chat commands.
|
||||
- **Approvals**: Approve/Reject paused workflows from your phone.
|
||||
- **Secure**: Outbound-only connection; no inbound ports required.
|
||||
- **Secure**: Outbound-only for Telegram/Discord. LINE/WhatsApp require inbound HTTPS (webhook).
|
||||
|
||||
[👉 **See Setup Guide (docs/connector.md)**](docs/connector.md)
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 71 KiB |
+21
-2
@@ -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 .platforms.whatsapp_webhook import WhatsAppWebhookServer
|
||||
from .results_poller import ResultsPoller
|
||||
from .router import CommandRouter
|
||||
|
||||
@@ -36,6 +37,7 @@ def _print_security_banner(config):
|
||||
or config.discord_allowed_channels
|
||||
or config.line_allowed_users
|
||||
or config.line_allowed_groups
|
||||
or config.whatsapp_allowed_users
|
||||
)
|
||||
has_admins = bool(config.admin_users)
|
||||
|
||||
@@ -95,6 +97,7 @@ async def main():
|
||||
tasks.append(asyncio.create_task(poller.start()))
|
||||
|
||||
line_server = None
|
||||
whatsapp_server = None
|
||||
|
||||
# 3. Platforms
|
||||
if config.telegram_bot_token:
|
||||
@@ -129,9 +132,23 @@ async def main():
|
||||
"LINE not configured (OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET missing)"
|
||||
)
|
||||
|
||||
if not tasks and not line_server:
|
||||
if config.whatsapp_access_token and config.whatsapp_verify_token:
|
||||
whatsapp_server = WhatsAppWebhookServer(config, router)
|
||||
platforms["whatsapp"] = whatsapp_server
|
||||
await whatsapp_server.start()
|
||||
# If only WhatsApp is active, add sleeper
|
||||
if not tasks:
|
||||
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
|
||||
elif config.whatsapp_access_token:
|
||||
logger.warning("WhatsApp configured but Verify Token missing. Skipping.")
|
||||
else:
|
||||
logger.info(
|
||||
"WhatsApp not configured (OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN missing)"
|
||||
)
|
||||
|
||||
if not tasks and not line_server and not whatsapp_server:
|
||||
logger.error(
|
||||
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, or LINE_SECRET."
|
||||
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, LINE_SECRET, or WHATSAPP_ACCESS_TOKEN."
|
||||
)
|
||||
await client.close()
|
||||
return
|
||||
@@ -152,6 +169,8 @@ async def main():
|
||||
finally:
|
||||
if line_server:
|
||||
await line_server.stop()
|
||||
if whatsapp_server:
|
||||
await whatsapp_server.stop()
|
||||
if poller:
|
||||
await poller.stop()
|
||||
await client.close()
|
||||
|
||||
@@ -40,6 +40,16 @@ class ConnectorConfig:
|
||||
line_bind_port: int = 8099
|
||||
line_webhook_path: str = "/line/webhook"
|
||||
|
||||
# WhatsApp
|
||||
whatsapp_access_token: Optional[str] = None
|
||||
whatsapp_verify_token: Optional[str] = None
|
||||
whatsapp_app_secret: Optional[str] = None # For signature verification
|
||||
whatsapp_phone_number_id: Optional[str] = None
|
||||
whatsapp_allowed_users: List[str] = field(default_factory=list)
|
||||
whatsapp_bind_host: str = "127.0.0.1"
|
||||
whatsapp_bind_port: int = 8098
|
||||
whatsapp_webhook_path: str = "/whatsapp/webhook"
|
||||
|
||||
# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
|
||||
admin_users: List[str] = field(default_factory=list)
|
||||
|
||||
@@ -122,6 +132,31 @@ def load_config() -> ConnectorConfig:
|
||||
"OPENCLAW_CONNECTOR_LINE_PATH", "/line/webhook"
|
||||
)
|
||||
|
||||
# WhatsApp
|
||||
cfg.whatsapp_access_token = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN"
|
||||
)
|
||||
cfg.whatsapp_verify_token = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_VERIFY_TOKEN"
|
||||
)
|
||||
cfg.whatsapp_app_secret = os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET")
|
||||
cfg.whatsapp_phone_number_id = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_PHONE_NUMBER_ID"
|
||||
)
|
||||
if wa_users := os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS"):
|
||||
cfg.whatsapp_allowed_users = [
|
||||
u.strip() for u in wa_users.split(",") if u.strip()
|
||||
]
|
||||
cfg.whatsapp_bind_host = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_BIND", "127.0.0.1"
|
||||
)
|
||||
if wa_port := os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_PORT"):
|
||||
if wa_port.isdigit():
|
||||
cfg.whatsapp_bind_port = int(wa_port)
|
||||
cfg.whatsapp_webhook_path = os.environ.get(
|
||||
"OPENCLAW_CONNECTOR_WHATSAPP_PATH", "/whatsapp/webhook"
|
||||
)
|
||||
|
||||
# Admin
|
||||
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
|
||||
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
WhatsApp Cloud API Platform Adapter (F36).
|
||||
Receives webhooks from WhatsApp Cloud API, verifies signature, and routes commands.
|
||||
|
||||
Setup:
|
||||
1. Create a Meta App → WhatsApp → Add a phone number.
|
||||
2. Set env vars: OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN, VERIFY_TOKEN, PHONE_NUMBER_ID.
|
||||
3. Configure webhook URL: https://<public>/whatsapp/webhook
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from ..config import ConnectorConfig
|
||||
from ..contract import CommandRequest, CommandResponse
|
||||
from ..router import CommandRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _import_aiohttp_web():
|
||||
"""
|
||||
Import aiohttp + aiohttp.web lazily.
|
||||
|
||||
Keeps unit tests runnable in environments where aiohttp isn't installed.
|
||||
"""
|
||||
try:
|
||||
import aiohttp # type: ignore
|
||||
from aiohttp import web # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
return None, None
|
||||
return aiohttp, web
|
||||
|
||||
|
||||
# Graph API version (can be overridden via env)
|
||||
GRAPH_API_VERSION = "v19.0"
|
||||
GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
|
||||
|
||||
|
||||
class WhatsAppWebhookServer:
|
||||
"""
|
||||
WhatsApp Cloud API webhook adapter.
|
||||
|
||||
GET /whatsapp/webhook → hub.challenge verification
|
||||
POST /whatsapp/webhook → message handling + signature check
|
||||
"""
|
||||
|
||||
# F32 WP2: Replay protection
|
||||
REPLAY_WINDOW_SEC = 300
|
||||
NONCE_CACHE_SIZE = 1000
|
||||
|
||||
def __init__(self, config: ConnectorConfig, router: CommandRouter):
|
||||
self.config = config
|
||||
self.router = router
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self.session = None
|
||||
# F32 WP2: LRU nonce cache (message_id -> timestamp)
|
||||
self._nonce_cache: dict = {}
|
||||
# F33 Media Store
|
||||
from ..media_store import MediaStore
|
||||
|
||||
self.media_store = MediaStore(config)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def start(self):
|
||||
"""Start the webhook server."""
|
||||
aiohttp, web = _import_aiohttp_web()
|
||||
if aiohttp is None or web is None:
|
||||
logger.warning("aiohttp not installed. Skipping WhatsApp adapter.")
|
||||
return
|
||||
|
||||
if not self.config.whatsapp_access_token:
|
||||
logger.warning(
|
||||
"WhatsApp Access Token missing. Skipping WhatsApp adapter."
|
||||
)
|
||||
return
|
||||
|
||||
if not self.config.whatsapp_verify_token:
|
||||
logger.warning(
|
||||
"WhatsApp Verify Token missing. Skipping WhatsApp adapter."
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting WhatsApp Webhook on "
|
||||
f"{self.config.whatsapp_bind_host}:{self.config.whatsapp_bind_port}"
|
||||
f"{self.config.whatsapp_webhook_path}"
|
||||
)
|
||||
self.session = aiohttp.ClientSession()
|
||||
|
||||
self.app = web.Application()
|
||||
self.app.router.add_get(
|
||||
self.config.whatsapp_webhook_path, self.handle_verify
|
||||
)
|
||||
self.app.router.add_post(
|
||||
self.config.whatsapp_webhook_path, self.handle_webhook
|
||||
)
|
||||
# F33 Media Route
|
||||
media_route = f"{self.config.media_path}/{{token}}"
|
||||
self.app.router.add_get(media_route, self._handle_media_request)
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(
|
||||
self.runner, self.config.whatsapp_bind_host, self.config.whatsapp_bind_port
|
||||
)
|
||||
await self.site.start()
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the server."""
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Webhook Handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def handle_verify(self, request):
|
||||
"""
|
||||
GET webhook verification (Meta hub.challenge handshake).
|
||||
|
||||
Meta sends:
|
||||
?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>
|
||||
"""
|
||||
_, web = _import_aiohttp_web()
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
mode = request.query.get("hub.mode")
|
||||
token = request.query.get("hub.verify_token")
|
||||
challenge = request.query.get("hub.challenge")
|
||||
|
||||
if mode == "subscribe" and token == self.config.whatsapp_verify_token:
|
||||
logger.info("WhatsApp webhook verified successfully")
|
||||
return web.Response(text=challenge or "", content_type="text/plain")
|
||||
|
||||
logger.warning(f"WhatsApp verification failed: mode={mode}")
|
||||
return web.Response(status=403, text="Verification failed")
|
||||
|
||||
async def handle_webhook(self, request):
|
||||
"""POST webhook handler for inbound messages."""
|
||||
_, web = _import_aiohttp_web()
|
||||
if web is None:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
body_bytes = await request.read()
|
||||
|
||||
# Signature verification (if app secret is configured)
|
||||
if self.config.whatsapp_app_secret:
|
||||
signature = request.headers.get("X-Hub-Signature-256", "")
|
||||
if not self._verify_signature(body_bytes, signature):
|
||||
logger.warning("Invalid WhatsApp webhook signature")
|
||||
return web.Response(status=401, text="Invalid Signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(body_bytes.decode("utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return web.Response(status=400, text="Bad JSON")
|
||||
|
||||
# WhatsApp Cloud API payload structure:
|
||||
# { "object": "whatsapp_business_account", "entry": [...] }
|
||||
if payload.get("object") != "whatsapp_business_account":
|
||||
return web.Response(status=200, text="OK") # Ignore non-WA events
|
||||
|
||||
for entry in payload.get("entry", []):
|
||||
for change in entry.get("changes", []):
|
||||
value = change.get("value", {})
|
||||
if change.get("field") != "messages":
|
||||
continue
|
||||
|
||||
messages = value.get("messages", [])
|
||||
contacts = value.get("contacts", [])
|
||||
metadata = value.get("metadata", {})
|
||||
|
||||
for msg in messages:
|
||||
await self._process_message(msg, contacts, metadata)
|
||||
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Media
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_media_request(self, request):
|
||||
"""Serve media files for verified tokens."""
|
||||
_, web = _import_aiohttp_web()
|
||||
token = request.match_info.get("token")
|
||||
path = self.media_store.get_image_path(token)
|
||||
|
||||
if not path:
|
||||
return web.Response(status=404, text="Media Not Found or Expired")
|
||||
|
||||
return web.FileResponse(path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Signature Verification
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _verify_signature(self, body: bytes, signature_header: str) -> bool:
|
||||
"""
|
||||
Verify X-Hub-Signature-256 using HMAC-SHA256 with app secret.
|
||||
|
||||
Header format: sha256=<hex_digest>
|
||||
"""
|
||||
if not signature_header or not self.config.whatsapp_app_secret:
|
||||
return False
|
||||
|
||||
if not signature_header.startswith("sha256="):
|
||||
return False
|
||||
|
||||
expected_sig = signature_header[7:] # Strip "sha256=" prefix
|
||||
secret = self.config.whatsapp_app_secret.encode("utf-8")
|
||||
computed = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
|
||||
return hmac.compare_digest(computed, expected_sig)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Replay Protection (F32 WP2)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _check_replay_protection(self, message_id: str, timestamp: int) -> bool:
|
||||
"""
|
||||
Returns False if the message should be rejected (replay or stale).
|
||||
"""
|
||||
now = time.time()
|
||||
age_sec = now - timestamp
|
||||
|
||||
if age_sec > self.REPLAY_WINDOW_SEC or age_sec < -60:
|
||||
logger.debug(f"Stale or future WhatsApp message: age={age_sec:.1f}s")
|
||||
return False
|
||||
|
||||
if message_id in self._nonce_cache:
|
||||
logger.debug(f"Duplicate WhatsApp message: {message_id}")
|
||||
return False
|
||||
|
||||
self._nonce_cache[message_id] = timestamp
|
||||
self._evict_old_nonces()
|
||||
return True
|
||||
|
||||
def _evict_old_nonces(self):
|
||||
"""Remove old entries from nonce cache."""
|
||||
if len(self._nonce_cache) <= self.NONCE_CACHE_SIZE:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
cutoff = now - self.REPLAY_WINDOW_SEC
|
||||
self._nonce_cache = {k: v for k, v in self._nonce_cache.items() if v > cutoff}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message Processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _process_message(
|
||||
self, msg: dict, contacts: list, metadata: dict
|
||||
):
|
||||
"""Convert WhatsApp message to CommandRequest and route."""
|
||||
msg_type = msg.get("type")
|
||||
if msg_type != "text":
|
||||
# Only handle text messages in this phase
|
||||
logger.debug(f"Ignoring WhatsApp message type: {msg_type}")
|
||||
return
|
||||
|
||||
sender_id = msg.get("from", "")
|
||||
message_id = msg.get("id", "")
|
||||
timestamp = int(msg.get("timestamp", 0))
|
||||
text = msg.get("text", {}).get("body", "")
|
||||
phone_number_id = metadata.get("phone_number_id", "")
|
||||
|
||||
if not text or not sender_id:
|
||||
return
|
||||
|
||||
# Replay protection
|
||||
if not self._check_replay_protection(message_id, timestamp):
|
||||
logger.warning(f"Replay rejected for WhatsApp message {message_id}")
|
||||
return
|
||||
|
||||
# Resolve contact name
|
||||
username = "whatsapp_user"
|
||||
for contact in contacts:
|
||||
if contact.get("wa_id") == sender_id:
|
||||
profile = contact.get("profile", {})
|
||||
username = profile.get("name", username)
|
||||
break
|
||||
|
||||
# Security: Allowlist check
|
||||
is_allowed = False
|
||||
if sender_id in self.config.whatsapp_allowed_users:
|
||||
is_allowed = True
|
||||
|
||||
if not is_allowed:
|
||||
msg_info = f"Untrusted WhatsApp message from user={sender_id}."
|
||||
if not self.config.whatsapp_allowed_users:
|
||||
msg_info += " (Allow list empty; all users will require approval)"
|
||||
else:
|
||||
msg_info += " (Not in allowlist; approval required)"
|
||||
logger.warning(msg_info)
|
||||
|
||||
req = CommandRequest(
|
||||
platform="whatsapp",
|
||||
sender_id=str(sender_id),
|
||||
channel_id=str(sender_id), # WhatsApp DMs use phone number
|
||||
username=username,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
timestamp=float(timestamp),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self.router.handle(req)
|
||||
if resp.text:
|
||||
await self.send_message(sender_id, resp.text)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error handling WhatsApp command: {e}")
|
||||
await self.send_message(sender_id, "[Internal Error]")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Outbound: Text
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_message(self, recipient_id: str, text: str):
|
||||
"""Send text message via WhatsApp Cloud API."""
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
url = (
|
||||
f"{GRAPH_API_BASE}/{self.config.whatsapp_phone_number_id}/messages"
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.config.whatsapp_access_token}",
|
||||
}
|
||||
|
||||
# WhatsApp text limit is ~4096 chars
|
||||
if len(text) > 4000:
|
||||
text = text[:4000] + "\n...(truncated)"
|
||||
|
||||
body = {
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": recipient_id,
|
||||
"type": "text",
|
||||
"text": {"preview_url": False, "body": text},
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.session.post(url, headers=headers, json=body) as resp:
|
||||
if resp.status == 429:
|
||||
logger.warning("WhatsApp API Rate Limit Hit")
|
||||
elif resp.status not in (200, 201):
|
||||
err = await resp.text()
|
||||
logger.error(
|
||||
f"WhatsApp send_message failed: {resp.status} {err}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp send_message error: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Outbound: Image
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def send_image(
|
||||
self,
|
||||
channel_id: str,
|
||||
image_data: bytes,
|
||||
filename: str = "image.png",
|
||||
caption: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Send image via WhatsApp using public media URL.
|
||||
Reuses F33 media store to host the image, then sends a link message.
|
||||
"""
|
||||
if not self.config.public_base_url:
|
||||
logger.warning("WhatsApp send_image: No public_base_url configured.")
|
||||
text = (
|
||||
"[OpenClaw] Image ready but cannot be delivered.\n"
|
||||
"⚠️ Admin: Set OPENCLAW_CONNECTOR_PUBLIC_BASE_URL to enable image delivery."
|
||||
)
|
||||
await self.send_message(channel_id, text)
|
||||
return
|
||||
|
||||
try:
|
||||
ext = "." + filename.split(".")[-1] if "." in filename else ".png"
|
||||
token = self.media_store.store_image(image_data, ext, channel_id)
|
||||
|
||||
# Construct public URL
|
||||
base = self.config.public_base_url.rstrip("/")
|
||||
path = self.config.media_path.strip("/")
|
||||
image_url = f"{base}/{path}/{token}"
|
||||
|
||||
await self._send_whatsapp_image(channel_id, image_url, caption)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send WhatsApp image: {e}")
|
||||
await self.send_message(
|
||||
channel_id, "[OpenClaw] Error delivering image."
|
||||
)
|
||||
|
||||
async def _send_whatsapp_image(
|
||||
self,
|
||||
recipient_id: str,
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
):
|
||||
"""Send image message via Graph API /messages endpoint."""
|
||||
if not self.session:
|
||||
return
|
||||
|
||||
url = (
|
||||
f"{GRAPH_API_BASE}/{self.config.whatsapp_phone_number_id}/messages"
|
||||
)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.config.whatsapp_access_token}",
|
||||
}
|
||||
|
||||
image_payload = {"link": image_url}
|
||||
if caption:
|
||||
image_payload["caption"] = caption[:1024]
|
||||
|
||||
body = {
|
||||
"messaging_product": "whatsapp",
|
||||
"recipient_type": "individual",
|
||||
"to": recipient_id,
|
||||
"type": "image",
|
||||
"image": image_payload,
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.session.post(url, headers=headers, json=body) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
err = await resp.text()
|
||||
logger.error(
|
||||
f"WhatsApp image send failed: {resp.status} {err}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp image send error: {e}")
|
||||
@@ -200,6 +200,11 @@ class CommandRouter:
|
||||
return True
|
||||
return False
|
||||
|
||||
if platform == "whatsapp":
|
||||
if sender_id in self.config.whatsapp_allowed_users:
|
||||
return True
|
||||
return False
|
||||
|
||||
# Unknown platform: trust only admins
|
||||
return False
|
||||
|
||||
|
||||
+33
-5
@@ -1,14 +1,15 @@
|
||||
# OpenClaw Connector
|
||||
|
||||
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram** and **Discord**, without exposing your ComfyUI to the public internet.
|
||||
The **OpenClaw Connector** (`connector`) is a standalone process that allows you to control your local ComfyUI instance remotely via chat platforms like **Telegram**, **Discord**, **LINE**, and **WhatsApp**.
|
||||
|
||||
## How It Works
|
||||
|
||||
The connector runs alongside ComfyUI on your machine.
|
||||
|
||||
1. It connects outbound to Telegram/Discord (polling/gateway).
|
||||
2. It talks to ComfyUI via `localhost`.
|
||||
3. It relays commands and status updates securely.
|
||||
2. LINE/WhatsApp use inbound webhooks (HTTPS required).
|
||||
3. It talks to ComfyUI via `localhost`.
|
||||
4. It relays commands and status updates securely.
|
||||
|
||||
**Security**:
|
||||
|
||||
@@ -21,6 +22,8 @@ The connector runs alongside ComfyUI on your machine.
|
||||
|
||||
- **Telegram**: Long-polling (instant response).
|
||||
- **Discord**: Gateway WebSocket (instant response).
|
||||
- **LINE**: Webhook (requires inbound HTTPS).
|
||||
- **WhatsApp**: Webhook (requires inbound HTTPS).
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -69,17 +72,29 @@ Set the following environment variables (or put them in a `.env` file if you use
|
||||
- `OPENCLAW_CONNECTOR_LINE_PORT`: Port (default `8099`).
|
||||
- `OPENCLAW_CONNECTOR_LINE_PATH`: Webhook path (default `/line/webhook`).
|
||||
|
||||
**WhatsApp:**
|
||||
|
||||
*(Requires Inbound Connectivity - see below)*
|
||||
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN`: Cloud API access token.
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_VERIFY_TOKEN`: Webhook verify token (used during setup).
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET`: App secret for signature verification (recommended).
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_PHONE_NUMBER_ID`: Phone number ID used for outbound messages.
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS`: Comma-separated sender `wa_id` values (phone numbers).
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_BIND`: Host to bind (default `127.0.0.1`).
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_PORT`: Port (default `8098`).
|
||||
- `OPENCLAW_CONNECTOR_WHATSAPP_PATH`: Webhook path (default `/whatsapp/webhook`).
|
||||
|
||||
**Image Delivery (F33):**
|
||||
|
||||
- `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL`: Public HTTPS URL of your connector (e.g. `https://your-tunnel.example.com`). Required for sending images.
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_PATH`: URL path for serving temporary media (default `/media`).
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`).
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`).
|
||||
- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`).
|
||||
|
||||
> **Note:** Media URLs are signed with a secret derived from `OPENCLAW_CONNECTOR_ADMIN_TOKEN` or a random key.
|
||||
> To ensure URLs remain valid after connector restarts, **you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN`**.
|
||||
> LINE also **requires** `public_base_url` to be HTTPS.
|
||||
> LINE and WhatsApp also **require** `public_base_url` to be HTTPS.
|
||||
|
||||
### 3. Usage
|
||||
|
||||
@@ -107,6 +122,19 @@ Since the connector runs on `localhost` (default port 8099), you must expose it
|
||||
|
||||
- Configure your proxy to forward HTTPS traffic to `127.0.0.1:8099`.
|
||||
|
||||
#### WhatsApp Webhook Setup
|
||||
|
||||
WhatsApp Cloud API delivers webhooks to your connector. You must expose it via HTTPS.
|
||||
|
||||
1. Create a Meta app and add the WhatsApp product.
|
||||
2. Add a phone number and note its **Phone Number ID**.
|
||||
3. Configure the webhook URL: `https://<your-public-host>/whatsapp/webhook`.
|
||||
4. Set the webhook **Verify Token** to match `OPENCLAW_CONNECTOR_WHATSAPP_VERIFY_TOKEN`.
|
||||
5. Subscribe to `messages` events.
|
||||
6. Ensure `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` is an HTTPS URL so media can be delivered.
|
||||
|
||||
If you run locally, use a secure tunnel (Cloudflare Tunnel or ngrok) and point it to `http://127.0.0.1:8098`.
|
||||
|
||||
## Commands
|
||||
|
||||
**General:**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Compatibility Matrix (R51)
|
||||
|
||||
This document outlines the validated environments for ComfyUI-OpenClaw M1 Release.
|
||||
This document outlines the validated environments for ComfyUI-OpenClaw `v0.2.1` (M1 Release).
|
||||
|
||||
## Core Dependencies
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-openclaw"
|
||||
description = "Your own personal AIGC Factory. Any picture. Any reel. The Comfy way.©️"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -13,6 +13,15 @@ Every implementation plan must include the **full test validation procedure** in
|
||||
- `pre-commit` installed: `python -m pip install pre-commit`
|
||||
- Frontend deps installed: `npm install`
|
||||
|
||||
## Environment Parity Guardrails (CI Safety)
|
||||
|
||||
To avoid local vs CI mismatches:
|
||||
|
||||
- **Do not hard-import optional deps in tests** (e.g. `aiohttp`) unless the test explicitly installs them.
|
||||
- If a test needs a module that may be missing in CI, **use a stub** (e.g. `sys.modules["services.foo"]=stub`) or patch the **module-level import location** used by the code under test.
|
||||
- If a test truly requires an optional dependency, mark it with a **clear skip** when the dep is unavailable.
|
||||
- Record the environment in the implementation record (OS, Python, Node, and any extras installed) so mismatches are visible.
|
||||
|
||||
## Required Pre-Push Workflow (Must Run)
|
||||
|
||||
### Optional automation (recommended)
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Tests for WhatsApp Webhook Server (F36).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from connector.config import ConnectorConfig
|
||||
from connector.contract import CommandRequest
|
||||
from connector.platforms.whatsapp_webhook import WhatsAppWebhookServer
|
||||
|
||||
|
||||
class TestWhatsAppWebhook(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.config = ConnectorConfig()
|
||||
self.config.whatsapp_access_token = "dummy_access"
|
||||
self.config.whatsapp_verify_token = "dummy_verify"
|
||||
self.config.whatsapp_app_secret = "secret123"
|
||||
self.config.whatsapp_allowed_users = ["123456789"]
|
||||
|
||||
self.router = MagicMock()
|
||||
self.router.handle = AsyncMock()
|
||||
|
||||
self.server = WhatsAppWebhookServer(self.config, self.router)
|
||||
|
||||
def _sign(self, body):
|
||||
secret = b"secret123"
|
||||
sig = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
return f"sha256={sig}"
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_verify_webhook_success(self, mock_import):
|
||||
"""Test GET verification handshake."""
|
||||
mock_web = MagicMock()
|
||||
mock_import.return_value = (MagicMock(), mock_web)
|
||||
|
||||
def side_effect(text=None, **kwargs):
|
||||
m = MagicMock()
|
||||
m.text = text
|
||||
m.status = kwargs.get("status", 200)
|
||||
return m
|
||||
mock_web.Response.side_effect = side_effect
|
||||
|
||||
request = MagicMock()
|
||||
request.query = {
|
||||
"hub.mode": "subscribe",
|
||||
"hub.verify_token": "dummy_verify",
|
||||
"hub.challenge": "1234"
|
||||
}
|
||||
|
||||
resp = await self.server.handle_verify(request)
|
||||
self.assertEqual(resp.text, "1234")
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_verify_webhook_fail(self, mock_import):
|
||||
"""Test GET verification failure."""
|
||||
mock_web = MagicMock()
|
||||
mock_import.return_value = (MagicMock(), mock_web)
|
||||
|
||||
def side_effect(text=None, **kwargs):
|
||||
m = MagicMock()
|
||||
m.text = text
|
||||
m.status = kwargs.get("status", 200)
|
||||
return m
|
||||
mock_web.Response.side_effect = side_effect
|
||||
|
||||
request = MagicMock()
|
||||
request.query = {
|
||||
"hub.mode": "subscribe",
|
||||
"hub.verify_token": "wrong_token",
|
||||
"hub.challenge": "1234"
|
||||
}
|
||||
|
||||
resp = await self.server.handle_verify(request)
|
||||
# Should return 403 Forbidden
|
||||
mock_web.Response.assert_called_with(status=403, text="Verification failed")
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_handle_message(self, mock_import):
|
||||
"""Test POST message handling (Happy Path)."""
|
||||
mock_web = MagicMock()
|
||||
mock_import.return_value = (MagicMock(), mock_web)
|
||||
|
||||
def side_effect(text=None, **kwargs):
|
||||
m = MagicMock()
|
||||
m.text = text
|
||||
m.status = kwargs.get("status", 200)
|
||||
return m
|
||||
mock_web.Response.side_effect = side_effect
|
||||
|
||||
now_ts = int(time.time())
|
||||
payload = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "123456789",
|
||||
"id": "msg_id_fresh",
|
||||
"timestamp": str(now_ts),
|
||||
"type": "text",
|
||||
"text": {"body": "/run check"}
|
||||
}],
|
||||
"contacts": [{"wa_id": "123456789", "profile": {"name": "Alice"}}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
|
||||
request = MagicMock()
|
||||
request.read = AsyncMock(return_value=body)
|
||||
request.headers = {}
|
||||
request.headers["X-Hub-Signature-256"] = self._sign(body)
|
||||
|
||||
await self.server.handle_webhook(request)
|
||||
|
||||
# Verify router called
|
||||
self.router.handle.assert_called_once()
|
||||
args = self.router.handle.call_args[0][0]
|
||||
self.assertEqual(args.sender_id, "123456789")
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_handle_message_replay_old(self, mock_import):
|
||||
"""Test Replay Protection: Stale Timestamp."""
|
||||
mock_web = MagicMock()
|
||||
mock_import.return_value = (MagicMock(), mock_web)
|
||||
|
||||
old_ts = int(time.time()) - 400 # > 300s window
|
||||
payload = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "123456789",
|
||||
"id": "msg_id_old",
|
||||
"timestamp": str(old_ts),
|
||||
"type": "text",
|
||||
"text": {"body": "/run check"}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = MagicMock()
|
||||
request.read = AsyncMock(return_value=body)
|
||||
request.headers = {}
|
||||
request.headers["X-Hub-Signature-256"] = self._sign(body)
|
||||
|
||||
with self.assertLogs("connector.platforms.whatsapp_webhook", level="WARNING") as cm:
|
||||
await self.server.handle_webhook(request)
|
||||
self.assertTrue(any("Replay rejected" in o for o in cm.output))
|
||||
|
||||
self.router.handle.assert_not_called()
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_handle_message_replay_duplicate(self, mock_import):
|
||||
"""Test Replay Protection: Duplicate Message ID."""
|
||||
mock_web = MagicMock()
|
||||
mock_import.return_value = (MagicMock(), mock_web)
|
||||
|
||||
now_ts = int(time.time())
|
||||
payload = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "123456789",
|
||||
"id": "msg_id_dup",
|
||||
"timestamp": str(now_ts),
|
||||
"type": "text",
|
||||
"text": {"body": "/run check"}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = MagicMock()
|
||||
request.read = AsyncMock(return_value=body)
|
||||
request.headers = {}
|
||||
request.headers["X-Hub-Signature-256"] = self._sign(body)
|
||||
|
||||
# First call: Success
|
||||
await self.server.handle_webhook(request)
|
||||
self.assertEqual(self.router.handle.call_count, 1)
|
||||
|
||||
# Second call: Fail (Duplicate)
|
||||
with self.assertLogs("connector.platforms.whatsapp_webhook", level="WARNING") as cm:
|
||||
await self.server.handle_webhook(request)
|
||||
self.assertTrue(any("Replay rejected" in o for o in cm.output))
|
||||
|
||||
self.assertEqual(self.router.handle.call_count, 1) # Still 1
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_untrusted_user_logging(self, mock_import):
|
||||
"""Test logging for user not in allowlist."""
|
||||
mock_web = MagicMock()
|
||||
mock_import.return_value = (MagicMock(), mock_web)
|
||||
|
||||
# Configure allowlist
|
||||
self.config.whatsapp_allowed_users = ["999999"] # Sender is not 999999
|
||||
|
||||
now_ts = int(time.time())
|
||||
payload = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "123456789", # Untrusted
|
||||
"id": "msg_id_untrusted",
|
||||
"timestamp": str(now_ts),
|
||||
"type": "text",
|
||||
"text": {"body": "/run check"}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = MagicMock()
|
||||
request.read = AsyncMock(return_value=body)
|
||||
request.headers = {}
|
||||
request.headers["X-Hub-Signature-256"] = self._sign(body)
|
||||
|
||||
with self.assertLogs("connector.platforms.whatsapp_webhook", level="WARNING") as cm:
|
||||
await self.server.handle_webhook(request)
|
||||
self.assertTrue(any("Untrusted WhatsApp message" in o for o in cm.output))
|
||||
|
||||
# Router is STILL called (router enforces permissions)
|
||||
self.router.handle.assert_called_once()
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_handle_message_bad_signature(self, mock_import):
|
||||
"""Test POST message with bad signature."""
|
||||
mock_web = MagicMock()
|
||||
mock_import.return_value = (MagicMock(), mock_web)
|
||||
|
||||
request = MagicMock()
|
||||
request.read = AsyncMock(return_value=b"{}")
|
||||
request.headers = {"X-Hub-Signature-256": "sha256=bad_sig"}
|
||||
|
||||
await self.server.handle_webhook(request)
|
||||
|
||||
mock_web.Response.assert_called_with(status=401, text="Invalid Signature")
|
||||
self.router.handle.assert_not_called()
|
||||
|
||||
@patch("connector.platforms.whatsapp_webhook._import_aiohttp_web")
|
||||
async def test_send_message(self, mock_import):
|
||||
"""Test outbound message sending."""
|
||||
mock_aiohttp = MagicMock()
|
||||
mock_import.return_value = (mock_aiohttp, MagicMock())
|
||||
|
||||
# Mock session post
|
||||
mock_session = MagicMock()
|
||||
post_ctx = AsyncMock()
|
||||
post_ctx.__aenter__.return_value.status = 200
|
||||
mock_session.post.return_value = post_ctx
|
||||
self.server.session = mock_session
|
||||
|
||||
self.config.whatsapp_phone_number_id = "pid"
|
||||
|
||||
await self.server.send_message("123456789", "Hello")
|
||||
|
||||
mock_session.post.assert_called_once()
|
||||
url = mock_session.post.call_args[0][0]
|
||||
self.assertIn("/pid/messages", url)
|
||||
|
||||
kwargs = mock_session.post.call_args[1]
|
||||
self.assertEqual(kwargs["json"]["to"], "123456789")
|
||||
self.assertEqual(kwargs["json"]["text"]["body"], "Hello")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user