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 logging
import mimetypes import mimetypes
import os import os
import re
import shutil import shutil
import uuid import uuid
from collections import OrderedDict 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 # delivery within minutes, not days. 5000 entries with FIFO eviction is
# plenty for normal traffic and bounds memory. # plenty for normal traffic and bounds memory.
WAMID_DEDUP_CACHE_SIZE = 5000 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. # 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 # 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)) self._api_version: str = str(extra.get("api_version", DEFAULT_API_VERSION))
# Behavior-mixin contract: these names are read by the mixin's # Behavior-mixin contract: these names are read by the mixin's
# gating methods. Derived from env / config the same way the # gating methods. WHATSAPP_CLOUD_* env vars take precedence so the
# Baileys adapter derives them. # two adapters can run in parallel with independent policies; the
# shared WHATSAPP_* names remain as fallback for single-adapter
# setups.
import os import os
self._reply_prefix: Optional[str] = extra.get("reply_prefix") self._reply_prefix: Optional[str] = extra.get("reply_prefix")
self._dm_policy: str = str( 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() ).strip().lower()
self._allow_from: set[str] = self._coerce_allow_list( self._allow_from: set[str] = self._normalize_allow_ids(
extra.get("allow_from") or extra.get("allowFrom") self._coerce_allow_list(
extra.get("allow_from")
or extra.get("allowFrom")
or os.getenv("WHATSAPP_CLOUD_ALLOW_FROM")
)
) )
self._group_policy: str = str( 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() ).strip().lower()
self._group_allow_from: set[str] = self._coerce_allow_list( self._group_allow_from: set[str] = self._normalize_allow_ids(
extra.get("group_allow_from") or extra.get("groupAllowFrom") 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() 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. # threading an extra kwarg through the gateway's base contract.
# In-memory only; on gateway restart the next inbound message # In-memory only; on gateway restart the next inbound message
# repopulates it. # 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 # Interactive-button state. Each maps a short id (embedded in the
# outbound button payload) → the session/correlation key needed # outbound button payload) → the session/correlation key needed
# by the gateway's resolver. See ``_handle_interactive_reply`` for # 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 # _clarify_state: clarify_id → session_key (resolves via
# tools.clarify_gateway.resolve_gateway_clarify) # tools.clarify_gateway.resolve_gateway_clarify)
# _exec_approval_state: approval_id → session_key (resolves via # _exec_approval_state: approval_id → session_key (resolves via
# tools.approval.resolve_gateway_approval) # tools.approval.resolve_gateway_approval)
# _slash_confirm_state: confirm_id → session_key (resolves via # _slash_confirm_state: confirm_id → session_key (resolves via
# tools.slash_confirm.resolve) # tools.slash_confirm.resolve)
self._clarify_state: Dict[str, str] = {} self._clarify_state: "OrderedDict[str, str]" = OrderedDict()
self._exec_approval_state: Dict[str, str] = {} self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict()
self._slash_confirm_state: Dict[str, str] = {} self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict()
# Runtime # Runtime
self._runner = None self._runner = None
@ -281,6 +303,13 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
path = path[1:] path = path[1:]
return f"{GRAPH_API_BASE}/{self._api_version}/{self._phone_number_id}/{path}" 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: def _effective_reply_prefix(self) -> str:
"""Cloud API has no self-chat concept — never prepend a reply prefix. """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 self._reply_prefix.replace("\\n", "\n")
return "" 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 # ------------------------------------------------------------------ lifecycle
async def connect(self) -> bool: async def connect(self) -> bool:
if not check_whatsapp_cloud_requirements(): 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) result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
if result.success: if result.success:
self._clarify_state[clarify_id] = session_key self._bounded_put(self._clarify_state, clarify_id, session_key)
return result return result
async def send_exec_approval( 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) result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
if result.success: if result.success:
self._exec_approval_state[approval_id] = session_key self._bounded_put(self._exec_approval_state, approval_id, session_key)
return result return result
async def send_slash_confirm( 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) result = await self._post_interactive(chat_id, interactive, reply_to=reply_to)
if result.success: if result.success:
self._slash_confirm_state[confirm_id] = session_key self._bounded_put(self._slash_confirm_state, confirm_id, session_key)
return result return result
@staticmethod @staticmethod
@ -1061,9 +1114,21 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
if is_local_mp3: if is_local_mp3:
opus_path = await self._convert_to_opus(audio_path) opus_path = await self._convert_to_opus(audio_path)
if opus_path: if opus_path:
source = opus_path try:
mime_type = "audio/ogg; codecs=opus" result = await self._send_media_from_path_or_link(
else: 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. # Will deliver as MP3 attachment, not voice bubble.
# Warn-once is logged inside _convert_to_opus. # Warn-once is logged inside _convert_to_opus.
mime_type = "audio/mpeg" mime_type = "audio/mpeg"
@ -1159,6 +1224,16 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
""" """
if self._http_client is None: if self._http_client is None:
return None, 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}"} headers = {"Authorization": f"Bearer {self._access_token}"}
# Step 1 — metadata (gives us a temporary signed URL + mime) # Step 1 — metadata (gives us a temporary signed URL + mime)
@ -1436,9 +1511,21 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
wamid, wamid,
) )
continue continue
try:
event = await self._build_message_event_from_cloud( event = await self._build_message_event_from_cloud(
raw_message, contacts_by_waid, metadata 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: if event is None:
continue continue
self._accepted_count += 1 self._accepted_count += 1
@ -1855,7 +1942,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
# to this message. Done HERE (after _should_process_message # to this message. Done HERE (after _should_process_message
# gating) so filtered messages don't leak typing on # gating) so filtered messages don't leak typing on
# unwanted inbound traffic. # 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( return MessageEvent(
text=body, text=body,

View File

@ -342,8 +342,18 @@ class WhatsAppBehaviorMixin:
# _text_ is already WhatsApp italic — leave as-is # _text_ is already WhatsApp italic — leave as-is
# --- 4. Convert markdown headers to bold text --- # --- 4. Convert markdown headers to bold text ---
# # Header → *Header* # # Header → *Header*. Strip any *...* wrapping already produced
result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) # 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) --- # --- 5. Convert markdown links: [text](url) → text (url) ---
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result) 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") 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( def _resolve_browser_feature_state(
*, *,
browser_tool_enabled: bool, browser_tool_enabled: bool,
@ -772,10 +790,22 @@ def apply_nous_managed_defaults(
# (requires `pip install faster-whisper`); for Nous subscribers we # (requires `pip install faster-whisper`); for Nous subscribers we
# flip it to "openai" so the managed audio gateway handles transcription # flip it to "openai" so the managed audio gateway handles transcription
# via the same auth as TTS. Skipped when the user has explicitly # via the same auth as TTS. Skipped when the user has explicitly
# configured STT or has direct credentials for a non-managed provider. # configured STT, has direct credentials for a non-managed provider,
if not features.stt.explicit_configured and not ( # has a working local backend (faster-whisper installed or a custom
get_env_value("GROQ_API_KEY") # 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") 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" stt_cfg["provider"] = "openai"
changed.add("stt") changed.add("stt")

View File

@ -162,16 +162,24 @@ 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. """Read one line of input. Returns "" on EOF / Ctrl+C / empty input.
The ``default`` parameter is shown to the user but NOT auto-applied The ``default`` parameter is shown to the user but NOT auto-applied
on empty input callers handle the "user kept existing" case on empty input callers handle the "user kept existing" case
explicitly so they can distinguish between a real value and a explicitly so they can distinguish between a real value and a
display preview (e.g. ``"abc12345..."`` for masked secrets). 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: try:
suffix = f" [{default}]" if default else "" suffix = f" [{default}]" if default else ""
if secret and sys.stdin.isatty():
import getpass
raw = getpass.getpass(f"{message}{suffix} (input hidden): ").strip()
else:
raw = input(f"{message}{suffix}: ").strip() raw = input(f"{message}{suffix}: ").strip()
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
print() print()
@ -185,6 +193,7 @@ def _prompt_validated(
*, *,
current: Optional[str] = None, current: Optional[str] = None,
help_text: Optional[str] = None, help_text: Optional[str] = None,
secret: bool = False,
) -> Optional[str]: ) -> Optional[str]:
"""Repeat the prompt until the user enters a valid value or aborts. """Repeat the prompt until the user enters a valid value or aborts.
@ -198,7 +207,7 @@ def _prompt_validated(
attempts = 0 attempts = 0
while True: while True:
attempts += 1 attempts += 1
value = _prompt(f"{message}", default=current) value = _prompt(f"{message}", default=current, secret=secret)
if not value: if not value:
return None return None
ok, reason = validator(value) ok, reason = validator(value)
@ -295,6 +304,7 @@ def run_whatsapp_cloud_setup() -> int:
"Access Token", "Access Token",
_validate_access_token, _validate_access_token,
current=current_display, current=current_display,
secret=True,
help_text=( help_text=(
"Two options for getting one:\n\n" "Two options for getting one:\n\n"
" (a) TEMP — App Dashboard → WhatsApp → API Setup →\n" " (a) TEMP — App Dashboard → WhatsApp → API Setup →\n"
@ -335,6 +345,7 @@ def run_whatsapp_cloud_setup() -> int:
"App Secret", "App Secret",
_validate_app_secret, _validate_app_secret,
current=current_secret_display, current=current_secret_display,
secret=True,
help_text=( help_text=(
"Found in: App Dashboard → Settings → Basic →\n" "Found in: App Dashboard → Settings → Basic →\n"
"'App secret' field (click 'Show', enter your Facebook password).\n\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") await adapter.send_typing("15551234567")
headers = adapter._http_client.post.call_args.kwargs["headers"] headers = adapter._http_client.post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer my-test-token" 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("## Subtitle") == "*Subtitle*"
assert adapter.format_message("### Deep") == "*Deep*" 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): def test_links_converted(self):
adapter = _make_adapter() adapter = _make_adapter()
result = adapter.format_message("[click here](https://example.com)") 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 gateway transcribes their voice notes without needing faster-whisper
installed.""" installed."""
monkeypatch.setattr(ns, "get_env_value", lambda name: "") 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. # Avoid the heavy real probing in get_nous_subscription_features.
monkeypatch.setattr( monkeypatch.setattr(
ns, ns,
@ -751,6 +755,66 @@ def test_apply_nous_managed_defaults_flips_stt_provider_to_openai_for_nous_users
assert config["stt"]["provider"] == "openai" 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): 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.""" """Don't override a user who explicitly set up Groq for STT."""
env = {"GROQ_API_KEY": "groq-key"} 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_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_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_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_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_ACCOUNT` | Bot phone number in E.164 format |
| `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs | | `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs |