fix(whatsapp-cloud): review follow-ups for #43921

- nous_subscription: gate the STT managed-default flip on openai-audio
  entitlement and skip when a local backend (faster-whisper or custom
  command) works; new _local_stt_backend_available() helper + tests
- whatsapp_cloud: WHATSAPP_CLOUD_{DM_POLICY,ALLOW_FROM,GROUP_POLICY,
  GROUP_ALLOW_FROM} env overrides so both adapters can run in parallel;
  normalize allowlist entries (JID/punctuation) to bare wa_id
- whatsapp_cloud: wrap per-message event build in try/except (dedup-marked
  wamids would be silently dropped on Meta's batch retry otherwise)
- whatsapp_cloud: validate media_id before URL/filename interpolation,
  delete transient .ogg after voice upload, FIFO-cap interactive-button
  state dicts and per-chat wamid cache
- whatsapp_common: '# **Title**' headers no longer double-wrap asterisks
- setup wizard: read access token / app secret via getpass on TTYs
- docs: new WHATSAPP_CLOUD_* gating env vars
This commit is contained in:
teknium1 2026-06-11 07:51:01 -07:00
parent 2ecb4e62bb
commit 52c7976f40
No known key found for this signature in database
8 changed files with 319 additions and 35 deletions

View file

@ -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 ``<digits>@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,

View file

@ -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)

View file

@ -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")

View file

@ -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"

View file

@ -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()

View file

@ -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)")

View file

@ -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"}

View file

@ -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 |