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
8 changed files with 319 additions and 35 deletions
+113 -26
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,
+12 -2
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)