diff --git a/README.md b/README.md index 81d2f8f..cbdadd7 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/assets/sidebar.png b/assets/sidebar.png index 54124e5..0c45201 100644 Binary files a/assets/sidebar.png and b/assets/sidebar.png differ diff --git a/connector/__main__.py b/connector/__main__.py index d64e548..f123bf8 100644 --- a/connector/__main__.py +++ b/connector/__main__.py @@ -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() diff --git a/connector/config.py b/connector/config.py index e2f9743..279d275 100644 --- a/connector/config.py +++ b/connector/config.py @@ -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()] diff --git a/connector/platforms/whatsapp_webhook.py b/connector/platforms/whatsapp_webhook.py new file mode 100644 index 0000000..c741e76 --- /dev/null +++ b/connector/platforms/whatsapp_webhook.py @@ -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:///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=&hub.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= + """ + 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}") diff --git a/connector/router.py b/connector/router.py index 1f2ddb2..142ce1e 100644 --- a/connector/router.py +++ b/connector/router.py @@ -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 diff --git a/docs/connector.md b/docs/connector.md index e93914b..9879583 100644 --- a/docs/connector.md +++ b/docs/connector.md @@ -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:///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:** diff --git a/docs/release/compatibility_matrix.md b/docs/release/compatibility_matrix.md index eb3ea3a..a5095b1 100644 --- a/docs/release/compatibility_matrix.md +++ b/docs/release/compatibility_matrix.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index f022d0a..ce712a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/TEST_SOP.md b/tests/TEST_SOP.md index 6551d02..837c4c5 100644 --- a/tests/TEST_SOP.md +++ b/tests/TEST_SOP.md @@ -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) diff --git a/tests/connector/test_whatsapp_webhook.py b/tests/connector/test_whatsapp_webhook.py new file mode 100644 index 0000000..af7219f --- /dev/null +++ b/tests/connector/test_whatsapp_webhook.py @@ -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()