Skip to content
Open
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ tutorials/01_audio/dscaper_data_impulse_response/*
*audio_dialog.json
./src/sdialog/sdialog
tutorials/01_audio/*.png

# SQLite
*.db
*.db-wal
*.db-shm
59 changes: 59 additions & 0 deletions src/sdialog/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,65 @@ def serve(self,

return Server.serve(agents=self, host=host, port=port, stateless=stateless, debug=debug, log_level=log_level)

def serve_whatsapp(self,
port: int = 1234,
host: str = "0.0.0.0",
phone_number_id: Optional[str] = None,
access_token: Optional[str] = None,
verify_token: Optional[str] = None,
app_secret: Optional[str] = None,
db_path: str = "whatsapp_sessions.db",
webhook_path: str = "/webhook",
session_timeout: int = 1800,
tunnel: bool = False,
auto_setup: bool = True,
log_level: str = "info"):
"""
Starts a Meta WhatsApp Cloud API webhook server to interact with the agent.

:param port: Port number to bind the server to.
:type port: int
:param host: Host address to bind the server to.
:type host: str
:param phone_number_id: Meta WhatsApp Phone Number ID (or WHATSAPP_PHONE_NUMBER_ID env var).
:type phone_number_id: Optional[str]
:param access_token: Meta Graph API Access Token (or WHATSAPP_ACCESS_TOKEN env var).
:type access_token: Optional[str]
:param verify_token: Webhook verification token (or WHATSAPP_VERIFY_TOKEN env var).
:type verify_token: Optional[str]
:param app_secret: Meta App Secret for HMAC validation (or WHATSAPP_APP_SECRET env var).
:type app_secret: Optional[str]
:param db_path: Path to the SQLite session database.
:type db_path: str
:param webhook_path: Webhook URL path.
:type webhook_path: str
:param session_timeout: Seconds of inactivity before a user session resets.
:type session_timeout: int
:param tunnel: If True, attempts to spawn a public ngrok tunnel.
:type tunnel: bool
:param auto_setup: If True, auto-discovers phone ID and auto-syncs Meta webhooks.
:type auto_setup: bool
:param log_level: Logging level for the server.
:type log_level: str
"""
from .whatsapp import WhatsAppServer, WhatsAppConfig

config = WhatsAppConfig(
port=port,
host=host,
phone_number_id=phone_number_id,
access_token=access_token,
verify_token=verify_token,
app_secret=app_secret,
db_path=db_path,
webhook_path=webhook_path,
session_timeout=session_timeout,
tunnel=tunnel,
auto_setup=auto_setup,
log_level=log_level
)
return WhatsAppServer.serve(agent=self, config=config)

def response_lookahead(self, message: str = None):
"""
Generates a response without updating the agent's memory.
Expand Down
63 changes: 63 additions & 0 deletions src/sdialog/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,69 @@ async def serve_async(cls,
server = uvicorn.Server(config)
await server.serve()

@classmethod
def serve_whatsapp(cls,
agent: Agent,
port: int = 1234,
host: str = "0.0.0.0",
phone_number_id: Optional[str] = None,
access_token: Optional[str] = None,
verify_token: Optional[str] = None,
app_secret: Optional[str] = None,
db_path: str = "whatsapp_sessions.db",
webhook_path: str = "/webhook",
session_timeout: int = 1800,
tunnel: bool = False,
auto_setup: bool = True,
log_level: str = "info") -> None:
"""
Serve an SDialog agent via Meta WhatsApp Cloud API webhook.

:param agent: The SDialog agent to serve.
:type agent: Agent
:param port: Port number to listen on.
:type port: int
:param host: Host address to bind to.
:type host: str
:param phone_number_id: Meta WhatsApp Phone Number ID.
:type phone_number_id: Optional[str]
:param access_token: Meta Graph API Access Token.
:type access_token: Optional[str]
:param verify_token: Webhook verification token.
:type verify_token: Optional[str]
:param app_secret: Meta App Secret for HMAC validation.
:type app_secret: Optional[str]
:param db_path: Path to the SQLite session database.
:type db_path: str
:param webhook_path: Webhook URL path.
:type webhook_path: str
:param session_timeout: Inactivity seconds before resetting session.
:type session_timeout: int
:param tunnel: If True, attempts to spawn a public ngrok tunnel.
:type tunnel: bool
:param auto_setup: If True, auto-discovers phone ID and auto-syncs Meta webhooks.
:type auto_setup: bool
:param log_level: Logging level for the server.
:type log_level: str
"""
from .whatsapp import WhatsAppServer, WhatsAppConfig

config = WhatsAppConfig(
port=port,
host=host,
phone_number_id=phone_number_id,
access_token=access_token,
verify_token=verify_token,
app_secret=app_secret,
db_path=db_path,
webhook_path=webhook_path,
session_timeout=session_timeout,
tunnel=tunnel,
auto_setup=auto_setup,
log_level=log_level
)
return WhatsAppServer.serve(agent=agent, config=config)

@classmethod
def serve_in_thread(cls,
agents: Union[Agent, List[Agent]],
Expand Down
25 changes: 25 additions & 0 deletions src/sdialog/whatsapp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# SPDX-FileCopyrightText: Copyright © 2025 Idiap Research Institute <contact@idiap.ch>
# SPDX-FileContributor: Sergio Burdisso <sergio.burdisso@idiap.ch>
# SPDX-FileContributor: Amit Bhakt <amitbhakt@users.noreply.github.com>
# SPDX-License-Identifier: MIT
"""
WhatsApp integration for SDialog agents.

This package provides native WhatsApp serving capabilities for SDialog agents,
enabling two-way text conversations over the Meta WhatsApp Cloud API with SQLite
persistence and per-user session isolation.
"""

from .config import WhatsAppConfig
from .server import WhatsAppServer
from .session_store import SQLiteSessionStore
from .client import MetaWhatsAppClient
from .discovery import MetaDiscoveryClient

__all__ = [
"WhatsAppConfig",
"WhatsAppServer",
"SQLiteSessionStore",
"MetaWhatsAppClient",
"MetaDiscoveryClient",
]
121 changes: 121 additions & 0 deletions src/sdialog/whatsapp/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# SPDX-FileCopyrightText: Copyright © 2025 Idiap Research Institute <contact@idiap.ch>
# SPDX-FileContributor: Sergio Burdisso <sergio.burdisso@idiap.ch>
# SPDX-FileContributor: Amit Bhakt <amitbhakt@users.noreply.github.com>
# SPDX-License-Identifier: MIT
"""
Meta WhatsApp Cloud API client.
"""

import logging
from typing import List
import httpx

from .config import WhatsAppConfig

logger = logging.getLogger(__name__)

# WhatsApp limits single message text body to 4096 characters
MAX_WHATSAPP_TEXT_LENGTH = 4096


class MetaWhatsAppClient:
"""Async client for interacting with the Meta WhatsApp Cloud API."""

def __init__(self, config: WhatsAppConfig):
self.config = config
self.base_url = f"https://graph.facebook.com/{config.api_version}/{config.phone_number_id}"
self.headers = {
"Authorization": f"Bearer {config.access_token}",
"Content-Type": "application/json",
}

async def send_text_message(self, to: str, text: str) -> bool:
"""
Send a text message to a WhatsApp user.

If the text exceeds WhatsApp's maximum length (4096 characters),
it is split and sent in chunks.
"""
if not text:
return False

chunks = self._chunk_text(text, MAX_WHATSAPP_TEXT_LENGTH)
success = True

async with httpx.AsyncClient(timeout=10.0) as client:
for chunk in chunks:
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": to,
"type": "text",
"text": {"preview_url": False, "body": chunk},
}

try:
response = await client.post(
f"{self.base_url}/messages",
json=payload,
headers=self.headers,
)

if response.status_code in (200, 201):
logger.debug(f"[WhatsApp] Sent message to {to}")
else:
logger.error(
f"[WhatsApp] Failed to send message to {to}. "
f"HTTP {response.status_code}: {response.text}"
)
success = False
except Exception as e:
logger.error(f"[WhatsApp] Exception while sending message to {to}: {e}")
success = False

return success

async def mark_as_read(self, message_id: str) -> bool:
"""Mark an incoming WhatsApp message as read (blue double check)."""
if not message_id:
return False

payload = {
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
}

async with httpx.AsyncClient(timeout=5.0) as client:
try:
response = await client.post(
f"{self.base_url}/messages",
json=payload,
headers=self.headers,
)
return response.status_code == 200
except Exception as e:
logger.debug(f"[WhatsApp] Could not mark message {message_id} as read: {e}")
return False

@staticmethod
def _chunk_text(text: str, max_length: int) -> List[str]:
"""Split long messages into WhatsApp-safe chunks without splitting words where possible."""
if len(text) <= max_length:
return [text]

chunks = []
while text:
if len(text) <= max_length:
chunks.append(text)
break

# Find last whitespace within max_length
split_idx = text.rfind("\n", 0, max_length)
if split_idx == -1:
split_idx = text.rfind(" ", 0, max_length)
if split_idx == -1:
split_idx = max_length

chunks.append(text[:split_idx].strip())
text = text[split_idx:].lstrip()

return chunks
64 changes: 64 additions & 0 deletions src/sdialog/whatsapp/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPDX-FileCopyrightText: Copyright © 2025 Idiap Research Institute <contact@idiap.ch>
# SPDX-FileContributor: Sergio Burdisso <sergio.burdisso@idiap.ch>
# SPDX-FileContributor: Amit Bhakt <amitbhakt@users.noreply.github.com>
# SPDX-License-Identifier: MIT
"""
Configuration models for SDialog WhatsApp server.
"""

import os
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class WhatsAppConfig:
"""
Configuration parameters for serving an SDialog agent on WhatsApp.

Parameters will fall back to standard environment variables if not provided:
- ``WHATSAPP_PHONE_NUMBER_ID``
- ``WHATSAPP_ACCESS_TOKEN``
- ``WHATSAPP_VERIFY_TOKEN``
- ``WHATSAPP_APP_SECRET``
"""

port: int = 1234
host: str = "0.0.0.0"
phone_number_id: Optional[str] = field(
default_factory=lambda: os.environ.get("WHATSAPP_PHONE_NUMBER_ID")
)
access_token: Optional[str] = field(
default_factory=lambda: os.environ.get("WHATSAPP_ACCESS_TOKEN") or os.environ.get("WHATSAPP_TOKEN")
)
verify_token: Optional[str] = field(
default_factory=lambda: os.environ.get("WHATSAPP_VERIFY_TOKEN")
or os.environ.get("WHATSAPP_WEBHOOK_VERIFY_TOKEN", "sdialog_whatsapp_secret")
)
app_secret: Optional[str] = field(
default_factory=lambda: os.environ.get("WHATSAPP_APP_SECRET")
)
api_version: str = "v21.0"
db_path: str = "whatsapp_sessions.db"
webhook_path: str = "/webhook"
session_timeout: int = 1800 # 30 minutes idle timeout
tunnel: bool = False
auto_setup: bool = True # Automatically discover IDs and configure Meta webhooks
max_history_turns: int = 40 # Context window sliding budget
enforce_signature: bool = True
mark_as_read: bool = True
log_level: str = "info"

def validate(self, dry_run: bool = False) -> None:
"""Validate that required credentials are set when not in dry_run mode."""
if not dry_run:
if not self.phone_number_id:
raise ValueError(
"WhatsApp phone_number_id is required. Pass it to serve_whatsapp() "
"or set the WHATSAPP_PHONE_NUMBER_ID environment variable."
)
if not self.access_token:
raise ValueError(
"WhatsApp access_token is required. Pass it to serve_whatsapp() "
"or set the WHATSAPP_ACCESS_TOKEN environment variable."
)
Loading