Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 27 additions & 27 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

For simplicity, we'll implement a generic interface that can work
with different backends via a bridge pattern.

"""

import asyncio
Expand Down Expand Up @@ -91,9 +92,7 @@ def check_whatsapp_requirements() -> bool:
try:
result = subprocess.run(
["node", "--version"],
capture_output=True,
text=True,
timeout=5
capture_output=True, text=True, timeout=5
)
return result.returncode == 0
except Exception:
Expand Down Expand Up @@ -270,7 +269,7 @@ def _should_process_message(self, data: Dict[str, Any]) -> bool:
if self._message_mentions_bot(data):
return True
return self._message_matches_mention_patterns(data)

async def connect(self) -> bool:
"""
Start the WhatsApp bridge.
Expand All @@ -287,7 +286,7 @@ async def connect(self) -> bool:
return False

logger.info("[%s] Bridge found at %s", self.name, bridge_path)

# Acquire scoped lock to prevent duplicate sessions
try:
from gateway.status import acquire_scoped_lock
Expand Down Expand Up @@ -319,9 +318,7 @@ async def connect(self) -> bool:
install_result = subprocess.run(
["npm", "install", "--silent"],
cwd=str(bridge_dir),
capture_output=True,
text=True,
timeout=60,
capture_output=True, text=True, timeout=60,
)
if install_result.returncode != 0:
print(f"[{self.name}] npm install failed: {install_result.stderr}")
Expand Down Expand Up @@ -386,8 +383,7 @@ async def connect(self) -> bool:
"--session", str(self._session_path),
"--mode", whatsapp_mode,
],
stdout=bridge_log_fh,
stderr=bridge_log_fh,
stdout=bridge_log_fh, stderr=bridge_log_fh,
preexec_fn=None if _IS_WINDOWS else os.setsid,
env=bridge_env,
)
Expand Down Expand Up @@ -477,7 +473,7 @@ async def connect(self) -> bool:
logger.error("[%s] Failed to start bridge: %s", self.name, e, exc_info=True)
self._close_bridge_log()
return False

def _close_bridge_log(self) -> None:
"""Close the bridge log file handle if open."""
if self._bridge_log_fh:
Expand All @@ -491,11 +487,9 @@ async def _check_managed_bridge_exit(self) -> Optional[str]:
"""Return a fatal error message if the managed bridge child exited."""
if self._bridge_process is None:
return None

returncode = self._bridge_process.poll()
if returncode is None:
return None

message = f"WhatsApp bridge process exited unexpectedly (code {returncode})."
if not self.has_fatal_error:
logger.error("[%s] %s", self.name, message)
Expand Down Expand Up @@ -558,13 +552,13 @@ async def disconnect(self) -> None:
self._close_bridge_log()
self._session_lock_identity = None
print(f"[{self.name}] Disconnected")

async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a message via the WhatsApp bridge."""
if not self._running or not self._http_session:
Expand Down Expand Up @@ -661,7 +655,6 @@ async def _send_media_to_bridge(
payload["caption"] = caption
if file_name:
payload["fileName"] = file_name

async with self._http_session.post(
f"http://127.0.0.1:{self._bridge_port}/send-media",
json=payload,
Expand Down Expand Up @@ -717,6 +710,17 @@ async def send_video(
"""Send a video natively via bridge — plays inline in WhatsApp."""
return await self._send_media_to_bridge(chat_id, video_path, "video", caption)

async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
**kwargs,
) -> SendResult:
"""Send an audio file as a native WhatsApp voice message via bridge."""
return await self._send_media_to_bridge(chat_id, audio_path, "audio", caption)

async def send_document(
self,
chat_id: str,
Expand All @@ -728,8 +732,7 @@ async def send_document(
) -> SendResult:
"""Send a document/file as a downloadable attachment via bridge."""
return await self._send_media_to_bridge(
chat_id, file_path, "document", caption,
file_name or os.path.basename(file_path),
chat_id, file_path, "document", caption, file_name or os.path.basename(file_path),
)

async def send_typing(self, chat_id: str, metadata=None) -> None:
Expand All @@ -741,15 +744,14 @@ async def send_typing(self, chat_id: str, metadata=None) -> None:

try:
import aiohttp

await self._http_session.post(
f"http://127.0.0.1:{self._bridge_port}/typing",
json={"chatId": chat_id},
timeout=aiohttp.ClientTimeout(total=5)
)
except Exception:
pass # Ignore typing indicator failures

async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Get information about a WhatsApp chat."""
if not self._running or not self._http_session:
Expand All @@ -759,7 +761,6 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:

try:
import aiohttp

async with self._http_session.get(
f"http://127.0.0.1:{self._bridge_port}/chat/{chat_id}",
timeout=aiohttp.ClientTimeout(total=10)
Expand All @@ -775,11 +776,10 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
logger.debug("Could not get WhatsApp chat info for %s: %s", chat_id, e)

return {"name": chat_id, "type": "dm"}

async def _poll_messages(self) -> None:
"""Poll the bridge for incoming messages."""
import aiohttp

while self._running:
if not self._http_session:
break
Expand Down Expand Up @@ -809,7 +809,7 @@ async def _poll_messages(self) -> None:
await asyncio.sleep(5)

await asyncio.sleep(1) # Poll interval

async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
"""Build a MessageEvent from bridge message data, downloading images to cache."""
try:
Expand All @@ -828,11 +828,11 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
msg_type = MessageType.VOICE
else:
msg_type = MessageType.DOCUMENT

# Determine chat type
is_group = data.get("isGroup", False)
chat_type = "group" if is_group else "dm"

# Build source
source = self.build_source(
chat_id=data.get("chatId", ""),
Expand All @@ -841,7 +841,7 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
user_id=data.get("senderId"),
user_name=data.get("senderName"),
)

# Download media URLs to the local cache so agent tools
# can access them reliably regardless of URL expiration.
raw_urls = data.get("mediaUrls", [])
Expand Down