feat: the claw now supports WhatsApp

This commit is contained in:
rookiestar28
2026-02-11 01:13:16 +08:00
parent b971262166
commit ab5fbe5840
11 changed files with 844 additions and 11 deletions
+4 -2
View File
@@ -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
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 .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()
+35
View File
@@ -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()]
+449
View File
@@ -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}")
+5
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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"
+9
View File
@@ -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)
+286
View File
@@ -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()