Files
hermes-agent/hermes_cli/setup_whatsapp_cloud.py

496 lines
20 KiB
Python

"""Interactive setup wizard for the WhatsApp Cloud API adapter.
Walks the user through the 6 credentials Meta requires + recipient allowlist, auto-generates the
verify token, and prints exact follow-up instructions for the parts that can't happen inside the
wizard process (starting cloudflared, starting the gateway, configuring Meta's webhook dashboard,
adding their phone to the recipient list).
The wizard intentionally does NOT smoke-test the webhook itself — the Hermes gateway and the
cloudflared tunnel both run in separate processes the user starts AFTER this wizard exits, so any
in-wizard probe would fail by design.
"""
from __future__ import annotations
from hermes_cli.cli_output import line_input
import re
import secrets
import sys
from typing import Optional
# ---------------------------------------------------------------------------
# Field-shape validators
# ---------------------------------------------------------------------------
#
# Each validator returns (ok, reason_if_not_ok). The wizard uses them to
# reject obviously-malformed input before saving — saves users a round
# trip with Meta's 401 / 400 errors.
def _validate_phone_number_id(value: str) -> tuple[bool, Optional[str]]:
"""Phone Number ID is a 15-17 digit numeric ID assigned by Meta.
It's NOT a phone number. The #1 setup mistake is pasting the actual phone number (e.g.
``15556422442``) into this field — that's only 10-11 digits and gets rejected by Graph as
"Object with ID does not exist."
"""
if not value:
return False, "Phone Number ID is required"
s = value.strip()
if not s.isdigit():
return False, "Phone Number ID must be numeric (no '+', spaces, or dashes)"
# Real phone numbers are 10-11 digits (US/CA country code + area code
# + 7 digits). Meta's internal IDs are 15-17 digits. If we see a
# phone-number-sized value, the user almost certainly pasted the
# phone number by mistake.
if 10 <= len(s) <= 12:
return False, (
"That looks like a phone number — but this field needs the "
"Phone Number ID (Meta's internal ID, 15-17 digits, e.g. "
"'7794189252778687'). Look just BELOW the 'From' dropdown in "
"API Setup → it's labelled 'Phone number ID'."
)
if len(s) < 13:
return False, "Phone Number ID looks too short (expected 13-18 digits)"
if len(s) > 20:
return False, "Phone Number ID looks too long (expected 13-18 digits)"
return True, None
def _numeric_id_validator(label: str, lo: int, hi: int, expected: str):
"""Validator for a numeric Meta ID whose digit count must fall in [lo, hi]."""
def validate(value: str) -> tuple[bool, Optional[str]]:
if not value:
return False, f"{label} is required"
s = value.strip()
if not s.isdigit():
return False, f"{label} must be numeric"
if len(s) < lo or len(s) > hi:
return False, f"{label} looks wrong (expected {expected})"
return True, None
return validate
# WABA ID: similar length range as Phone Number ID. App ID: typically 15-16 digits.
_validate_waba_id = _numeric_id_validator("WABA ID", 10, 25, "10-25 digits")
_validate_app_id = _numeric_id_validator("App ID", 13, 20, "15-16 digits")
# Common paste mistakes for the access-token field: (prefixes, what it actually is).
_FOREIGN_TOKEN_PREFIXES = (
(("sk-",), "That's an OpenAI key (starts with 'sk-'), not a Meta "
"WhatsApp access token. Meta tokens start with 'EAA'."),
(("xoxb-", "xoxp-"), "That's a Slack token, not a Meta WhatsApp access token. "
"Meta tokens start with 'EAA'."),
(("ghp_", "gho_"), "That's a GitHub token, not a Meta WhatsApp access "
"token. Meta tokens start with 'EAA'."),
)
def _validate_app_secret(value: str) -> tuple[bool, Optional[str]]:
"""App Secret is a 32-character lowercase hex string."""
if not value:
return False, "App Secret is required"
s = value.strip()
if not re.fullmatch(r"[0-9a-f]+", s.lower()):
return False, (
"App Secret should be a hex string (only digits 0-9 and "
"letters a-f). Make sure you copied the 'App secret' from "
"Settings → Basic, not some other token."
)
if len(s) != 32:
return False, f"App Secret should be exactly 32 hex characters (got {len(s)})"
return True, None
def _validate_access_token(value: str) -> tuple[bool, Optional[str]]:
"""Meta access tokens start with ``EAA`` and are 100-300+ characters.
Both temp tokens (24h) and System User permanent tokens share this prefix. We don't try to
distinguish them.
"""
if not value:
return False, "Access token is required"
s = value.strip()
if not s.startswith("EAA"):
# Diagnose common paste mistakes
for prefixes, reason in _FOREIGN_TOKEN_PREFIXES:
if s.startswith(prefixes):
return False, reason
return False, (
"Meta WhatsApp access tokens start with 'EAA'. Check that "
"you're copying from the right place (API Setup → 'Generate "
"access token', or Business Settings → System Users → "
"'Generate token' for a permanent one)."
)
if len(s) < 100:
return False, f"Access token looks too short ({len(s)} chars, expected 100+)"
return True, None
# ---------------------------------------------------------------------------
# Prompt helpers
# ---------------------------------------------------------------------------
def _prompt(message: str, default: Optional[str] = None, secret: bool = False) -> str:
"""Read one line of input. Returns "" on EOF / Ctrl+C / empty input.
``default`` is shown but NOT auto-applied on empty input: callers handle "kept existing"
explicitly so a real value is distinguishable from a display preview (masked secrets).
``secret=True`` reads via ``getpass`` so credentials are not echoed or left in scrollback.
"""
try:
suffix = f" [{default}]" if default else ""
if secret and sys.stdin.isatty():
import getpass
raw = getpass.getpass(f"{message}{suffix} (input hidden): ").strip()
else:
raw = line_input(f"{message}{suffix}: ").strip()
except (EOFError, KeyboardInterrupt):
print()
return ""
return raw
def _prompt_validated(
message: str,
validator,
*,
current: Optional[str] = None,
help_text: Optional[str] = None,
secret: bool = False,
) -> Optional[str]:
"""Repeat the prompt until the user enters a valid value or aborts.
Returns the validated value, or None if the user gave up (empty response after an error, or
Ctrl+C). ``current`` is shown as a default for re-runs of the wizard with existing config.
"""
if help_text:
for line in help_text.strip().splitlines():
print(f" {line}")
attempts = 0
while True:
attempts += 1
value = _prompt(f" → {message}", default=current, secret=secret)
if not value:
return None
ok, reason = validator(value)
if ok:
return value.strip()
print(f" ✗ {reason}")
if attempts >= 3:
try:
cont = input(" Try again, or press Enter to skip: ").strip()
except (EOFError, KeyboardInterrupt):
return None
if not cont:
return None
attempts = 0
# ---------------------------------------------------------------------------
# Wizard
# ---------------------------------------------------------------------------
def _header(title: str) -> None:
print("─" * 50)
print(title)
print("─" * 50)
def _save_optional(key: str, value: Optional[str], current: Optional[str]) -> None:
from hermes_cli.config import save_env_value
if value:
save_env_value(key, value)
print(f" ✓ Saved: {value}")
elif current:
print(f" ✓ Keeping existing: {current}")
def run_whatsapp_cloud_setup() -> int:
"""Interactive wizard for the WhatsApp Cloud API adapter.
Returns 0 on full success, 1 on user abort, 2 on partial completion (some fields written but the
user bailed before finishing).
"""
from hermes_cli.config import get_env_value, save_env_value
print()
print("⚕ WhatsApp Business Cloud API Setup")
print("=" * 50)
print()
print("This wizard configures Hermes to talk to WhatsApp via Meta's")
print("official Cloud API. It's the production-grade path:")
print()
print(" • No QR codes, no Node.js bridge subprocess")
print(" • Stable connection — no account-ban risk")
print(" • Business account required (not personal WhatsApp)")
print(" • Public webhook URL required (Cloudflare Tunnel, ngrok,")
print(" or your own reverse proxy with TLS)")
print()
print("If you don't have a Meta app set up yet, follow these steps")
print("FIRST, then come back and re-run this wizard:")
print()
print(" 1. https://developers.facebook.com/apps → Create App")
print(" → 'Connect with customers through WhatsApp'")
print(" 2. App Dashboard → WhatsApp → API Setup")
print(" 3. Click 'Generate access token' (temp 24h token is fine to")
print(" start; switch to a System User permanent token later)")
print()
try:
input("Press Enter to continue, or Ctrl+C to abort... ")
except (EOFError, KeyboardInterrupt):
print("\nSetup cancelled.")
return 1
print()
_header("STEP 1 — Phone Number ID")
current_phone_id = get_env_value("WHATSAPP_CLOUD_PHONE_NUMBER_ID") or None
phone_id = _prompt_validated(
"Phone Number ID",
_validate_phone_number_id,
current=current_phone_id,
help_text=(
"Found in: App Dashboard → WhatsApp → API Setup, in the\n"
"'Send and receive messages' section.\n"
"Look BELOW the 'From' dropdown — there's a 'Phone number ID'\n"
"line with the value (15-17 digits, e.g. '7794189252778687').\n"
"It is NOT the phone number itself (+1 555-...). That's the\n"
"single most common setup mistake."
),
)
if not phone_id:
if current_phone_id:
phone_id = current_phone_id
print(f" ✓ Keeping existing: {phone_id}")
else:
print("\n✗ Phone Number ID is required. Aborting.")
return 1
else:
save_env_value("WHATSAPP_CLOUD_PHONE_NUMBER_ID", phone_id)
print(f" ✓ Saved: {phone_id}")
print()
_header("STEP 2 — Access Token")
current_token = get_env_value("WHATSAPP_CLOUD_ACCESS_TOKEN") or None
current_display = (current_token[:15] + "...") if current_token else None
token = _prompt_validated(
"Access Token",
_validate_access_token,
current=current_display,
secret=True,
help_text=(
"Two options for getting one:\n\n"
" (a) TEMP — App Dashboard → WhatsApp → API Setup →\n"
" 'Generate access token' button. Lasts 24 hours.\n"
" Fine for testing today; you'll have to regenerate\n"
" tomorrow.\n\n"
" (b) PERMANENT (production) — System User token. One-time\n"
" setup, never expires:\n"
" • business.facebook.com → Settings → System users →\n"
" Add → Admin role\n"
" • Assign Assets → your app (Manage app), your\n"
" WhatsApp account (Manage WABAs)\n"
" • Generate token → expiration: Never → permissions:\n"
" business_management, whatsapp_business_messaging,\n"
" whatsapp_business_management\n\n"
"Tokens start with 'EAA'."
),
)
# If they had a current token and just hit Enter, keep it.
if not token:
if current_token:
token = current_token
print(" ✓ Keeping existing token")
else:
print("\n✗ Access Token is required. Aborting.")
return 1
else:
save_env_value("WHATSAPP_CLOUD_ACCESS_TOKEN", token)
print(" ✓ Saved (token hidden)")
print()
_header("STEP 3 — App Secret (required for webhook signature verification)")
current_secret = get_env_value("WHATSAPP_CLOUD_APP_SECRET") or None
current_secret_display = (current_secret[:8] + "...") if current_secret else None
app_secret = _prompt_validated(
"App Secret",
_validate_app_secret,
current=current_secret_display,
secret=True,
help_text=(
"Found in: App Dashboard → Settings → Basic →\n"
"'App secret' field (click 'Show', enter your Facebook password).\n\n"
"If 'Show' doesn't appear, you may need Admin role on the app.\n"
"It's a 32-character lowercase hex string.\n\n"
"Without the App Secret, inbound webhook POSTs are refused\n"
"with HTTP 503 (we can't verify they actually came from Meta)."
),
)
if not app_secret:
if current_secret:
app_secret = current_secret
print(" ✓ Keeping existing App Secret")
else:
print("\n⚠ Skipping App Secret — inbound webhooks will be refused")
print(" until you set WHATSAPP_CLOUD_APP_SECRET manually.")
else:
save_env_value("WHATSAPP_CLOUD_APP_SECRET", app_secret)
print(" ✓ Saved (secret hidden)")
print()
_header("STEP 4 — App ID & WABA ID (optional, for analytics)")
current_app_id = get_env_value("WHATSAPP_CLOUD_APP_ID") or None
app_id = _prompt_validated(
"App ID (optional, press Enter to skip)",
lambda v: (True, None) if not v else _validate_app_id(v),
current=current_app_id,
help_text=(
"Found in: App Dashboard → Settings → Basic → 'App ID' at the\n"
"top of the page. Numeric, ~15-16 digits.\n"
"Not required for messaging — useful only for analytics later."
),
)
_save_optional("WHATSAPP_CLOUD_APP_ID", app_id, current_app_id)
current_waba_id = get_env_value("WHATSAPP_CLOUD_WABA_ID") or None
waba_id = _prompt_validated(
"WABA ID (optional, press Enter to skip)",
lambda v: (True, None) if not v else _validate_waba_id(v),
current=current_waba_id,
help_text=(
"WhatsApp Business Account ID. Found in: App Dashboard →\n"
"WhatsApp → API Setup, near the top — 'WhatsApp Business\n"
"Account ID'. Numeric, ~15+ digits.\n"
"Not required for messaging — useful for analytics."
),
)
_save_optional("WHATSAPP_CLOUD_WABA_ID", waba_id, current_waba_id)
print()
_header("STEP 5 — Verify Token (auto-generated)")
current_verify = get_env_value("WHATSAPP_CLOUD_VERIFY_TOKEN") or None
if current_verify:
print(f" An existing verify token is already set ({current_verify[:8]}...).")
try:
regen = input(" Generate a new one? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
regen = "n"
if regen in {"y", "yes"}:
verify_token = secrets.token_urlsafe(32)
save_env_value("WHATSAPP_CLOUD_VERIFY_TOKEN", verify_token)
print(f" ✓ New verify token: {verify_token}")
else:
verify_token = current_verify
print(" ✓ Keeping existing verify token")
else:
verify_token = secrets.token_urlsafe(32)
save_env_value("WHATSAPP_CLOUD_VERIFY_TOKEN", verify_token)
print(f" ✓ Generated: {verify_token}")
print()
print(" → COPY THIS TOKEN NOW. You'll paste it into Meta's webhook")
print(" configuration dialog (next step).")
print()
_header("STEP 6 — Recipient Allowlist")
print()
print(" Who is allowed to message the bot? (Comma-separated phone")
print(" numbers with country code, no '+' / spaces / dashes. Use '*'")
print(" to allow anyone — only safe if you've also configured Meta's")
print(" recipient whitelist for app-development mode.)")
print()
allow_default = get_env_value("WHATSAPP_CLOUD_ALLOWED_USERS") or None
try:
allowed = line_input(
f" → Allowed users{' [' + allow_default + ']' if allow_default else ''}: "
).strip() or (allow_default or "")
except (EOFError, KeyboardInterrupt):
allowed = ""
if allowed:
# Light normalization — strip spaces and dashes from each entry.
allowed = ",".join(
re.sub(r"[\s\-+]", "", part) for part in allowed.split(",") if part.strip()
)
save_env_value("WHATSAPP_CLOUD_ALLOWED_USERS", allowed)
print(f" ✓ Saved: {allowed}")
else:
print(" ⚠ No allowlist — every inbound message will be denied.")
print(" Re-run this wizard or set WHATSAPP_CLOUD_ALLOWED_USERS manually.")
print()
_header("SETUP COMPLETE — Next steps")
print()
print(" Hermes needs a public HTTPS URL to receive WhatsApp messages.")
print(" The recommended path is Cloudflare Tunnel (free, no port")
print(" forwarding, no DNS setup).")
print()
print(" 1. Install cloudflared (one-time, if you don't have it):")
print(" Windows: winget install Cloudflare.cloudflared")
print(" macOS: brew install cloudflared")
print(" Linux: https://github.com/cloudflare/cloudflared/releases")
print()
print(" Alternatives: ngrok, or your own domain + reverse proxy")
print(" with TLS.")
print()
print(" 2. Start the tunnel in a separate terminal:")
print(" cloudflared tunnel --url http://localhost:8090")
print(" Note the printed https://<random>.trycloudflare.com URL.")
print()
print(" 3. Start the Hermes gateway in another terminal:")
print(" hermes gateway")
print()
print(" 4. Verify your local config is reachable. From a third")
print(" terminal, with the tunnel URL substituted:")
print()
print(" curl 'https://YOUR-TUNNEL.trycloudflare.com/whatsapp/webhook?\\")
print(f" hub.mode=subscribe&hub.verify_token={verify_token}&\\")
print(" hub.challenge=hello'")
print()
print(" Expected: HTTP 200 with body 'hello'.")
print(" Also try: curl https://YOUR-TUNNEL.trycloudflare.com/health")
print(" (should return JSON with verify_token_configured: true).")
print()
print(" 5. Configure Meta to point at your tunnel:")
print(" App Dashboard → WhatsApp → Configuration → Edit webhook")
print(" Callback URL: <tunnel-url>/whatsapp/webhook")
print(f" Verify Token: {verify_token}")
print(" → Click 'Verify and save'")
print(" → Then 'Manage' webhook fields → subscribe to 'messages'")
print()
print(" 6. Add your phone to Meta's recipient list:")
print(" App Dashboard → WhatsApp → API Setup → 'To' →")
print(" 'Manage phone number list'")
print()
print(" 7. DM the bot's test number from your phone.")
print()
_header("Optional: polish your bot's WhatsApp profile")
print()
print(" WhatsApp shows a display name and profile picture for your bot")
print(" in every chat header and contact list. These are set in Meta's")
print(" Business Manager, not via this wizard — but here's where to do")
print(" it once you're up and running:")
print()
effective_waba = waba_id or current_waba_id
print(" • Display name + profile picture:")
print(" https://business.facebook.com/wa/manage/phone-numbers/"
+ (f"?waba_id={effective_waba}" if effective_waba else ""))
if not effective_waba:
print(" (select your WhatsApp Business Account on that page)")
print(" Display-name changes go through a ~24-48h Meta review.")
print()
print(" • About, description, website, hours, business category:")
print(" Same page → click your phone number → 'Edit profile'.")
print()
print(" • Verified badge (the green check):")
print(" Requires Meta's business verification process —")
print(" Business Manager → Security Center → Start Verification.")
print()
print(" Docs: https://hermes-agent.nousresearch.com/docs/user-guide/")
print(" messaging/whatsapp-cloud")
print()
return 0