diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index 7a2337e367e..0d406274c0c 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -47,6 +47,7 @@ import hmac import logging import mimetypes import os +import re import shutil import uuid from collections import OrderedDict @@ -93,6 +94,9 @@ GRAPH_API_BASE = "https://graph.facebook.com" # delivery within minutes, not days. 5000 entries with FIFO eviction is # plenty for normal traffic and bounds memory. WAMID_DEDUP_CACHE_SIZE = 5000 +# Cap for the interactive-button state dicts and the per-chat last-wamid +# cache. Generous for any realistic number of in-flight prompts / chats. +INTERACTIVE_STATE_CACHE_SIZE = 1000 # Per-type size caps documented by Meta for the Cloud API /media endpoint. # These are the hard limits; we refuse uploads above them with a clean @@ -211,22 +215,36 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._api_version: str = str(extra.get("api_version", DEFAULT_API_VERSION)) # Behavior-mixin contract: these names are read by the mixin's - # gating methods. Derived from env / config the same way the - # Baileys adapter derives them. + # gating methods. WHATSAPP_CLOUD_* env vars take precedence so the + # two adapters can run in parallel with independent policies; the + # shared WHATSAPP_* names remain as fallback for single-adapter + # setups. import os self._reply_prefix: Optional[str] = extra.get("reply_prefix") self._dm_policy: str = str( - extra.get("dm_policy") or os.getenv("WHATSAPP_DM_POLICY", "open") + extra.get("dm_policy") + or os.getenv("WHATSAPP_CLOUD_DM_POLICY") + or os.getenv("WHATSAPP_DM_POLICY", "open") ).strip().lower() - self._allow_from: set[str] = self._coerce_allow_list( - extra.get("allow_from") or extra.get("allowFrom") + self._allow_from: set[str] = self._normalize_allow_ids( + self._coerce_allow_list( + extra.get("allow_from") + or extra.get("allowFrom") + or os.getenv("WHATSAPP_CLOUD_ALLOW_FROM") + ) ) self._group_policy: str = str( - extra.get("group_policy") or os.getenv("WHATSAPP_GROUP_POLICY", "open") + extra.get("group_policy") + or os.getenv("WHATSAPP_CLOUD_GROUP_POLICY") + or os.getenv("WHATSAPP_GROUP_POLICY", "open") ).strip().lower() - self._group_allow_from: set[str] = self._coerce_allow_list( - extra.get("group_allow_from") or extra.get("groupAllowFrom") + self._group_allow_from: set[str] = self._normalize_allow_ids( + self._coerce_allow_list( + extra.get("group_allow_from") + or extra.get("groupAllowFrom") + or os.getenv("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") + ) ) self._mention_patterns = self._compile_mention_patterns() @@ -249,21 +267,25 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # threading an extra kwarg through the gateway's base contract. # In-memory only; on gateway restart the next inbound message # repopulates it. - self._last_inbound_wamid_by_chat: Dict[str, str] = {} + self._last_inbound_wamid_by_chat: "OrderedDict[str, str]" = OrderedDict() # Interactive-button state. Each maps a short id (embedded in the # outbound button payload) → the session/correlation key needed # by the gateway's resolver. See ``_handle_interactive_reply`` for - # the dispatch table. + # the dispatch table. Entries are popped when the user taps a + # button; ignored prompts would otherwise accumulate forever, so + # each dict is FIFO-capped via _bounded_put (oldest pending prompt + # evicted first — an evicted button tap degrades to the plain-text + # fallback path, same as after a gateway restart). # _clarify_state: clarify_id → session_key (resolves via # tools.clarify_gateway.resolve_gateway_clarify) # _exec_approval_state: approval_id → session_key (resolves via # tools.approval.resolve_gateway_approval) # _slash_confirm_state: confirm_id → session_key (resolves via # tools.slash_confirm.resolve) - self._clarify_state: Dict[str, str] = {} - self._exec_approval_state: Dict[str, str] = {} - self._slash_confirm_state: Dict[str, str] = {} + self._clarify_state: "OrderedDict[str, str]" = OrderedDict() + self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict() + self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict() # Runtime self._runner = None @@ -281,6 +303,13 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): path = path[1:] return f"{GRAPH_API_BASE}/{self._api_version}/{self._phone_number_id}/{path}" + @staticmethod + def _bounded_put(cache: "OrderedDict[str, str]", key: str, value: str) -> None: + """Insert into a FIFO-capped OrderedDict, evicting oldest entries.""" + cache[key] = value + while len(cache) > INTERACTIVE_STATE_CACHE_SIZE: + cache.popitem(last=False) + def _effective_reply_prefix(self) -> str: """Cloud API has no self-chat concept — never prepend a reply prefix. @@ -291,6 +320,30 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return self._reply_prefix.replace("\\n", "\n") return "" + @staticmethod + def _normalize_allow_ids(ids: set[str]) -> set[str]: + """Normalize allowlist entries to bare wa_id form. + + The Cloud API identifies users by bare wa_id (digits, no JID + suffix), while Baileys uses ``@s.whatsapp.net`` JIDs. + Users sharing an allowlist between both adapters (or pasting a + JID/phone number with ``+`` or separators) should still match, + so strip any ``@...`` suffix and non-digit characters. + """ + normalized: set[str] = set() + for entry in ids: + bare = entry.split("@", 1)[0] + digits = re.sub(r"\D", "", bare) + normalized.add(digits or entry) + return normalized + + def _is_dm_allowed(self, sender_id: str) -> bool: + """Allowlist check against the normalized bare wa_id.""" + if self._dm_policy == "allowlist": + bare = re.sub(r"\D", "", str(sender_id).split("@", 1)[0]) + return (bare or sender_id) in self._allow_from + return super()._is_dm_allowed(sender_id) + # ------------------------------------------------------------------ lifecycle async def connect(self) -> bool: if not check_whatsapp_cloud_requirements(): @@ -687,7 +740,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): result = await self._post_interactive(chat_id, interactive, reply_to=reply_to) if result.success: - self._clarify_state[clarify_id] = session_key + self._bounded_put(self._clarify_state, clarify_id, session_key) return result async def send_exec_approval( @@ -740,7 +793,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): result = await self._post_interactive(chat_id, interactive, reply_to=reply_to) if result.success: - self._exec_approval_state[approval_id] = session_key + self._bounded_put(self._exec_approval_state, approval_id, session_key) return result async def send_slash_confirm( @@ -788,7 +841,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): result = await self._post_interactive(chat_id, interactive, reply_to=reply_to) if result.success: - self._slash_confirm_state[confirm_id] = session_key + self._bounded_put(self._slash_confirm_state, confirm_id, session_key) return result @staticmethod @@ -1061,12 +1114,24 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if is_local_mp3: opus_path = await self._convert_to_opus(audio_path) if opus_path: - source = opus_path - mime_type = "audio/ogg; codecs=opus" - else: - # Will deliver as MP3 attachment, not voice bubble. - # Warn-once is logged inside _convert_to_opus. - mime_type = "audio/mpeg" + try: + result = await self._send_media_from_path_or_link( + chat_id, opus_path, "audio", + caption=caption, reply_to=reply_to, + mime_type="audio/ogg; codecs=opus", + ) + finally: + # The .ogg is a transient conversion artifact next to + # the source MP3 — clean it up after upload so voice + # sends don't leak a file per message. + try: + os.unlink(opus_path) + except OSError: + pass + return result + # Will deliver as MP3 attachment, not voice bubble. + # Warn-once is logged inside _convert_to_opus. + mime_type = "audio/mpeg" return await self._send_media_from_path_or_link( chat_id, source, "audio", @@ -1159,6 +1224,16 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): """ if self._http_client is None: return None, None + # Defense in depth: media_id comes from the (signature-verified) + # webhook payload, but it's interpolated into both a Graph URL and + # a cache filename below — refuse anything that isn't a plain + # Meta-style media id so a hostile payload can't traverse paths. + media_id = str(media_id).strip() + if not re.fullmatch(r"[A-Za-z0-9._-]+", media_id): + logger.warning( + "[whatsapp_cloud] refusing malformed media id %r", media_id[:64] + ) + return None, None headers = {"Authorization": f"Bearer {self._access_token}"} # Step 1 — metadata (gives us a temporary signed URL + mime) @@ -1436,9 +1511,21 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): wamid, ) continue - event = await self._build_message_event_from_cloud( - raw_message, contacts_by_waid, metadata - ) + try: + event = await self._build_message_event_from_cloud( + raw_message, contacts_by_waid, metadata + ) + except Exception: + # Build errors must not bubble out either: the wamid + # is already dedup-marked above, so a 500 here would + # make Meta retry the batch and every message in it + # (including this one) would be silently dropped as + # a duplicate. Log and move on to the next message. + logger.exception( + "[whatsapp_cloud] failed to build event for wamid %s", + wamid, + ) + continue if event is None: continue self._accepted_count += 1 @@ -1855,7 +1942,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # to this message. Done HERE (after _should_process_message # gating) so filtered messages don't leak typing on # unwanted inbound traffic. - self._last_inbound_wamid_by_chat[chat_id] = wamid + self._bounded_put(self._last_inbound_wamid_by_chat, chat_id, wamid) return MessageEvent( text=body, diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index dfdc612084a..6b56be3b8de 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -342,8 +342,18 @@ class WhatsAppBehaviorMixin: # _text_ is already WhatsApp italic — leave as-is # --- 4. Convert markdown headers to bold text --- - # # Header → *Header* - result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) + # # Header → *Header*. Strip any *...* wrapping already produced + # by step 3 (e.g. "# **Title**" → "*Title*", not "**Title**", + # which WhatsApp renders with literal asterisks). + def _header_to_bold(m: re.Match) -> str: + inner = m.group(1).strip() + while len(inner) > 1 and inner.startswith("*") and inner.endswith("*"): + inner = inner[1:-1].strip() + return f"*{inner}*" + + result = re.sub( + r"^#{1,6}\s+(.+)$", _header_to_bold, result, flags=re.MULTILINE + ) # --- 5. Convert markdown links: [text](url) → text (url) --- result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index 75950738e0e..50dfb97ba2a 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -226,6 +226,24 @@ def _stt_label(current_provider: str) -> str: return mapping.get(current_provider or "local", current_provider or "Local faster-whisper") +def _local_stt_backend_available() -> bool: + """Whether a local STT backend could serve transcription right now. + + True when faster-whisper is importable or a custom local STT command + is configured. Used both for feature detection and to stop + ``apply_nous_managed_defaults`` from flipping a working local setup + to the managed gateway. + """ + if get_env_value("HERMES_LOCAL_STT_COMMAND"): + return True + try: + from tools.transcription_tools import _HAS_FASTER_WHISPER + + return bool(_HAS_FASTER_WHISPER) + except Exception: + return False + + def _resolve_browser_feature_state( *, browser_tool_enabled: bool, @@ -772,10 +790,22 @@ def apply_nous_managed_defaults( # (requires `pip install faster-whisper`); for Nous subscribers we # flip it to "openai" so the managed audio gateway handles transcription # via the same auth as TTS. Skipped when the user has explicitly - # configured STT or has direct credentials for a non-managed provider. - if not features.stt.explicit_configured and not ( - get_env_value("GROQ_API_KEY") - or get_env_value("MISTRAL_API_KEY") + # configured STT, has direct credentials for a non-managed provider, + # has a working local backend (faster-whisper installed or a custom + # local command — strong intent signal that "local" was a choice, not + # just the DEFAULT_CONFIG seed), or isn't entitled to the managed + # "openai-audio" category (flipping would point at a gateway that + # refuses them, silently breaking voice transcription). + if ( + not features.stt.explicit_configured + and not _local_stt_backend_available() + and not ( + resolve_openai_audio_api_key() + or get_env_value("GROQ_API_KEY") + or get_env_value("MISTRAL_API_KEY") + ) + and features.account_info is not None + and features.account_info.tool_gateway_entitled_for("openai-audio") ): stt_cfg["provider"] = "openai" changed.add("stt") diff --git a/hermes_cli/setup_whatsapp_cloud.py b/hermes_cli/setup_whatsapp_cloud.py index f885e40fc49..af979c05d56 100644 --- a/hermes_cli/setup_whatsapp_cloud.py +++ b/hermes_cli/setup_whatsapp_cloud.py @@ -162,17 +162,25 @@ def _validate_access_token(value: str) -> tuple[bool, Optional[str]]: # --------------------------------------------------------------------------- -def _prompt(message: str, default: Optional[str] = None) -> str: +def _prompt(message: str, default: Optional[str] = None, secret: bool = False) -> str: """Read one line of input. Returns "" on EOF / Ctrl+C / empty input. The ``default`` parameter is shown to the user but NOT auto-applied on empty input — callers handle the "user kept existing" case explicitly so they can distinguish between a real value and a display preview (e.g. ``"abc12345..."`` for masked secrets). + + ``secret=True`` reads via ``getpass`` so credentials are not echoed + to the terminal (or left in scrollback). """ try: suffix = f" [{default}]" if default else "" - raw = input(f"{message}{suffix}: ").strip() + if secret and sys.stdin.isatty(): + import getpass + + raw = getpass.getpass(f"{message}{suffix} (input hidden): ").strip() + else: + raw = input(f"{message}{suffix}: ").strip() except (EOFError, KeyboardInterrupt): print() return "" @@ -185,6 +193,7 @@ def _prompt_validated( *, 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. @@ -198,7 +207,7 @@ def _prompt_validated( attempts = 0 while True: attempts += 1 - value = _prompt(f" → {message}", default=current) + value = _prompt(f" → {message}", default=current, secret=secret) if not value: return None ok, reason = validator(value) @@ -295,6 +304,7 @@ def run_whatsapp_cloud_setup() -> int: "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" @@ -335,6 +345,7 @@ def run_whatsapp_cloud_setup() -> int: "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" diff --git a/tests/gateway/test_whatsapp_cloud.py b/tests/gateway/test_whatsapp_cloud.py index 735bf7d24d9..4db634e737a 100644 --- a/tests/gateway/test_whatsapp_cloud.py +++ b/tests/gateway/test_whatsapp_cloud.py @@ -2248,3 +2248,74 @@ class TestSendTyping: await adapter.send_typing("15551234567") headers = adapter._http_client.post.call_args.kwargs["headers"] assert headers["Authorization"] == "Bearer my-test-token" + + +# --------------------------------------------------------------------------- +# Allowlist normalization + env decoupling (salvage follow-up) +# --------------------------------------------------------------------------- + +class TestAllowlistNormalization: + def test_normalize_allow_ids_strips_jid_suffix_and_punctuation(self): + from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter + + ids = {"15551234567@s.whatsapp.net", "+1 (555) 765-4321", "15550000000"} + normalized = WhatsAppCloudAdapter._normalize_allow_ids(ids) + assert normalized == {"15551234567", "15557654321", "15550000000"} + + def test_dm_allowlist_matches_bare_wa_id_against_jid_entry(self): + """A Baileys-style JID in the allowlist must match the Cloud API's + bare wa_id sender — users share allowlists between both adapters.""" + from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter + + adapter = _make_adapter() + adapter._dm_policy = "allowlist" + adapter._allow_from = WhatsAppCloudAdapter._normalize_allow_ids( + {"15551234567@s.whatsapp.net"} + ) + assert adapter._is_dm_allowed("15551234567") is True + assert adapter._is_dm_allowed("19998887777") is False + + def test_cloud_env_overrides_take_precedence(self, monkeypatch): + """WHATSAPP_CLOUD_DM_POLICY wins over the shared WHATSAPP_DM_POLICY + so both adapters can run in parallel with independent policies.""" + from gateway.platforms.whatsapp_cloud import WhatsAppCloudAdapter + + monkeypatch.setenv("WHATSAPP_DM_POLICY", "allowlist") + monkeypatch.setenv("WHATSAPP_CLOUD_DM_POLICY", "open") + monkeypatch.setenv("WHATSAPP_CLOUD_ALLOW_FROM", "+1 555 123 4567") + + config = MagicMock() + config.extra = { + "phone_number_id": "123", + "access_token": "tok", + } + adapter = WhatsAppCloudAdapter(config) + assert adapter._dm_policy == "open" + assert adapter._allow_from == {"15551234567"} + + +class TestBoundedInteractiveState: + def test_bounded_put_evicts_oldest(self): + from collections import OrderedDict + + from gateway.platforms.whatsapp_cloud import ( + INTERACTIVE_STATE_CACHE_SIZE, + WhatsAppCloudAdapter, + ) + + cache: OrderedDict = OrderedDict() + for i in range(INTERACTIVE_STATE_CACHE_SIZE + 10): + WhatsAppCloudAdapter._bounded_put(cache, f"id-{i}", "sess") + assert len(cache) == INTERACTIVE_STATE_CACHE_SIZE + assert "id-0" not in cache + assert f"id-{INTERACTIVE_STATE_CACHE_SIZE + 9}" in cache + + +class TestMediaIdValidation: + @pytest.mark.asyncio + async def test_traversal_media_id_refused(self): + adapter = _make_adapter() + adapter._http_client = MagicMock() # would be used if not refused + path, mime = await adapter._download_media_to_cache("../../etc/passwd") + assert path is None and mime is None + adapter._http_client.get.assert_not_called() diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 04b3174cdc2..dd88728865b 100644 --- a/tests/gateway/test_whatsapp_formatting.py +++ b/tests/gateway/test_whatsapp_formatting.py @@ -91,6 +91,13 @@ class TestFormatMessage: assert adapter.format_message("## Subtitle") == "*Subtitle*" assert adapter.format_message("### Deep") == "*Deep*" + def test_bold_header_does_not_double_wrap(self): + """"# **Title**" must become *Title*, not **Title** (WhatsApp would + render the doubled asterisks literally).""" + adapter = _make_adapter() + assert adapter.format_message("# **Title**") == "*Title*" + assert adapter.format_message("## __Strong__") == "*Strong*" + def test_links_converted(self): adapter = _make_adapter() result = adapter.format_message("[click here](https://example.com)") diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index e815688ee39..9faf25965e0 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -723,6 +723,10 @@ def test_apply_nous_managed_defaults_flips_stt_provider_to_openai_for_nous_users gateway transcribes their voice notes without needing faster-whisper installed.""" monkeypatch.setattr(ns, "get_env_value", lambda name: "") + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + # CI installs [all] extras, so faster-whisper is importable there — + # force the "no local backend" case this test is about. + monkeypatch.setattr(ns, "_local_stt_backend_available", lambda: False) # Avoid the heavy real probing in get_nous_subscription_features. monkeypatch.setattr( ns, @@ -751,6 +755,66 @@ def test_apply_nous_managed_defaults_flips_stt_provider_to_openai_for_nous_users assert config["stt"]["provider"] == "openai" +def _stt_features_stub(*, account_info): + return ns.NousSubscriptionFeatures( + subscribed=True, + nous_auth_present=True, + provider_is_nous=True, + account_info=account_info, + 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", "video_gen", "tts", "stt", "browser", "modal") + }, + ) + + +def test_apply_nous_managed_defaults_keeps_local_stt_when_backend_works(monkeypatch): + """A working local backend (faster-whisper installed or custom command) + is a strong intent signal — never flip it to the managed gateway.""" + monkeypatch.setattr(ns, "get_env_value", lambda name: "") + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "_local_stt_backend_available", lambda: True) + monkeypatch.setattr( + ns, + "get_nous_subscription_features", + lambda config, **kw: _stt_features_stub( + account_info=_account(logged_in=True, paid=True) + ), + ) + + config = {"stt": {"provider": "local"}} + changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[]) + + assert "stt" not in changed + assert config["stt"]["provider"] == "local" + + +def test_apply_nous_managed_defaults_skips_stt_when_not_entitled(monkeypatch): + """A subscriber whose tool pool doesn't cover openai-audio must not be + pointed at a managed gateway that will refuse them.""" + monkeypatch.setattr(ns, "get_env_value", lambda name: "") + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "_local_stt_backend_available", lambda: False) + monkeypatch.setattr( + ns, + "get_nous_subscription_features", + lambda config, **kw: _stt_features_stub( + account_info=_account(logged_in=True, paid=False) + ), + ) + + config = {"stt": {"provider": "local"}} + changed = ns.apply_nous_managed_defaults(config, enabled_toolsets=[]) + + assert "stt" not in changed + assert config["stt"]["provider"] == "local" + + 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"} diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index d59279a7d57..a22499733e9 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -313,6 +313,10 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `WHATSAPP_CLOUD_WEBHOOK_PATH` | URL path Meta posts inbound messages to (default `/whatsapp/webhook`) | | `WHATSAPP_CLOUD_API_VERSION` | Meta Graph API version to call (default `v20.0`) | | `WHATSAPP_CLOUD_HOME_CHANNEL` | `wa_id` to use as the bot's home channel (for cron jobs etc.) | +| `WHATSAPP_CLOUD_DM_POLICY` | DM gating for the Cloud adapter (`open`/`allowlist`/`disabled`); falls back to `WHATSAPP_DM_POLICY` when unset | +| `WHATSAPP_CLOUD_ALLOW_FROM` | Comma-separated senders allowed when `dm_policy: allowlist` (bare `wa_id`s; Baileys-style JIDs are normalized) | +| `WHATSAPP_CLOUD_GROUP_POLICY` | Group gating for the Cloud adapter (`open`/`allowlist`/`disabled`); falls back to `WHATSAPP_GROUP_POLICY` when unset | +| `WHATSAPP_CLOUD_GROUP_ALLOW_FROM` | Comma-separated group chat IDs allowed when `group_policy: allowlist` | | `SIGNAL_HTTP_URL` | signal-cli daemon HTTP endpoint (for example `http://127.0.0.1:8080`) | | `SIGNAL_ACCOUNT` | Bot phone number in E.164 format | | `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs |