feat(whatsapp): add WhatsApp Business Cloud API adapter
Add an official, production-grade WhatsApp integration via Meta's Business Cloud API as a complement to the existing Baileys bridge. No bridge subprocess, no QR codes, no account-ban risk — at the cost of a Meta Business account and a public HTTPS webhook URL. Setup is fully wizard-driven: 'hermes whatsapp-cloud' walks through every credential with paste-time validation (catches the #1 trap of pasting a phone number into the Phone Number ID field), generates a verify token, and ends with copy-paste instructions for the cloudflared / Meta-dashboard / Business Manager pieces that can't be automated. The wizard also points users at Meta's Business Manager for setting the bot's display name and profile picture. Feature set: - Inbound: text, images (with native-vision routing), voice notes (STT), documents (small text inlined, larger cached), reply context. - Outbound: text with WhatsApp-flavored markdown conversion, images, videos, documents, opus voice notes via ffmpeg with MP3 fallback. - Native interactive buttons for clarify, dangerous-command approval, and slash-command confirmation flows — matches the Telegram / Discord UX, graceful degrades to plain text. - Read receipts (blue double-checkmarks) and typing indicator, using Meta's combined endpoint so they fire in a single API call. - Webhook security: X-Hub-Signature-256 HMAC verification (raw body, constant-time), wamid deduplication, group-shaped-message refusal (groups deferred to v2 — Baileys still covers them). - Full integration with the gateway's session, cron, display-tier, prompt-hint, and auth-allowlist systems. Cloud and Baileys can run side-by-side against different phone numbers. Also wires STT (speech-to-text) through Nous's managed audio gateway for Nous subscribers — previously the default stt.provider=local required a separate faster-whisper install. New subscribers now get voice-note transcription out of the box. Docs: 418-line user guide at website/docs/user-guide/messaging/ whatsapp-cloud.md, sidebar entry, environment-variables reference, ADDING_A_PLATFORM.md updated with the optional interactive-UX contract for future adapter authors. Tests: 100 dedicated tests for the adapter, 32 for the setup wizard, 20 for the Nous subscription STT wiring, plus regression coverage across display_config, prompt_builder, and the cron scheduler. Known limitations (deferred until clear demand signal): - Group chats — use the Baileys bridge if you need them. - Message templates for 24-hour-window outside-conversation sends — reactive chat is unaffected; cron / delegate_task with gaps > 24h will fail with a clear error. The agent's system prompt warns the model about this so it knows to mention it when scheduling delayed messages.
This commit is contained in:
@@ -109,6 +109,7 @@ class Platform(Enum):
|
||||
TELEGRAM = "telegram"
|
||||
DISCORD = "discord"
|
||||
WHATSAPP = "whatsapp"
|
||||
WHATSAPP_CLOUD = "whatsapp_cloud"
|
||||
SLACK = "slack"
|
||||
SIGNAL = "signal"
|
||||
MATTERMOST = "mattermost"
|
||||
@@ -419,6 +420,9 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
|
||||
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
|
||||
),
|
||||
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
|
||||
Platform.WHATSAPP_CLOUD: lambda cfg: bool(
|
||||
cfg.extra.get("phone_number_id") and cfg.extra.get("access_token")
|
||||
),
|
||||
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
|
||||
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
|
||||
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
|
||||
@@ -1367,6 +1371,61 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
thread_id=os.getenv("WHATSAPP_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# WhatsApp Cloud API (official Business Platform via Meta).
|
||||
# Distinct from the Baileys bridge: pure HTTP graph.facebook.com calls
|
||||
# outbound, public webhook inbound. Both adapters can run in parallel
|
||||
# against different phone numbers.
|
||||
whatsapp_cloud_phone_id = os.getenv("WHATSAPP_CLOUD_PHONE_NUMBER_ID")
|
||||
whatsapp_cloud_token = os.getenv("WHATSAPP_CLOUD_ACCESS_TOKEN")
|
||||
if whatsapp_cloud_phone_id and whatsapp_cloud_token:
|
||||
if Platform.WHATSAPP_CLOUD not in config.platforms:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD] = PlatformConfig()
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].enabled = True
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra.update({
|
||||
"phone_number_id": whatsapp_cloud_phone_id,
|
||||
"access_token": whatsapp_cloud_token,
|
||||
})
|
||||
# Optional: app_id / app_secret (signature verification)
|
||||
wa_cloud_app_id = os.getenv("WHATSAPP_CLOUD_APP_ID")
|
||||
if wa_cloud_app_id:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_id"] = wa_cloud_app_id
|
||||
wa_cloud_app_secret = os.getenv("WHATSAPP_CLOUD_APP_SECRET")
|
||||
if wa_cloud_app_secret:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = wa_cloud_app_secret
|
||||
# Optional: WABA id (analytics, future use)
|
||||
wa_cloud_waba_id = os.getenv("WHATSAPP_CLOUD_WABA_ID")
|
||||
if wa_cloud_waba_id:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = wa_cloud_waba_id
|
||||
# Webhook verify token — Meta hub.verify_token shared secret
|
||||
wa_cloud_verify_token = os.getenv("WHATSAPP_CLOUD_VERIFY_TOKEN")
|
||||
if wa_cloud_verify_token:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = wa_cloud_verify_token
|
||||
# Webhook server bind config (defaults baked into the adapter)
|
||||
wa_cloud_host = os.getenv("WHATSAPP_CLOUD_WEBHOOK_HOST")
|
||||
if wa_cloud_host:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = wa_cloud_host
|
||||
wa_cloud_port = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PORT")
|
||||
if wa_cloud_port:
|
||||
try:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(wa_cloud_port)
|
||||
except ValueError:
|
||||
pass
|
||||
wa_cloud_path = os.getenv("WHATSAPP_CLOUD_WEBHOOK_PATH")
|
||||
if wa_cloud_path:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = wa_cloud_path
|
||||
# Graph API version override (rarely needed)
|
||||
wa_cloud_api_version = os.getenv("WHATSAPP_CLOUD_API_VERSION")
|
||||
if wa_cloud_api_version:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = wa_cloud_api_version
|
||||
whatsapp_cloud_home = os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL")
|
||||
if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms:
|
||||
config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel(
|
||||
platform=Platform.WHATSAPP_CLOUD,
|
||||
chat_id=whatsapp_cloud_home,
|
||||
name=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_NAME", "Home"),
|
||||
thread_id=os.getenv("WHATSAPP_CLOUD_HOME_CHANNEL_THREAD_ID") or None,
|
||||
)
|
||||
|
||||
# Slack
|
||||
slack_token = os.getenv("SLACK_BOT_TOKEN")
|
||||
if slack_token:
|
||||
|
||||
Reference in New Issue
Block a user