├── .env.example ├── README.md ├── persona.json.example ├── requirements.txt └── script.py /.env.example: -------------------------------------------------------------------------------- 1 | GEMINI_API_KEY="YOUR_GEMINI_API_KEY" 2 | WASENDER_API_TOKEN="YOUR_WASENDER_API_TOKEN" 3 | # Optional: If you change the port in script.py, update it here too for ngrok or other services 4 | # FLASK_RUN_PORT=5000 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Affordable WhatsApp AI Chatbot Built in Python: Just $6/month 2 | 3 | Create a powerful WhatsApp chatbot powered by Google's Gemini AI for just $6/month (WaSenderAPI subscription) plus Google's free Gemini API tier (1500 requests/month). This Python-based solution uses Flask to handle incoming messages via WaSenderAPI webhooks and leverages Gemini's advanced AI capabilities to generate intelligent, conversational responses. 4 | 5 | ## 💰 Cost-Effective Solution 6 | 7 | - **WaSenderAPI**: Only $6/month for WhatsApp integration 8 | - **Gemini AI**: Free tier with 1500 requests/month 9 | - **Hosting**: Run locally or on low-cost cloud options 10 | - **No WhatsApp Business API fees**: Uses WaSenderAPI as an affordable alternative 11 | 12 | ## 🔥 Key Features 13 | 14 | - **WhatsApp Integration**: Receives and sends messages through WaSenderAPI 15 | - **AI-Powered Responses**: Generates intelligent replies using Google's Gemini AI 16 | - **Media Support**: Handles text, images, audio, video, and document messages 17 | - **Smart Message Splitting**: Automatically breaks long responses into multiple messages for better readability 18 | - **Customizable AI Persona**: Tailor the bot's personality and behavior via simple JSON configuration 19 | - **Conversation History**: Maintains context between messages for natural conversations 20 | - **Error Handling**: Robust logging and error management for reliable operation 21 | - **Easy Configuration**: Simple setup with environment variables 22 | 23 | ## 📁 Project Structure 24 | 25 | ``` 26 | /whatsapp-python-chatbot/ 27 | ├── script.py # Main Flask application and bot logic 28 | ├── requirements.txt # Python dependencies 29 | ├── .env # Environment variables (API keys, etc.) 30 | ├── persona.json # Customizable AI personality settings 31 | └── README.md # This file 32 | ``` 33 | 34 | ## 🚀 Setup and Installation 35 | 36 | 1. **Clone the repository (if applicable) or create the files as described.** 37 | 38 | 2. **Create a virtual environment (recommended):** 39 | 40 | ```bash 41 | python3 -m venv venv 42 | source venv/bin/activate # On Windows use `venv\Scripts\activate` 43 | ``` 44 | 45 | 3. **Install dependencies:** 46 | 47 | ```bash 48 | pip3 install -r requirements.txt 49 | ``` 50 | 51 | 4. **Configure Environment Variables:** 52 | Create a `.env` file in the project root directory by copying the example below. **Do not commit your `.env` file to version control if it contains sensitive keys.** 53 | 54 | ```env 55 | GEMINI_API_KEY="YOUR_GEMINI_API_KEY_HERE" # Free tier: 1500 requests/month 56 | WASENDER_API_TOKEN="YOUR_WASENDER_API_TOKEN_HERE" # $6/month subscription 57 | # Optional: If you change the port in script.py, update it here too for ngrok or other services 58 | # FLASK_RUN_PORT=5000 59 | ``` 60 | 61 | Replace the placeholder values with your actual API keys: 62 | 63 | - `GEMINI_API_KEY`: Your API key for the Gemini API (free tier available) 64 | - `WASENDER_API_TOKEN`: Your API token from WaSenderAPI ($6/month subscription) 65 | 66 | ## 🏃‍♂️ Running the Application 67 | 68 | ### 1. Development Mode (using Flask's built-in server) 69 | 70 | This is suitable for local development and testing. 71 | 72 | ```bash 73 | python3 script.py 74 | ``` 75 | 76 | The application will typically run on `http://0.0.0.0:5001/` by default. 77 | 78 | ### 2. Using ngrok for Webhook Testing 79 | 80 | WaSenderAPI needs to send webhook events (incoming messages) to a publicly accessible URL. If you're running the Flask app locally, `ngrok` can expose your local server to the internet. 81 | 82 | a. **Install ngrok** (if you haven't already) from [https://ngrok.com/](https://ngrok.com/). 83 | 84 | b. **Start ngrok** to forward to your Flask app's port (e.g., 5001): 85 | 86 | ```bash 87 | ngrok http 5001 88 | ``` 89 | 90 | c. **ngrok will provide you with a public URL** (e.g., `https://xxxx-xx-xxx-xxx-xx.ngrok-free.app`). 91 | 92 | d. **Configure this ngrok URL as your webhook URL** in the WaSenderAPI dashboard for your connected device/session. Make sure to append the `/webhook` path (e.g., `https://xxxx-xx-xxx-xxx-xx.ngrok-free.app/webhook`). 93 | 94 | ### 3. Production Deployment (using Gunicorn) 95 | 96 | For production, it's recommended to use a proper WSGI server like Gunicorn instead of Flask's built-in development server. 97 | 98 | a. **Install Gunicorn:** 99 | 100 | ```bash 101 | pip3 install gunicorn 102 | ``` 103 | 104 | b. **Run the application with Gunicorn:** 105 | Replace `script:app` with `your_filename:your_flask_app_instance_name` if you change them. 106 | 107 | ```bash 108 | gunicorn --workers 4 --bind 0.0.0.0:5001 script:app 109 | ``` 110 | 111 | - `--workers 4`: Adjust the number of worker processes based on your server's CPU cores (a common starting point is `2 * num_cores + 1`). 112 | - `--bind 0.0.0.0:5001`: Specifies the address and port Gunicorn should listen on. 113 | 114 | c. **Reverse Proxy (Recommended):** 115 | In a typical production setup, you would run Gunicorn behind a reverse proxy like Nginx or Apache. The reverse proxy would handle incoming HTTPS requests, SSL termination, static file serving (if any), and forward requests to Gunicorn. 116 | 117 | ## 🔄 WaSenderAPI Webhook Configuration 118 | 119 | - Log in to your WaSenderAPI dashboard. 120 | - Navigate to the session management section. 121 | - connect you phone number to the session. 122 | - Find the option to set or update the webhook URL. 123 | - Enter the publicly accessible URL where your Flask application's `/webhook` endpoint is running (e.g., your ngrok URL during development, or your production server's URL). 124 | - make sure you only select only **message_upsert**. 125 | - seve the changes. 126 | 127 | ## 📝 Customizing Your Bot's Personality 128 | 129 | The chatbot includes a customizable base prompt that defines the AI's persona and behavior. Edit the `persona.json` file to change how Gemini responds to messages, making the bot more formal, casual, informative, or conversational as needed for your use case. 130 | 131 | ```json 132 | { 133 | "name": "WhatsApp Assistant", 134 | "base_prompt": "You are a helpful and concise AI assistant replying in a WhatsApp chat...", 135 | "description": "You are a helpful WhatsApp assistant. Keep your responses concise..." 136 | } 137 | ``` 138 | 139 | ## 📊 Logging and Error Handling 140 | 141 | - The application uses Python's built-in `logging` module. 142 | - Logs are printed to the console by default. 143 | - Log format: `%(asctime)s - %(levelname)s - %(message)s`. 144 | - Unhandled exceptions are also logged. 145 | - **Important for Production:** Consider configuring logging to write to files, use a centralized logging service (e.g., ELK stack, Sentry, Datadog), and implement log rotation. 146 | 147 | ## 📚 WaSenderAPI Documentation 148 | 149 | Refer to the official WaSenderAPI documentation for the most up-to-date information on API endpoints, request/response formats, and webhook details: [https://wasenderapi.com/api-docs](https://wasenderapi.com/api-docs) 150 | 151 | ## 💡 Why This Solution? 152 | 153 | This chatbot offers an incredibly cost-effective way to deploy an AI-powered WhatsApp bot without the high costs typically associated with WhatsApp Business API. By combining WaSenderAPI's affordable $6/month subscription with Google's free Gemini API tier, you get a powerful, customizable chatbot solution at a fraction of the cost of enterprise alternatives. 154 | -------------------------------------------------------------------------------- /persona.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WhatsApp Assistant", 3 | "description": "I'm a friendly and helpful WhatsApp assistant. I provide concise, accurate information and can help with a variety of tasks. I'm designed to be conversational but efficient.", 4 | "base_prompt": "You are a helpful and concise AI assistant replying in a WhatsApp chat. Do not use Markdown formatting. Keep your answers short, friendly, and easy to read. If your response is longer than 3 lines, split it into multiple messages using \n every 3 lines. Each \n means a new WhatsApp message. Avoid long paragraphs or unnecessary explanations." 5 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | requests 3 | python-dotenv 4 | google-generativeai -------------------------------------------------------------------------------- /script.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import requests 4 | from flask import Flask, request, jsonify 5 | from dotenv import load_dotenv 6 | import google.generativeai as genai 7 | import json 8 | 9 | load_dotenv() 10 | 11 | app = Flask(__name__) 12 | 13 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 14 | 15 | # Directory for storing conversations 16 | CONVERSATIONS_DIR = 'conversations' 17 | if not os.path.exists(CONVERSATIONS_DIR): 18 | os.makedirs(CONVERSATIONS_DIR) 19 | logging.info(f"Created conversations directory at {CONVERSATIONS_DIR}") 20 | 21 | GEMINI_API_KEY = os.getenv('GEMINI_API_KEY') 22 | WASENDER_API_TOKEN = os.getenv('WASENDER_API_TOKEN') 23 | WASENDER_API_URL = "https://wasenderapi.com/api/send-message" 24 | 25 | if GEMINI_API_KEY: 26 | genai.configure(api_key=GEMINI_API_KEY) 27 | else: 28 | logging.error("GEMINI_API_KEY not found in environment variables. The application might not work correctly.") 29 | 30 | @app.errorhandler(Exception) 31 | def handle_global_exception(e): 32 | """Global handler for unhandled exceptions.""" 33 | logging.error(f"Unhandled Exception: {e}", exc_info=True) 34 | return jsonify(status='error', message='An internal server error occurred.'), 500 35 | 36 | # --- Load Persona --- 37 | PERSONA_FILE_PATH = 'persona.json' 38 | PERSONA_DESCRIPTION = "You are a helpful assistant." # Default persona 39 | PERSONA_NAME = "Assistant" 40 | BASE_PROMPT = "You are a helpful and concise AI assistant replying in a WhatsApp chat. Do not use Markdown formatting. Keep your answers short, friendly, and easy to read. If your response is longer than 3 lines, split it into multiple messages using \n every 3 lines. Each \n means a new WhatsApp message. Avoid long paragraphs or unnecessary explanations." 41 | 42 | try: 43 | with open(PERSONA_FILE_PATH, 'r') as f: 44 | persona_data = json.load(f) 45 | custom_description = persona_data.get('description', PERSONA_DESCRIPTION) 46 | base_prompt = persona_data.get('base_prompt', BASE_PROMPT) 47 | PERSONA_DESCRIPTION = f"{base_prompt}\n\n{custom_description}" 48 | PERSONA_NAME = persona_data.get('name', PERSONA_NAME) 49 | logging.info(f"Successfully loaded persona: {PERSONA_NAME}") 50 | except FileNotFoundError: 51 | logging.warning(f"Persona file not found at {PERSONA_FILE_PATH}. Using default persona.") 52 | except json.JSONDecodeError: 53 | logging.error(f"Error decoding JSON from {PERSONA_FILE_PATH}. Using default persona.") 54 | except Exception as e: 55 | logging.error(f"An unexpected error occurred while loading persona: {e}. Using default persona.") 56 | # --- End Load Persona --- 57 | 58 | def load_conversation_history(user_id): 59 | """Loads conversation history for a given user_id.""" 60 | file_path = os.path.join(CONVERSATIONS_DIR, f"{user_id}.json") 61 | try: 62 | with open(file_path, 'r') as f: 63 | history = json.load(f) 64 | # Ensure history is a list of dictionaries (pairs of user/assistant messages) 65 | if isinstance(history, list) and all(isinstance(item, dict) and 'role' in item and 'parts' in item for item in history): 66 | return history 67 | else: 68 | logging.warning(f"Invalid history format in {file_path}. Starting fresh.") 69 | return [] 70 | except FileNotFoundError: 71 | return [] 72 | except json.JSONDecodeError: 73 | logging.error(f"Error decoding JSON from {file_path}. Starting fresh.") 74 | return [] 75 | except Exception as e: 76 | logging.error(f"Unexpected error loading history from {file_path}: {e}") 77 | return [] 78 | 79 | def save_conversation_history(user_id, history): 80 | """Saves conversation history for a given user_id.""" 81 | file_path = os.path.join(CONVERSATIONS_DIR, f"{user_id}.json") 82 | try: 83 | with open(file_path, 'w') as f: 84 | json.dump(history, f, indent=2) 85 | except Exception as e: 86 | logging.error(f"Error saving conversation history to {file_path}: {e}") 87 | def split_message(text, max_lines=3, max_chars_per_line=100): 88 | """Split a long message into smaller chunks for better WhatsApp readability.""" 89 | # First split by existing newlines 90 | paragraphs = text.split('\\n') 91 | chunks = [] 92 | current_chunk = [] 93 | current_line_count = 0 94 | 95 | for paragraph in paragraphs: 96 | # Split long paragraphs into smaller lines 97 | if len(paragraph) > max_chars_per_line: 98 | words = paragraph.split() 99 | current_line = [] 100 | current_length = 0 101 | 102 | for word in words: 103 | if current_length + len(word) + 1 <= max_chars_per_line: 104 | current_line.append(word) 105 | current_length += len(word) + 1 106 | else: 107 | if current_line_count >= max_lines: 108 | chunks.append('\n'.join(current_chunk)) 109 | current_chunk = [] 110 | current_line_count = 0 111 | current_chunk.append(' '.join(current_line)) 112 | current_line_count += 1 113 | current_line = [word] 114 | current_length = len(word) 115 | 116 | if current_line: 117 | if current_line_count >= max_lines: 118 | chunks.append('\n'.join(current_chunk)) 119 | current_chunk = [] 120 | current_line_count = 0 121 | current_chunk.append(' '.join(current_line)) 122 | current_line_count += 1 123 | else: 124 | if current_line_count >= max_lines: 125 | chunks.append('\n'.join(current_chunk)) 126 | current_chunk = [] 127 | current_line_count = 0 128 | current_chunk.append(paragraph) 129 | current_line_count += 1 130 | 131 | if current_chunk: 132 | chunks.append('\n'.join(current_chunk)) 133 | 134 | return chunks 135 | 136 | def get_gemini_response(message_text, conversation_history=None): 137 | """Generates a response from Gemini using the google-generativeai library, including conversation history.""" 138 | if not GEMINI_API_KEY: 139 | logging.error("Gemini API key is not configured.") 140 | return "Sorry, I'm having trouble connecting to my brain right now (API key issue)." 141 | 142 | try: 143 | # Using Gemini 2.0 Flash model with system instruction for persona 144 | model_name = 'gemini-2.0-flash' 145 | model = genai.GenerativeModel(model_name, system_instruction=PERSONA_DESCRIPTION) 146 | 147 | logging.info(f"Sending prompt to Gemini (system persona active): {message_text[:200]}...") 148 | 149 | if conversation_history: 150 | # Use chat history if available 151 | chat = model.start_chat(history=conversation_history) 152 | response = chat.send_message(message_text) 153 | else: 154 | # For first message with no history 155 | response = model.generate_content(message_text) 156 | 157 | # Extract the text from the response 158 | if response and hasattr(response, 'text') and response.text: 159 | return response.text.strip() 160 | elif response and response.candidates: 161 | # Fallback if .text is not directly available but candidates are 162 | try: 163 | return response.candidates[0].content.parts[0].text.strip() 164 | except (IndexError, AttributeError, KeyError) as e: 165 | logging.error(f"Error parsing Gemini response candidates: {e}. Response: {response}") 166 | return "I received an unusual response structure from Gemini. Please try again." 167 | else: 168 | logging.error(f"Gemini API (google-generativeai) returned an empty or unexpected response: {response}") 169 | return "I received an empty or unexpected response from Gemini. Please try again." 170 | 171 | except Exception as e: 172 | logging.error(f"Error calling Gemini API with google-generativeai: {e}", exc_info=True) 173 | return "I'm having trouble processing that request with my AI brain. Please try again later." 174 | 175 | def send_whatsapp_message(recipient_number, message_content, message_type='text', media_url=None): 176 | """Sends a message via WaSenderAPI. Supports text and media messages.""" 177 | if not WASENDER_API_TOKEN: 178 | logging.error("WaSender API token is not set. Please check .env file.") 179 | return False 180 | 181 | headers = { 182 | 'Authorization': f'Bearer {WASENDER_API_TOKEN}', 183 | 'Content-Type': 'application/json' 184 | } 185 | 186 | # Sanitize recipient_number to remove "@s.whatsapp.net" 187 | if recipient_number and "@s.whatsapp.net" in recipient_number: 188 | formatted_recipient_number = recipient_number.split('@')[0] 189 | else: 190 | formatted_recipient_number = recipient_number 191 | 192 | payload = { 193 | 'to': formatted_recipient_number 194 | } 195 | 196 | if message_type == 'text': 197 | payload['text'] = message_content 198 | elif message_type == 'image' and media_url: 199 | payload['imageUrl'] = media_url 200 | if message_content: 201 | payload['text'] = message_content 202 | elif message_type == 'video' and media_url: 203 | payload['videoUrl'] = media_url 204 | if message_content: 205 | payload['text'] = message_content 206 | elif message_type == 'audio' and media_url: 207 | payload['audioUrl'] = media_url 208 | elif message_type == 'document' and media_url: 209 | payload['documentUrl'] = media_url 210 | if message_content: 211 | payload['text'] = message_content 212 | else: 213 | if message_type != 'text': 214 | logging.error(f"Media URL is required for message type '{message_type}'.") 215 | return False 216 | logging.error(f"Unsupported message type or missing content/media_url: {message_type}") 217 | return False 218 | 219 | logging.debug(f"Attempting to send WhatsApp message. Payload: {payload}") 220 | 221 | try: 222 | response = requests.post(WASENDER_API_URL, headers=headers, json=payload, timeout=20) 223 | response.raise_for_status() 224 | logging.info(f"Message sent to {recipient_number}. Response: {response.json()}") 225 | return True 226 | except requests.exceptions.RequestException as e: 227 | status_code = e.response.status_code if e.response is not None else "N/A" 228 | response_text = e.response.text if e.response is not None else "N/A" 229 | logging.error(f"Error sending WhatsApp message to {recipient_number} (Status: {status_code}): {e}. Response: {response_text}") 230 | if status_code == 422: 231 | logging.error("WaSenderAPI 422 Error: This often means an issue with the payload (e.g., device_id, 'to' format, or message content/URL). Check the payload logged above and WaSenderAPI docs.") 232 | return False 233 | except Exception as e: 234 | logging.error(f"An unexpected error occurred while sending WhatsApp message: {e}") 235 | return False 236 | 237 | @app.route('/webhook', methods=['POST']) 238 | def webhook(): 239 | """Handles incoming WhatsApp messages via webhook.""" 240 | data = request.json 241 | logging.info(f"Received webhook data (first 200 chars): {str(data)[:200]}") 242 | 243 | try: 244 | if data.get('event') == 'messages.upsert' and data.get('data') and data['data'].get('messages'): 245 | message_info = data['data']['messages'] 246 | 247 | # Check if it's a message sent by the bot itself 248 | if message_info.get('key', {}).get('fromMe'): 249 | logging.info(f"Ignoring self-sent message: {message_info.get('key', {}).get('id')}") 250 | return jsonify({'status': 'success', 'message': 'Self-sent message ignored'}), 200 251 | 252 | sender_number = message_info.get('key', {}).get('remoteJid') 253 | 254 | incoming_message_text = None 255 | message_type = 'unknown' 256 | 257 | # Extract message content based on message structure 258 | if message_info.get('message'): 259 | msg_content_obj = message_info['message'] 260 | if 'conversation' in msg_content_obj: 261 | incoming_message_text = msg_content_obj['conversation'] 262 | message_type = 'text' 263 | elif 'extendedTextMessage' in msg_content_obj and 'text' in msg_content_obj['extendedTextMessage']: 264 | incoming_message_text = msg_content_obj['extendedTextMessage']['text'] 265 | message_type = 'text' 266 | 267 | if message_info.get('messageStubType'): 268 | stub_params = message_info.get('messageStubParameters', []) 269 | logging.info(f"Received system message of type {message_info['messageStubType']} from {sender_number}. Stub params: {stub_params}") 270 | return jsonify({'status': 'success', 'message': 'System message processed'}), 200 271 | 272 | if not sender_number: 273 | logging.warning("Webhook received message without sender information.") 274 | return jsonify({'status': 'error', 'message': 'Incomplete sender data'}), 400 275 | 276 | # Sanitize sender_number to use as a filename 277 | safe_sender_id = "".join(c if c.isalnum() else '_' for c in sender_number) 278 | 279 | if message_type == 'text' and incoming_message_text: 280 | logging.info(f"Processing text message from {sender_number} ({safe_sender_id}): {incoming_message_text}") 281 | 282 | # Load conversation history 283 | conversation_history = load_conversation_history(safe_sender_id) 284 | 285 | # Get Gemini's reply, passing the history 286 | gemini_reply = get_gemini_response(incoming_message_text, conversation_history) 287 | 288 | if gemini_reply: 289 | # Split the response into chunks and send them sequentially 290 | message_chunks = split_message(gemini_reply) 291 | for chunk in message_chunks: 292 | if not send_whatsapp_message(sender_number, chunk, message_type='text'): 293 | logging.error(f"Failed to send message chunk to {sender_number}") 294 | break 295 | # Delay between messages 296 | import random 297 | import time 298 | if i < len(message_chunks) - 1: 299 | delay = random.uniform(0.55, 1.5) 300 | time.sleep(delay) 301 | # Save the new exchange to history 302 | # Ensure history format is compatible with genai: list of {'role': 'user'/'model', 'parts': ['text']} 303 | conversation_history.append({'role': 'user', 'parts': [incoming_message_text]}) 304 | conversation_history.append({'role': 'model', 'parts': [gemini_reply]}) 305 | save_conversation_history(safe_sender_id, conversation_history) 306 | elif incoming_message_text: 307 | logging.info(f"Received '{message_type}' message from {sender_number}. No text content. Full data: {message_info}") 308 | elif message_type != 'unknown': 309 | logging.info(f"Received '{message_type}' message from {sender_number}. No text content. Full data: {message_info}") 310 | else: 311 | logging.warning(f"Received unhandled or incomplete message from {sender_number}. Data: {message_info}") 312 | elif data.get('event'): 313 | logging.info(f"Received event '{data.get('event')}' which is not 'messages.upsert'. Data: {str(data)[:200]}") 314 | 315 | return jsonify({'status': 'success'}), 200 316 | except Exception as e: 317 | logging.error(f"Error processing webhook: {e}") 318 | return jsonify({'status': 'error', 'message': 'Internal server error'}), 500 319 | 320 | if __name__ == '__main__': 321 | # For development with webhook testing via ngrok 322 | app.run(debug=True, port=5001, host='0.0.0.0') 323 | --------------------------------------------------------------------------------