mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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.
212 lines
7.7 KiB
Python
212 lines
7.7 KiB
Python
"""Per-platform display/verbosity configuration resolver.
|
|
|
|
Provides ``resolve_display_setting()`` — the single entry-point for reading
|
|
display settings with platform-specific overrides and sensible defaults.
|
|
|
|
Resolution order (first non-None wins):
|
|
1. ``display.platforms.<platform>.<key>`` — explicit per-platform user override
|
|
2. ``display.<key>`` — global user setting
|
|
3. ``_PLATFORM_DEFAULTS[<platform>][<key>]`` — built-in sensible default
|
|
4. ``_GLOBAL_DEFAULTS[<key>]`` — built-in global default
|
|
|
|
Exception: ``display.streaming`` is CLI-only. Gateway streaming follows the
|
|
top-level ``streaming`` config unless ``display.platforms.<platform>.streaming``
|
|
sets an explicit per-platform override.
|
|
|
|
Backward compatibility: ``display.tool_progress_overrides`` is still read as a
|
|
fallback for ``tool_progress`` when no ``display.platforms`` entry exists. A
|
|
config migration (version bump) automatically moves the old format into the new
|
|
``display.platforms`` structure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Overrideable display settings and their global defaults
|
|
# ---------------------------------------------------------------------------
|
|
# These are the settings that can be configured per-platform.
|
|
# Other display settings (compact, personality, skin, etc.) are CLI-only
|
|
# and don't participate in per-platform resolution.
|
|
|
|
_GLOBAL_DEFAULTS: dict[str, Any] = {
|
|
"tool_progress": "all",
|
|
"show_reasoning": False,
|
|
"tool_preview_length": 0,
|
|
"streaming": None, # None = follow top-level streaming config
|
|
# When true, delete tool-progress / "Still working..." / status bubbles
|
|
# after the final response lands on platforms that support message
|
|
# deletion (e.g. Telegram). Off by default — progress is still shown
|
|
# live, just cleaned up after success so the chat doesn't fill up with
|
|
# stale breadcrumbs. Failed runs leave bubbles in place as breadcrumbs.
|
|
"cleanup_progress": False,
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sensible per-platform defaults — tiered by platform capability
|
|
# ---------------------------------------------------------------------------
|
|
# Tier 1 (high): Supports message editing, typically personal/team use
|
|
# Tier 2 (medium): Supports editing but often workspace/customer-facing
|
|
# Tier 3 (low): No edit support — each progress msg is permanent
|
|
# Tier 4 (minimal): Batch/non-interactive delivery
|
|
|
|
_TIER_HIGH = {
|
|
"tool_progress": "all",
|
|
"show_reasoning": False,
|
|
"tool_preview_length": 40,
|
|
"streaming": None, # follow global
|
|
}
|
|
|
|
_TIER_MEDIUM = {
|
|
"tool_progress": "new",
|
|
"show_reasoning": False,
|
|
"tool_preview_length": 40,
|
|
"streaming": None,
|
|
}
|
|
|
|
_TIER_LOW = {
|
|
"tool_progress": "off",
|
|
"show_reasoning": False,
|
|
"tool_preview_length": 40,
|
|
"streaming": False,
|
|
}
|
|
|
|
_TIER_MINIMAL = {
|
|
"tool_progress": "off",
|
|
"show_reasoning": False,
|
|
"tool_preview_length": 0,
|
|
"streaming": False,
|
|
}
|
|
|
|
_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
|
|
# Tier 1 — full edit support, personal/team use
|
|
"telegram": {**_TIER_HIGH, "tool_progress": "new"},
|
|
"discord": _TIER_HIGH,
|
|
|
|
# Tier 2 — edit support, often customer/workspace channels
|
|
# Slack: tool_progress off by default — Bolt posts cannot be edited like CLI;
|
|
# "new"/"all" spam permanent lines in channels (hermes-agent#14663).
|
|
"slack": {**_TIER_MEDIUM, "tool_progress": "off"},
|
|
"mattermost": _TIER_MEDIUM,
|
|
"matrix": _TIER_MEDIUM,
|
|
"feishu": _TIER_MEDIUM,
|
|
|
|
# Tier 3 — no edit support, progress messages are permanent
|
|
"signal": _TIER_LOW,
|
|
"whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit
|
|
# WhatsApp Cloud API: Meta added message editing in 2023 but the
|
|
# Hermes Cloud adapter doesn't implement edit_message yet, so we
|
|
# stay on TIER_LOW (tool_progress off) to avoid spamming each
|
|
# status update as a separate message. Promote to TIER_MEDIUM once
|
|
# Cloud's edit_message lands.
|
|
"whatsapp_cloud": _TIER_LOW,
|
|
"bluebubbles": _TIER_LOW,
|
|
"weixin": _TIER_LOW,
|
|
"wecom": _TIER_LOW,
|
|
"wecom_callback": _TIER_LOW,
|
|
"dingtalk": _TIER_LOW,
|
|
|
|
# Tier 4 — batch or non-interactive delivery
|
|
"email": _TIER_MINIMAL,
|
|
"sms": _TIER_MINIMAL,
|
|
"webhook": _TIER_MINIMAL,
|
|
"homeassistant": _TIER_MINIMAL,
|
|
"api_server": {**_TIER_HIGH, "tool_preview_length": 0},
|
|
}
|
|
|
|
# Canonical set of per-platform overrideable keys (for validation).
|
|
OVERRIDEABLE_KEYS = frozenset(_GLOBAL_DEFAULTS.keys())
|
|
|
|
|
|
def resolve_display_setting(
|
|
user_config: dict,
|
|
platform_key: str,
|
|
setting: str,
|
|
fallback: Any = None,
|
|
) -> Any:
|
|
"""Resolve a display setting with per-platform override support.
|
|
|
|
Parameters
|
|
----------
|
|
user_config : dict
|
|
The full parsed config.yaml dict.
|
|
platform_key : str
|
|
Platform config key (e.g. ``"telegram"``, ``"slack"``). Use
|
|
``_platform_config_key(source.platform)`` from gateway/run.py.
|
|
setting : str
|
|
Display setting name (e.g. ``"tool_progress"``, ``"show_reasoning"``).
|
|
fallback : Any
|
|
Fallback value when the setting isn't found anywhere.
|
|
|
|
Returns
|
|
-------
|
|
The resolved value, or *fallback* if nothing is configured.
|
|
"""
|
|
display_cfg = user_config.get("display") or {}
|
|
|
|
# 1. Explicit per-platform override (display.platforms.<platform>.<key>)
|
|
platforms = display_cfg.get("platforms") or {}
|
|
plat_overrides = platforms.get(platform_key)
|
|
if isinstance(plat_overrides, dict):
|
|
val = plat_overrides.get(setting)
|
|
if val is not None:
|
|
return _normalise(setting, val)
|
|
|
|
# 1b. Backward compat: display.tool_progress_overrides.<platform>
|
|
if setting == "tool_progress":
|
|
legacy = display_cfg.get("tool_progress_overrides")
|
|
if isinstance(legacy, dict):
|
|
val = legacy.get(platform_key)
|
|
if val is not None:
|
|
return _normalise(setting, val)
|
|
|
|
# 2. Global user setting (display.<key>). Skip display.streaming because
|
|
# that key controls only CLI terminal streaming; gateway token streaming is
|
|
# governed by the top-level streaming config plus per-platform overrides.
|
|
if setting != "streaming":
|
|
val = display_cfg.get(setting)
|
|
if val is not None:
|
|
return _normalise(setting, val)
|
|
|
|
# 3. Built-in platform default
|
|
plat_defaults = _PLATFORM_DEFAULTS.get(platform_key)
|
|
if plat_defaults:
|
|
val = plat_defaults.get(setting)
|
|
if val is not None:
|
|
return val
|
|
|
|
# 4. Built-in global default
|
|
val = _GLOBAL_DEFAULTS.get(setting)
|
|
if val is not None:
|
|
return val
|
|
|
|
return fallback
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _normalise(setting: str, value: Any) -> Any:
|
|
"""Normalise YAML quirks (bare ``off`` → False in YAML 1.1)."""
|
|
if setting == "tool_progress":
|
|
if value is False:
|
|
return "off"
|
|
if value is True:
|
|
return "all"
|
|
return str(value).lower()
|
|
if setting in {"show_reasoning", "streaming"}:
|
|
if isinstance(value, str):
|
|
return value.lower() in {"true", "1", "yes", "on"}
|
|
return bool(value)
|
|
if setting == "cleanup_progress":
|
|
if isinstance(value, str):
|
|
return value.lower() in {"true", "1", "yes", "on"}
|
|
return bool(value)
|
|
if setting == "tool_preview_length":
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
return value
|