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:
@@ -179,7 +179,13 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": True, "image_gen": False, "tts": False, "browser": False},
|
||||
lambda: {
|
||||
"web": True,
|
||||
"image_gen": False,
|
||||
"tts": False,
|
||||
"stt": False,
|
||||
"browser": False,
|
||||
},
|
||||
)
|
||||
|
||||
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
|
||||
@@ -191,4 +197,150 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
|
||||
assert "web" in has_direct
|
||||
assert "web" not in already_managed
|
||||
assert set(unconfigured) == {"image_gen", "tts", "browser"}
|
||||
assert set(unconfigured) == {"image_gen", "tts", "stt", "browser"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# STT — managed-by-Nous detection (Phase 4 follow-up)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_stt_managed_by_nous_when_provider_openai_and_no_direct_key(monkeypatch):
|
||||
"""Default `stt.provider: openai` with a Nous sub + no direct OpenAI key
|
||||
should route through the managed audio gateway."""
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"is_managed_tool_gateway_ready",
|
||||
lambda vendor: vendor == "openai-audio",
|
||||
)
|
||||
|
||||
features = ns.get_nous_subscription_features({"stt": {"provider": "openai"}})
|
||||
|
||||
assert features.stt.available is True
|
||||
assert features.stt.active is True
|
||||
assert features.stt.managed_by_nous is True
|
||||
assert features.stt.direct_override is False
|
||||
assert features.stt.current_provider == "OpenAI Whisper"
|
||||
|
||||
|
||||
def test_stt_direct_key_overrides_managed(monkeypatch):
|
||||
"""When the user has VOICE_TOOLS_OPENAI_KEY set, STT should use the
|
||||
direct key, not the managed gateway — same precedence as TTS."""
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "sk-direct-key")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"is_managed_tool_gateway_ready",
|
||||
lambda vendor: vendor == "openai-audio",
|
||||
)
|
||||
|
||||
features = ns.get_nous_subscription_features({"stt": {"provider": "openai"}})
|
||||
|
||||
assert features.stt.available is True
|
||||
assert features.stt.managed_by_nous is False
|
||||
assert features.stt.direct_override is True
|
||||
|
||||
|
||||
def test_stt_groq_provider_requires_groq_key(monkeypatch):
|
||||
env = {"GROQ_API_KEY": "groq-key"}
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: False)
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
|
||||
|
||||
features = ns.get_nous_subscription_features({"stt": {"provider": "groq"}})
|
||||
|
||||
assert features.stt.available is True
|
||||
assert features.stt.managed_by_nous is False
|
||||
assert features.stt.current_provider == "Groq Whisper"
|
||||
assert features.stt.explicit_configured is True
|
||||
|
||||
|
||||
def test_apply_nous_managed_defaults_flips_stt_provider_to_openai_for_nous_users(monkeypatch):
|
||||
"""Fresh Nous-subscribed user with the DEFAULT_CONFIG `stt.provider: local`
|
||||
seed should have it auto-flipped to "openai" so the managed audio
|
||||
gateway transcribes their voice notes without needing faster-whisper
|
||||
installed."""
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
# Avoid the heavy real probing in get_nous_subscription_features.
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"get_nous_subscription_features",
|
||||
lambda config: ns.NousSubscriptionFeatures(
|
||||
subscribed=True,
|
||||
nous_auth_present=True,
|
||||
provider_is_nous=True,
|
||||
features={
|
||||
key: ns.NousFeatureState(
|
||||
key=key, label=key, included_by_default=True,
|
||||
available=False, active=False, managed_by_nous=False,
|
||||
direct_override=False, toolset_enabled=False,
|
||||
explicit_configured=False,
|
||||
)
|
||||
for key in ("web", "image_gen", "tts", "stt", "browser", "modal")
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
config = {"stt": {"provider": "local"}}
|
||||
changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[])
|
||||
|
||||
assert "stt" in changed
|
||||
assert config["stt"]["provider"] == "openai"
|
||||
|
||||
|
||||
def test_apply_nous_managed_defaults_skips_stt_when_groq_key_present(monkeypatch):
|
||||
"""Don't override a user who explicitly set up Groq for STT."""
|
||||
env = {"GROQ_API_KEY": "groq-key"}
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"get_nous_subscription_features",
|
||||
lambda config: ns.NousSubscriptionFeatures(
|
||||
subscribed=True,
|
||||
nous_auth_present=True,
|
||||
provider_is_nous=True,
|
||||
features={
|
||||
key: ns.NousFeatureState(
|
||||
key=key, label=key, included_by_default=True,
|
||||
available=False, active=False, managed_by_nous=False,
|
||||
direct_override=False, toolset_enabled=False,
|
||||
explicit_configured=False,
|
||||
)
|
||||
for key in ("web", "image_gen", "tts", "stt", "browser", "modal")
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
config = {"stt": {"provider": "local"}}
|
||||
changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[])
|
||||
|
||||
# STT was not flipped because the user has a Groq key configured.
|
||||
assert "stt" not in changed
|
||||
assert config["stt"]["provider"] == "local"
|
||||
|
||||
|
||||
def test_apply_gateway_defaults_sets_stt_use_gateway(monkeypatch):
|
||||
config = {}
|
||||
changed = ns.apply_gateway_defaults(config, ["stt"])
|
||||
|
||||
assert "stt" in changed
|
||||
assert config["stt"]["provider"] == "openai"
|
||||
assert config["stt"]["use_gateway"] is True
|
||||
|
||||
Reference in New Issue
Block a user