mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
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
8 changed files with 319 additions and 35 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue