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:
parent
2ecb4e62bb
commit
52c7976f40
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)")
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user