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 000000000..ea77e2126 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/readme.md @@ -0,0 +1,94 @@ +# WhatsApp Chat Agent with Stock Market Insights + +This is a WhatsApp chatbot that provides stock market insights and financial advice using the WhatsApp Business API. The bot is built using FastAPI and can be run locally using ngrok for development and testing. + +## Prerequisites + +- Python 3.7+ +- ngrok account (free tier works fine) +- WhatsApp Business API access +- Meta Developer account +- OpenAI API key + +## Setup Instructions + +1. **Install Dependencies** + +```bash +pip install -r requirements.txt +``` + +2. **Set up ngrok** + + - Download and install ngrok from https://ngrok.com/download + - Sign up for a free account and get your authtoken + - Authenticate ngrok with your token: + ```bash + ngrok config add-authtoken YOUR_AUTH_TOKEN + ``` + +3. **Create a Meta Developer Account** + + - Go to https://developers.facebook.com/ + - Create a new app + - Set up WhatsApp in your app + - Get your WhatsApp Business Account ID and Phone Number ID + +4. **Environment Variables** + Create a `.env` file in the project root with the following variables: + +```env +WHATSAPP_ACCESS_TOKEN=your_whatsapp_access_token +WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id +WHATSAPP_VERIFY_TOKEN=your_custom_verify_token # Can be any string you choose +OPENAI_API_KEY=your_openai_api_key +``` + +## Running the Application + +1. **Start the FastAPI server** + +```bash +python whatsapp_chat_agent.py +``` + +2. **Start ngrok** + In a new terminal window: + +```bash +ngrok http 8000 +``` + +3. **Configure Webhook** + - Copy the HTTPS URL provided by ngrok (e.g., https://xxxx-xx-xx-xxx-xx.ngrok.io) + - Go to your Meta Developer Portal + - Set up Webhooks for your WhatsApp Business Account + - Use the ngrok URL + "/webhook" as your Callback URL + - Use your WHATSAPP_VERIFY_TOKEN as the Verify Token + - Subscribe to the messages webhook + +## Testing the Bot + +1. Send a message to your WhatsApp Business number +2. The bot should respond with stock market insights based on your query +3. You can ask questions about: + - Stock prices + - Company information + - Analyst recommendations + - Stock fundamentals + - Historical prices + - Company news + +## Troubleshooting + +- Make sure all environment variables are properly set +- Check the FastAPI logs for any errors +- Verify that ngrok is running and the webhook URL is correctly configured +- Ensure your WhatsApp Business API is properly set up and the phone number is verified + +## Important Notes + +- The ngrok URL changes every time you restart ngrok (unless you have a paid account) +- You'll need to update the Webhook URL in the Meta Developer Portal whenever the ngrok URL changes +- Keep your WHATSAPP_ACCESS_TOKEN and other credentials secure +- The bot stores conversation history in a SQLite database in the `tmp` directory 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 000000000..00e787fd5 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.68.0 +uvicorn>=0.15.0 +python-dotenv>=0.19.0 +requests>=2.26.0 +yfinance>=0.1.63 +openai>=1.0.0 +agno>=0.1.0 +python-multipart>=0.0.5 +aiohttp>=3.8.0 +SQLAlchemy>=1.4.0 diff --git a/cookbook/examples/apps/whatsapp_chat_agent/whatsapp_chat_agent.py b/cookbook/examples/apps/whatsapp_chat_agent/whatsapp_chat_agent.py new file mode 100644 index 000000000..d468e6e25 --- /dev/null +++ b/cookbook/examples/apps/whatsapp_chat_agent/whatsapp_chat_agent.py @@ -0,0 +1,130 @@ +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.whatsapp import WhatsAppTools +from agno.tools.yfinance import YFinanceTools +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import PlainTextResponse + +# 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" + +# Initialize WhatsApp tools and agent +whatsapp = WhatsAppTools() +agent = Agent( + name="WhatsApp Assistant", + model=OpenAIChat(id="gpt-4o"), + tools=[ + whatsapp, + YFinanceTools( + stock_price=True, + analyst_recommendations=True, + stock_fundamentals=True, + historical_prices=True, + company_info=True, + company_news=True, + ), + ], + 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 financial advisor and can help with stock-related queries. You will respond like how people talk to each other on whatsapp, with short sentences and simple language. don't add markdown to your responses.", +) + +# 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") + + logger.info(f"Webhook verification request - Mode: {mode}, Token: {token}") + + 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: + 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) + whatsapp.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") + logger.info(f"Webhook URL: {WEBHOOK_URL}") + logger.info(f"Verify Token: {VERIFY_TOKEN}") + logger.info( + "Make sure your .env file contains WHATSAPP_ACCESS_TOKEN and WHATSAPP_PHONE_NUMBER_ID" + ) + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/cookbook/tools/whatsapp_tools.py b/cookbook/tools/whatsapp_tools.py new file mode 100644 index 000000000..1d1342cc5 --- /dev/null +++ b/cookbook/tools/whatsapp_tools.py @@ -0,0 +1,48 @@ +""" +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 (https://developers.facebook.com/docs/whatsapp/cloud-api/get-started) + +2. Set Up WhatsApp Business API + - Get your WhatsApp Business Account ID from Business Settings (https://business.facebook.com/settings/) + - Generate a permanent access token in System Users (https://business.facebook.com/settings/system-users) + - Set up a test phone number (https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#testing-your-app) + - Create a message template in Meta Business Manager (https://business.facebook.com/wa/manage/message-templates/) + +3. Configure Environment + - Set these environment variables: + WHATSAPP_ACCESS_TOKEN=your_access_token # Permanent access token from System Users + WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id # Your WhatsApp test phone number ID + +Important Notes: +- WhatsApp has a 24-hour messaging window policy +- You can only send free-form messages to users who have messaged you in the last 24 hours +- For first-time outreach, you must use pre-approved message templates + (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: +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 000000000..88b41f20f --- /dev/null +++ b/libs/agno/agno/tools/whatsapp.py @@ -0,0 +1,212 @@ +import json +import os +from typing import Any, Dict, List, Optional + +import httpx +from dotenv import load_dotenv + +from agno.tools import Toolkit +from agno.utils.log import logger + +# Try to load from both .env and .envrc +load_dotenv() + + +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, + ): + """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) + """ + super().__init__(name="whatsapp") + + # Core credentials + self.access_token = access_token or os.getenv("WHATSAPP_ACCESS_TOKEN") or os.getenv("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("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("RECIPIENT_WAID") + + # API version + self.version = version or os.getenv("WHATSAPP_VERSION") or os.getenv("VERSION", "v22.0") + + # Register methods that can be used by the agent + 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}, + } + 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 + """ + async with httpx.AsyncClient() as client: + response = await client.post(self._get_messages_url(), headers=self._get_headers(), json=data) + 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, recipient: Optional[str] = None, text: str = "", preview_url: bool = False) -> str: + """Send a text 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 + text: The text message to send + 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 + + # Keep the async methods for compatibility but mark them as internal + async def send_text_message(self, *args, **kwargs): + """Internal async version - use send_text_message_sync instead""" + return self.send_text_message_sync(*args, **kwargs) + + async def send_template_message(self, *args, **kwargs): + """Internal async version - use send_template_message_sync instead""" + return self.send_template_message_sync(*args, **kwargs)