diff --git a/cookbook/examples/apps/whatsapp_chat_agent/__init__.py b/cookbook/examples/apps/whatsapp_chat_agent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cookbook/examples/apps/whatsapp_chat_agent/agents.py b/cookbook/examples/apps/whatsapp_chat_agent/agents.py new file mode 100644 index 0000000000..4113771e35 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/agents.py @@ -0,0 +1,54 @@ +import logging +import os + +from agno.agent import Agent +from agno.models.openai import OpenAIChat +from agno.storage.agent.sqlite import SqliteAgentStorage +from agno.tools.duckduckgo import DuckDuckGoTools +from agno.tools.whatsapp import WhatsAppTools +from dotenv import load_dotenv + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Load environment variables +load_dotenv() + +# Configure constants +VERIFY_TOKEN = os.getenv("WHATSAPP_VERIFY_TOKEN") +if not VERIFY_TOKEN: + raise ValueError("WHATSAPP_VERIFY_TOKEN must be set in .envrc") + +WEBHOOK_URL = os.getenv("WHATSAPP_WEBHOOK_URL") +if not WEBHOOK_URL: + raise ValueError("WHATSAPP_WEBHOOK_URL must be set in .envrc") + +AGENT_STORAGE_FILE = "tmp/whatsapp_agents.db" + + +def get_whatsapp_agent() -> Agent: + """Returns an instance of the WhatsApp Agent. + + Returns: + Agent: The configured WhatsApp agent instance. + """ + # Initialize WhatsApp tools + whatsapp = WhatsAppTools() + + # Create and return the agent + return Agent( + name="WhatsApp Assistant", + model=OpenAIChat(id="gpt-4o"), + tools=[ + whatsapp, + DuckDuckGoTools(), + ], + storage=SqliteAgentStorage( + table_name="whatsapp_agent", db_file=AGENT_STORAGE_FILE + ), + add_history_to_messages=True, + num_history_responses=3, + markdown=True, + description="You are a whatsapp chat agent. You will respond like how people talk to each other on whatsapp, with short sentences and simple language. If user asks you something which requires a web search, use the DuckDuckGoTools to search the web.", + ) diff --git a/cookbook/examples/apps/whatsapp_chat_agent/app.py b/cookbook/examples/apps/whatsapp_chat_agent/app.py new file mode 100644 index 0000000000..392bfbe0b0 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/app.py @@ -0,0 +1,91 @@ +import logging + +from agents import VERIFY_TOKEN, get_whatsapp_agent +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import PlainTextResponse +from security import validate_webhook_signature + +# Configure logging +logger = logging.getLogger(__name__) + +# Initialize agent +agent = get_whatsapp_agent() + +# Create FastAPI app +app = FastAPI() + + +@app.get("/webhook") +async def verify_webhook(request: Request): + """Handle WhatsApp webhook verification""" + mode = request.query_params.get("hub.mode") + token = request.query_params.get("hub.verify_token") + challenge = request.query_params.get("hub.challenge") + + if mode == "subscribe" and token == VERIFY_TOKEN: + if not challenge: + raise HTTPException(status_code=400, detail="No challenge received") + return PlainTextResponse(content=challenge) + + raise HTTPException(status_code=403, detail="Invalid verify token or mode") + + +@app.post("/webhook") +async def handle_message(request: Request): + """Handle incoming WhatsApp messages""" + try: + # Get raw payload for signature validation + payload = await request.body() + signature = request.headers.get("X-Hub-Signature-256") + + # Validate webhook signature + if not validate_webhook_signature(payload, signature): + logger.warning("Invalid webhook signature") + raise HTTPException(status_code=403, detail="Invalid signature") + + body = await request.json() + + # Validate webhook data + if body.get("object") != "whatsapp_business_account": + logger.warning( + f"Received non-WhatsApp webhook object: {body.get('object')}" + ) + return {"status": "ignored"} + + # Process messages + for entry in body.get("entry", []): + for change in entry.get("changes", []): + messages = change.get("value", {}).get("messages", []) + + if not messages: + continue + + message = messages[0] + if message.get("type") != "text": + continue + + # Extract message details + phone_number = message["from"] + message_text = message["text"]["body"] + + logger.info(f"Processing message from {phone_number}: {message_text}") + + # Generate and send response + response = agent.run(message_text) + agent.tools[0].send_text_message_sync( + recipient=phone_number, text=response.content + ) + logger.info(f"Response sent to {phone_number}") + + return {"status": "ok"} + + except Exception as e: + logger.error(f"Error processing webhook: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + import uvicorn + + logger.info("Starting WhatsApp Bot Server") + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/cookbook/examples/apps/whatsapp_chat_agent/generate_requirements.sh b/cookbook/examples/apps/whatsapp_chat_agent/generate_requirements.sh new file mode 100755 index 0000000000..78f8c7cec9 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/generate_requirements.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +############################################################################ +# Generate requirements.txt from requirements.in +############################################################################ + +echo "Generating requirements.txt" + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +UV_CUSTOM_COMPILE_COMMAND="./generate_requirements.sh" \ + uv pip compile ${CURR_DIR}/requirements.in --no-cache --upgrade -o ${CURR_DIR}/requirements.txt diff --git a/cookbook/examples/apps/whatsapp_chat_agent/readme.md b/cookbook/examples/apps/whatsapp_chat_agent/readme.md new file mode 100644 index 0000000000..f2ed2e623b --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/readme.md @@ -0,0 +1,158 @@ +# WhatsApp Business API Integration with AI Agent + +This is a WhatsApp chatbot that automatically responds to incoming messages using an AI agent. The bot runs on FastAPI and uses the WhatsApp Business API to handle message interactions. + +## Features + +- Automatically responds to any incoming WhatsApp messages +- Uses AI to generate contextual responses +- Handles webhook verification for WhatsApp Business API +- Supports secure HTTPS communication +- Logs all interactions for monitoring + +## Prerequisites + +- Python 3.7+ +- ngrok account (for development/testing) +- WhatsApp Business API access +- Meta Developer account +- OpenAI API key + +## Getting WhatsApp Credentials + +1. **Create Meta Developer Account**: + + - Go to [Meta Developer Portal](https://developers.facebook.com/) and create an account + - Create a new app at [Meta Apps Dashboard](https://developers.facebook.com/apps/) + - Enable WhatsApp integration for your app + +2. **Set Up WhatsApp Business API**: + + - Go to your app's WhatsApp Setup page + - Find your WhatsApp Business Account ID in Business Settings + - Get your Phone Number ID from the WhatsApp > Getting Started page + - Generate a permanent access token in App Dashboard > WhatsApp > API Setup + +3. **Test Environment Setup**: + - Note: Initially, you can only send messages to numbers registered in your test environment + - For production, you'll need to submit your app for review + +## Environment Setup + +Create a `.envrc` file in the project root with these variables: + +```bash +# From Meta Developer Portal +export WHATSAPP_ACCESS_TOKEN=your_whatsapp_access_token # From App Dashboard > WhatsApp > API Setup +export WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id # From WhatsApp > Getting Started +export WHATSAPP_WEBHOOK_URL=your_webhook_url # Your ngrok URL + /webhook +export WHATSAPP_VERIFY_TOKEN=your_verify_token # Any secure string you choose + +# For OpenAI integration +export OPENAI_API_KEY=your_openai_api_key +``` + +## Installation + +1. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +2. Set up your environment variables in `.envrc`: + +```bash +source .envrc +``` + +## Running the Application + +You need to run two components: + +1. **The ngrok tunnel** (in one terminal): + +```bash +ngrok http --domain=your-domain.ngrok-free.app 8000 +``` + +2. **The FastAPI server** (in another terminal): + +```bash +python app.py +``` + +## WhatsApp Business Setup + +1. Go to Meta Developer Portal +2. Set up your WhatsApp Business account +3. Configure the webhook: + - URL: Your ngrok URL + "/webhook" (e.g., https://your-domain.ngrok-free.app/webhook) + - Verify Token: Same as WHATSAPP_VERIFY_TOKEN in your .envrc + - Subscribe to the 'messages' webhook field + +## How It Works + +1. When someone sends a message to your WhatsApp Business number: + + - The message is received via webhook + - The AI agent processes the message + - A response is automatically generated and sent back + +2. The agent can: + - Process incoming text messages + - Generate contextual responses + - Log all interactions + +## Monitoring + +The application logs important events: + +- Server start/stop +- Incoming messages +- Response generation +- Message delivery status + +Check the console output for logs. + +## Error Handling + +The application includes error handling for: + +- Invalid webhook verification +- Message processing errors +- API communication issues + +## Security Notes + +- Keep your environment variables secure +- Don't commit `.envrc` to version control +- Use HTTPS for all communications +- Regularly rotate your access tokens + +## Troubleshooting + +Common issues: + +1. Webhook verification failing: + + - Check your VERIFY_TOKEN matches + - Ensure ngrok is running + - Verify webhook URL is correct + +2. Messages not being received: + + - Check webhook subscription status + - Verify WhatsApp Business API access + +3. No responses being sent: + - Verify OpenAI API key + - Check WhatsApp access token + +## Support + +For issues and questions: + +1. Check the logs for error messages +2. Review Meta's WhatsApp Business API documentation +3. Verify your API credentials and tokens diff --git a/cookbook/examples/apps/whatsapp_chat_agent/requirements.in b/cookbook/examples/apps/whatsapp_chat_agent/requirements.in new file mode 100644 index 0000000000..c638ce6039 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/requirements.in @@ -0,0 +1,10 @@ +fastapi +uvicorn +python-dotenv +requests +duckduckgo-search +openai +agno +python-multipart +aiohttp +SQLAlchemy diff --git a/cookbook/examples/apps/whatsapp_chat_agent/requirements.txt b/cookbook/examples/apps/whatsapp_chat_agent/requirements.txt new file mode 100644 index 0000000000..03a9354be1 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/requirements.txt @@ -0,0 +1,172 @@ +# This file was autogenerated by uv via the following command: +# ./generate_requirements.sh +agno==1.1.9 + # via -r requirements.in +aiohappyeyeballs==2.5.0 + # via aiohttp +aiohttp==3.11.13 + # via -r requirements.in +aiosignal==1.3.2 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.8.0 + # via + # httpx + # openai + # starlette +attrs==25.1.0 + # via aiohttp +beautifulsoup4==4.13.3 + # via yfinance +certifi==2025.1.31 + # via + # httpcore + # httpx + # requests +charset-normalizer==3.4.1 + # via requests +click==8.1.8 + # via + # typer + # uvicorn +distro==1.9.0 + # via openai +docstring-parser==0.16 + # via agno +fastapi==0.115.11 + # via -r requirements.in +frozendict==2.4.6 + # via yfinance +frozenlist==1.5.0 + # via + # aiohttp + # aiosignal +gitdb==4.0.12 + # via gitpython +gitpython==3.1.44 + # via agno +h11==0.14.0 + # via + # httpcore + # uvicorn +httpcore==1.0.7 + # via httpx +httpx==0.28.1 + # via + # agno + # openai +idna==3.10 + # via + # anyio + # httpx + # requests + # yarl +jiter==0.8.2 + # via openai +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +multidict==6.1.0 + # via + # aiohttp + # yarl +multitasking==0.0.11 + # via yfinance +numpy==2.2.3 + # via + # pandas + # yfinance +openai==1.65.5 + # via -r requirements.in +pandas==2.2.3 + # via yfinance +peewee==3.17.9 + # via yfinance +platformdirs==4.3.6 + # via yfinance +propcache==0.3.0 + # via + # aiohttp + # yarl +pydantic==2.10.6 + # via + # agno + # fastapi + # openai + # pydantic-settings +pydantic-core==2.27.2 + # via pydantic +pydantic-settings==2.8.1 + # via agno +pygments==2.19.1 + # via rich +python-dateutil==2.9.0.post0 + # via pandas +python-dotenv==1.0.1 + # via + # -r requirements.in + # agno + # pydantic-settings +python-multipart==0.0.20 + # via + # -r requirements.in + # agno +pytz==2025.1 + # via + # pandas + # yfinance +pyyaml==6.0.2 + # via agno +requests==2.32.3 + # via + # -r requirements.in + # yfinance +rich==13.9.4 + # via + # agno + # typer +shellingham==1.5.4 + # via typer +six==1.17.0 + # via python-dateutil +smmap==5.0.2 + # via gitdb +sniffio==1.3.1 + # via + # anyio + # openai +soupsieve==2.6 + # via beautifulsoup4 +sqlalchemy==2.0.38 + # via -r requirements.in +starlette==0.46.1 + # via fastapi +tomli==2.2.1 + # via agno +tqdm==4.67.1 + # via openai +typer==0.15.2 + # via agno +typing-extensions==4.12.2 + # via + # agno + # anyio + # beautifulsoup4 + # fastapi + # openai + # pydantic + # pydantic-core + # sqlalchemy + # typer +tzdata==2025.1 + # via pandas +urllib3==2.3.0 + # via requests +uvicorn==0.34.0 + # via -r requirements.in +yarl==1.18.3 + # via aiohttp +duckduckgo-search==7.3.2 + # via -r requirements.in diff --git a/cookbook/examples/apps/whatsapp_chat_agent/security.py b/cookbook/examples/apps/whatsapp_chat_agent/security.py new file mode 100644 index 0000000000..01f62fdfab --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/security.py @@ -0,0 +1,59 @@ +import hmac +import hashlib +import os +from typing import Optional + +def is_development_mode() -> bool: + """Check if the application is running in development mode.""" + return os.getenv('APP_ENV', 'development').lower() == 'development' + +def get_app_secret() -> str: + """ + Get the WhatsApp app secret from environment variables. + In development mode, returns a dummy secret if WHATSAPP_APP_SECRET is not set. + """ + app_secret = os.getenv('WHATSAPP_APP_SECRET') + + if not app_secret: + if is_development_mode(): + app_secret = 'dummy_secret_for_local_development' + print("WARNING: Using dummy secret for local development. Do not use in production!") + else: + raise ValueError('WHATSAPP_APP_SECRET environment variable is not set in production mode') + + return app_secret + +def validate_webhook_signature(payload: bytes, signature_header: Optional[str]) -> bool: + """ + Validate the webhook payload using SHA256 signature. + In development mode, signature validation can be bypassed. + + Args: + payload: The raw request payload bytes + signature_header: The X-Hub-Signature-256 header value + + Returns: + bool: True if signature is valid or in development mode, False otherwise + """ + # In development mode, we can bypass signature validation + if is_development_mode(): + if not signature_header: + print("WARNING: Bypassing signature validation in development mode") + return True + + if not signature_header or not signature_header.startswith('sha256='): + return False + + app_secret = get_app_secret() + expected_signature = signature_header.split('sha256=')[1] + + # Calculate signature + hmac_obj = hmac.new( + app_secret.encode('utf-8'), + payload, + hashlib.sha256 + ) + calculated_signature = hmac_obj.hexdigest() + + # Compare signatures using constant-time comparison + return hmac.compare_digest(calculated_signature, expected_signature) diff --git a/cookbook/examples/apps/whatsapp_chat_agent/tests/test_security.py b/cookbook/examples/apps/whatsapp_chat_agent/tests/test_security.py new file mode 100644 index 0000000000..58084ee087 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/tests/test_security.py @@ -0,0 +1,94 @@ +import os +import hmac +import hashlib +from cookbook.examples.apps.whatsapp_chat_agent.security import is_development_mode, get_app_secret, validate_webhook_signature + +def generate_signature(payload: bytes, secret: str) -> str: + """Helper function to generate a valid signature.""" + hmac_obj = hmac.new( + secret.encode('utf-8'), + payload, + hashlib.sha256 + ) + return f"sha256={hmac_obj.hexdigest()}" + +def test_development_mode(): + """Test security behavior in development mode.""" + print("\n=== Testing Development Mode ===") + + # Ensure we're in development mode + os.environ['APP_ENV'] = 'development' + if 'WHATSAPP_APP_SECRET' in os.environ: + del os.environ['WHATSAPP_APP_SECRET'] + + print(f"Is development mode? {is_development_mode()}") + + # Test 1: Get app secret without setting WHATSAPP_APP_SECRET + try: + secret = get_app_secret() + print("✅ Got dummy secret in development mode:", secret) + except ValueError as e: + print("❌ Failed to get dummy secret:", e) + + # Test 2: Validate signature with no signature header + payload = b"test message" + result = validate_webhook_signature(payload, None) + print("✅ Signature bypass in dev mode:", result) + + # Test 3: Validate with invalid signature + result = validate_webhook_signature(payload, "sha256=invalid") + print("✅ Invalid signature check in dev mode:", result is False) + +def test_production_mode(): + """Test security behavior in production mode.""" + print("\n=== Testing Production Mode ===") + + # Switch to production mode + os.environ['APP_ENV'] = 'production' + test_secret = "test_production_secret" + os.environ['WHATSAPP_APP_SECRET'] = test_secret + + print(f"Is development mode? {is_development_mode()}") + + # Test 1: Get app secret + try: + secret = get_app_secret() + print("✅ Got production secret:", secret == test_secret) + except ValueError as e: + print("❌ Failed to get production secret:", e) + + # Test 2: Validate correct signature + payload = b"test message" + valid_signature = generate_signature(payload, test_secret) + result = validate_webhook_signature(payload, valid_signature) + print("✅ Valid signature check in prod mode:", result) + + # Test 3: Validate invalid signature + result = validate_webhook_signature(payload, "sha256=invalid") + print("✅ Invalid signature check in prod mode:", result is False) + + # Test 4: Try with no signature + result = validate_webhook_signature(payload, None) + print("✅ No signature check in prod mode:", result is False) + +def test_production_mode_no_secret(): + """Test production mode without secret configured.""" + print("\n=== Testing Production Mode (No Secret) ===") + + # Switch to production mode without secret + os.environ['APP_ENV'] = 'production' + if 'WHATSAPP_APP_SECRET' in os.environ: + del os.environ['WHATSAPP_APP_SECRET'] + + try: + secret = get_app_secret() + print("❌ Should not get secret without configuration") + except ValueError as e: + print("✅ Correctly failed without secret:", str(e)) + +if __name__ == "__main__": + # Run all tests + test_development_mode() + test_production_mode() + test_production_mode_no_secret() + print("\nTests completed!") diff --git a/cookbook/tools/whatsapp_tools.py b/cookbook/tools/whatsapp_tools.py new file mode 100644 index 0000000000..71cdc4d6a2 --- /dev/null +++ b/cookbook/tools/whatsapp_tools.py @@ -0,0 +1,45 @@ +""" +WhatsApp Cookbook +---------------- + +This cookbook demonstrates how to use WhatsApp integration with Agno. Before running this example, +you'll need to complete these setup steps: + +1. Create Meta Developer Account + - Go to [Meta Developer Portal](https://developers.facebook.com/) and create a new account + - Create a new app at [Meta Apps Dashboard](https://developers.facebook.com/apps/) + - Enable WhatsApp integration for your app [here](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started) + +2. Set Up WhatsApp Business API + You can get your WhatsApp Business Account ID from [Business Settings](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started) + +3. Configure Environment + - Set these environment variables: + WHATSAPP_ACCESS_TOKEN=your_access_token # Access Token + WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id # Phone Number ID + WHATSAPP_RECIPIENT_WAID=your_recipient_waid # Recipient WhatsApp ID (e.g. 1234567890) + WHATSAPP_VERSION=your_whatsapp_version # WhatsApp API Version (e.g. v22.0) + +Important Notes: +- For first-time outreach, you must use pre-approved message templates + [here](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates) +- Test messages can only be sent to numbers that are registered in your test environment + +The example below shows how to send a template message using Agno's WhatsApp tools. +For more complex use cases, check out the WhatsApp Cloud API documentation: +[here](https://developers.facebook.com/docs/whatsapp/cloud-api/overview) +""" + +from agno.agent import Agent +from agno.tools.whatsapp import WhatsAppTools + +agent = Agent( + name="whatsapp", + tools=[WhatsAppTools()], +) + +# Example: Send a template message +# Note: Replace 'hello_world' with your actual template name +agent.print_response( + "Send a template message using the 'hello_world' template in English" +) diff --git a/libs/agno/agno/tools/whatsapp.py b/libs/agno/agno/tools/whatsapp.py new file mode 100644 index 0000000000..247c820d84 --- /dev/null +++ b/libs/agno/agno/tools/whatsapp.py @@ -0,0 +1,314 @@ +import json +import os +from typing import Any, Dict, List, Optional + +import httpx + +from agno.tools import Toolkit +from agno.utils.log import logger + + +class WhatsAppTools(Toolkit): + """WhatsApp Business API toolkit for sending messages.""" + + base_url = "https://graph.facebook.com" + + def __init__( + self, + access_token: Optional[str] = None, + phone_number_id: Optional[str] = None, + version: str = "v22.0", + recipient_waid: Optional[str] = None, + async_mode: bool = False, + ): + """Initialize WhatsApp toolkit. + + Args: + access_token: WhatsApp Business API access token + phone_number_id: WhatsApp Business Account phone number ID + version: API version to use + recipient_waid: Default recipient WhatsApp ID (optional) + async_mode: Whether to use async methods (default: False) + """ + super().__init__(name="whatsapp") + + # Core credentials + self.access_token = access_token or os.getenv("WHATSAPP_ACCESS_TOKEN") or os.getenv("WHATSAPP_ACCESS_TOKEN") + if not self.access_token: + logger.error("WHATSAPP_ACCESS_TOKEN not set. Please set the WHATSAPP_ACCESS_TOKEN environment variable.") + + self.phone_number_id = ( + phone_number_id or os.getenv("WHATSAPP_PHONE_NUMBER_ID") or os.getenv("WHATSAPP_PHONE_NUMBER_ID") + ) + if not self.phone_number_id: + logger.error( + "WHATSAPP_PHONE_NUMBER_ID not set. Please set the WHATSAPP_PHONE_NUMBER_ID environment variable." + ) + + # Optional default recipient + self.default_recipient = ( + recipient_waid or os.getenv("WHATSAPP_RECIPIENT_WAID") or os.getenv("WHATSAPP_RECIPIENT_WAID") + ) + + # API version and mode + self.version = version or os.getenv("WHATSAPP_VERSION") or os.getenv("WHATSAPP_VERSION", "v22.0") + self.async_mode = async_mode + + # Register methods that can be used by the agent based on mode + if self.async_mode: + self.register(self.send_text_message_async) + self.register(self.send_template_message_async) + else: + self.register(self.send_text_message_sync) + self.register(self.send_template_message_sync) + + # Log configuration status + self._log_config_status() + + def _log_config_status(self): + """Log the configuration status of the WhatsApp toolkit.""" + config_status = { + "Core credentials": { + "access_token": bool(self.access_token), + "phone_number_id": bool(self.phone_number_id), + }, + "Optional settings": { + "default_recipient": bool(self.default_recipient), + "api_version": self.version, + "async_mode": self.async_mode, + }, + } + logger.debug(f"WhatsApp toolkit configuration status: {json.dumps(config_status, indent=2)}") + + def _get_headers(self) -> Dict[str, str]: + """Get headers for API requests.""" + return {"Authorization": f"Bearer {self.access_token}", "Content-Type": "application/json"} + + def _get_messages_url(self) -> str: + """Get the messages endpoint URL.""" + return f"{self.base_url}/{self.version}/{self.phone_number_id}/messages" + + async def _send_message_async(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Send a message asynchronously using the WhatsApp API. + + Args: + data: Message data to send + + Returns: + API response as dictionary + """ + url = self._get_messages_url() + headers = self._get_headers() + + logger.debug(f"Sending WhatsApp request to URL: {url}") + logger.debug(f"Request data: {json.dumps(data, indent=2)}") + logger.debug(f"Headers: {json.dumps(headers, indent=2)}") + + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=data) + + logger.debug(f"Response status code: {response.status_code}") + logger.debug(f"Response headers: {dict(response.headers)}") + logger.debug(f"Response body: {response.text}") + + response.raise_for_status() + return response.json() + + def _send_message_sync(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Send a message synchronously using the WhatsApp API. + + Args: + data: Message data to send + + Returns: + API response as dictionary + """ + url = self._get_messages_url() + headers = self._get_headers() + + logger.debug(f"Sending WhatsApp request to URL: {url}") + logger.debug(f"Request data: {json.dumps(data, indent=2)}") + logger.debug(f"Headers: {json.dumps(headers, indent=2)}") + + response = httpx.post(url, headers=headers, json=data) + + logger.debug(f"Response status code: {response.status_code}") + logger.debug(f"Response headers: {dict(response.headers)}") + logger.debug(f"Response body: {response.text}") + + response.raise_for_status() + return response.json() + + def send_text_message_sync(self, text: str = "", recipient: Optional[str] = None, preview_url: bool = False) -> str: + """Send a text message to a WhatsApp user (synchronous version). + + Args: + text: The text message to send + recipient: Recipient's WhatsApp ID or phone number (e.g., "+1234567890"). If not provided, uses default_recipient + preview_url: Whether to generate previews for links in the message + + Returns: + Success message with message ID + """ + # Use default recipient if none provided + if recipient is None: + if not self.default_recipient: + raise ValueError("No recipient provided and no default recipient set") + recipient = self.default_recipient + logger.debug(f"Using default recipient: {recipient}") + + logger.debug(f"Sending WhatsApp message to {recipient}: {text}") + logger.debug(f"Current config - Phone Number ID: {self.phone_number_id}, Version: {self.version}") + + data = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": recipient, + "type": "text", + "text": {"preview_url": preview_url, "body": text}, + } + + try: + response = self._send_message_sync(data) + message_id = response.get("messages", [{}])[0].get("id", "unknown") + logger.debug(f"Full API response: {json.dumps(response, indent=2)}") + return f"Message sent successfully! Message ID: {message_id}" + except httpx.HTTPStatusError as e: + logger.error(f"Failed to send WhatsApp message: {e}") + logger.error(f"Error response: {e.response.text if hasattr(e, 'response') else 'No response text'}") + raise + except Exception as e: + logger.error(f"Unexpected error sending WhatsApp message: {str(e)}") + raise + + def send_template_message_sync( + self, + recipient: Optional[str] = None, + template_name: str = "", + language_code: str = "en_US", + components: Optional[List[Dict[str, Any]]] = None, + ) -> str: + """Send a template message to a WhatsApp user (synchronous version). + + Args: + recipient: Recipient's WhatsApp ID or phone number (e.g., "+1234567890"). If not provided, uses default_recipient + template_name: Name of the template to use + language_code: Language code for the template (e.g., "en_US") + components: Optional list of template components (header, body, buttons) + + Returns: + Success message with message ID + """ + # Use default recipient if none provided + if recipient is None: + if not self.default_recipient: + raise ValueError("No recipient provided and no default recipient set") + recipient = self.default_recipient + + logger.debug(f"Sending WhatsApp template message to {recipient}: {template_name}") + + data = { + "messaging_product": "whatsapp", + "to": recipient, + "type": "template", + "template": {"name": template_name, "language": {"code": language_code}}, + } + + if components: + data["template"]["components"] = components # type: ignore[index] + + try: + response = self._send_message_sync(data) + message_id = response.get("messages", [{}])[0].get("id", "unknown") + return f"Template message sent successfully! Message ID: {message_id}" + except httpx.HTTPStatusError as e: + logger.error(f"Failed to send WhatsApp template message: {e}") + raise + + async def send_text_message_async( + self, text: str = "", recipient: Optional[str] = None, preview_url: bool = False + ) -> str: + """Send a text message to a WhatsApp user (asynchronous version). + + Args: + text: The text message to send + recipient: Recipient's WhatsApp ID or phone number (e.g., "+1234567890"). If not provided, uses default_recipient + preview_url: Whether to generate previews for links in the message + + Returns: + Success message with message ID + """ + # Use default recipient if none provided + if recipient is None: + if not self.default_recipient: + raise ValueError("No recipient provided and no default recipient set") + recipient = self.default_recipient + logger.debug(f"Using default recipient: {recipient}") + + logger.debug(f"Sending WhatsApp message to {recipient}: {text}") + logger.debug(f"Current config - Phone Number ID: {self.phone_number_id}, Version: {self.version}") + + data = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": recipient, + "type": "text", + "text": {"preview_url": preview_url, "body": text}, + } + + try: + response = await self._send_message_async(data) + message_id = response.get("messages", [{}])[0].get("id", "unknown") + logger.debug(f"Full API response: {json.dumps(response, indent=2)}") + return f"Message sent successfully! Message ID: {message_id}" + except httpx.HTTPStatusError as e: + logger.error(f"Failed to send WhatsApp message: {e}") + logger.error(f"Error response: {e.response.text if hasattr(e, 'response') else 'No response text'}") + raise + except Exception as e: + logger.error(f"Unexpected error sending WhatsApp message: {str(e)}") + raise + + async def send_template_message_async( + self, + recipient: Optional[str] = None, + template_name: str = "", + language_code: str = "en_US", + components: Optional[List[Dict[str, Any]]] = None, + ) -> str: + """Send a template message to a WhatsApp user (asynchronous version). + + Args: + recipient: Recipient's WhatsApp ID or phone number (e.g., "+1234567890"). If not provided, uses default_recipient + template_name: Name of the template to use + language_code: Language code for the template (e.g., "en_US") + components: Optional list of template components (header, body, buttons) + + Returns: + Success message with message ID + """ + # Use default recipient if none provided + if recipient is None: + if not self.default_recipient: + raise ValueError("No recipient provided and no default recipient set") + recipient = self.default_recipient + + logger.debug(f"Sending WhatsApp template message to {recipient}: {template_name}") + + data = { + "messaging_product": "whatsapp", + "to": recipient, + "type": "template", + "template": {"name": template_name, "language": {"code": language_code}}, + } + + if components: + data["template"]["components"] = components # type: ignore[index] + + try: + response = await self._send_message_async(data) + message_id = response.get("messages", [{}])[0].get("id", "unknown") + return f"Template message sent successfully! Message ID: {message_id}" + except httpx.HTTPStatusError as e: + logger.error(f"Failed to send WhatsApp template message: {e}") + raise