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.
157 lines
7.0 KiB
Python
157 lines
7.0 KiB
Python
"""Tests for hermes_cli.status model/provider display."""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
|
|
|
|
|
|
def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""):
|
|
import hermes_cli.auth as auth_mod
|
|
|
|
monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False)
|
|
monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False)
|
|
|
|
def _get_env_value(name: str):
|
|
if name == "OPENAI_BASE_URL":
|
|
return openai_base_url
|
|
return ""
|
|
|
|
monkeypatch.setattr(status_mod, "get_env_value", _get_env_value, raising=False)
|
|
monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False)
|
|
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
|
|
monkeypatch.setattr(
|
|
status_mod.subprocess,
|
|
"run",
|
|
lambda *args, **kwargs: SimpleNamespace(stdout="inactive\n", returncode=3),
|
|
)
|
|
|
|
|
|
def test_show_status_displays_configured_dict_model_and_provider_label(monkeypatch, capsys, tmp_path):
|
|
from hermes_cli import status as status_mod
|
|
|
|
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
|
monkeypatch.setattr(
|
|
status_mod,
|
|
"load_config",
|
|
lambda: {"model": {"default": "anthropic/claude-sonnet-4", "provider": "anthropic"}},
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "anthropic", raising=False)
|
|
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "anthropic", raising=False)
|
|
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Anthropic", raising=False)
|
|
|
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Model: anthropic/claude-sonnet-4" in out
|
|
assert "Provider: Anthropic" in out
|
|
|
|
|
|
def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatch, capsys, tmp_path):
|
|
from hermes_cli import status as status_mod
|
|
|
|
_patch_common_status_deps(monkeypatch, status_mod, tmp_path, openai_base_url="http://localhost:8080/v1")
|
|
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "qwen3:latest"}, raising=False)
|
|
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "auto", raising=False)
|
|
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openrouter", raising=False)
|
|
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Custom endpoint" if provider == "custom" else provider, raising=False)
|
|
|
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Model: qwen3:latest" in out
|
|
assert "Provider: Custom endpoint" in out
|
|
|
|
|
|
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
|
|
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: True)
|
|
from hermes_cli import status as status_mod
|
|
|
|
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
|
monkeypatch.setattr(
|
|
status_mod,
|
|
"load_config",
|
|
lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}},
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
|
|
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
|
|
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
|
|
monkeypatch.setattr(
|
|
status_mod,
|
|
"get_nous_subscription_features",
|
|
lambda config: NousSubscriptionFeatures(
|
|
subscribed=True,
|
|
nous_auth_present=True,
|
|
provider_is_nous=True,
|
|
features={
|
|
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
|
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
|
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
|
"stt": NousFeatureState("stt", "Speech-to-text", True, True, True, True, False, True, "OpenAI Whisper"),
|
|
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
|
|
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
|
},
|
|
),
|
|
raising=False,
|
|
)
|
|
|
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Nous Tool Gateway" in out
|
|
assert "Browser automation" in out
|
|
assert "active via Nous subscription" in out
|
|
|
|
|
|
def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path):
|
|
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: False)
|
|
from hermes_cli import status as status_mod
|
|
|
|
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
|
monkeypatch.setattr(
|
|
status_mod,
|
|
"load_config",
|
|
lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}},
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
|
|
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
|
|
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
|
|
|
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Nous Tool Gateway" not in out
|
|
|
|
|
|
def test_show_status_reports_empty_lmstudio_listing_as_reachable(monkeypatch, capsys, tmp_path):
|
|
from hermes_cli import status as status_mod
|
|
|
|
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
|
monkeypatch.setattr(
|
|
status_mod,
|
|
"load_config",
|
|
lambda: {
|
|
"model": {
|
|
"default": "qwen/qwen3-coder-30b",
|
|
"provider": "lmstudio",
|
|
"base_url": "http://127.0.0.1:1234/v1",
|
|
}
|
|
},
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "lmstudio", raising=False)
|
|
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "lmstudio", raising=False)
|
|
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "LM Studio", raising=False)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.models.probe_lmstudio_models",
|
|
lambda api_key=None, base_url=None, timeout=5.0: [],
|
|
)
|
|
|
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
|
|
|
out = capsys.readouterr().out
|
|
assert "LM Studio" in out
|
|
assert "reachable (0 model(s)) at http://127.0.0.1:1234/v1" in out
|