├── requirements.txt ├── .env.example ├── README.md ├── LICENSE └── whatsapp_bot.py /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.34.69 2 | python-dotenv==1.0.1 3 | requests==2.31.0 4 | python-tempfile==0.1.0 5 | runpod==1.5.0 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_SQS_QUEUE=your-sqs-queue-url 2 | WHATSAPP_API_TOKEN=your-whatsapp-api-token 3 | WHATSAPP_PHONE_NUMBER_ID=your-phone-number-id 4 | RUNPOD_API_KEY=your-runpod-api-key 5 | RUNPOD_ENDPOINT=your-runpod-endpoint 6 | POSTHOG_API_KEY=your-posthog-api-key 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WhatsApp Bot 2 | 3 | A simple bot that processes WhatsApp messages from an SQS queue and responds to them. 4 | 5 | ## Setup 6 | 7 | 1. Install dependencies: 8 | ```bash 9 | pip install -r requirements.txt 10 | ``` 11 | 12 | 2. Copy the environment template and fill in your values: 13 | ```bash 14 | cp .env.example .env 15 | ``` 16 | 17 | 3. Edit the `.env` file with your credentials: 18 | - `APP_SQS_QUEUE`: Your SQS queue URL 19 | - `WHATSAPP_API_TOKEN`: Your WhatsApp Business API token 20 | - `WHATSAPP_PHONE_NUMBER_ID`: Your WhatsApp phone number ID 21 | 22 | ## Usage 23 | 24 | Run the bot: 25 | ```bash 26 | python whatsapp_bot.py 27 | ``` 28 | 29 | The bot will: 30 | 1. Listen for messages in the specified SQS queue 31 | 2. Mark received messages as read 32 | 3. Reply with a transcription of audio messages. 33 | 4. Delete processed messages from the queue 34 | 35 | ## Error Handling 36 | 37 | The bot includes error handling for: 38 | - SQS connection issues 39 | - WhatsApp API errors 40 | - Message processing errors 41 | 42 | Errors are logged to the console but won't stop the bot from running. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ivrit.ai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /whatsapp_bot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import boto3 4 | import requests 5 | import tempfile 6 | import base64 7 | import runpod 8 | from dotenv import load_dotenv 9 | from pathlib import Path 10 | import subprocess 11 | import logging 12 | from datetime import datetime, timezone 13 | import threading 14 | import time 15 | import posthog 16 | import uuid 17 | import random 18 | import argparse 19 | from pydub import AudioSegment 20 | import collections 21 | import phonenumbers 22 | from phonenumbers import region_code_for_number, country_code_for_region 23 | 24 | # Load environment variables 25 | load_dotenv() 26 | 27 | # Configure logging with a custom formatter that includes file name and line number 28 | class FileLineFormatter(logging.Formatter): 29 | def format(self, record): 30 | # Get the relative path of the file 31 | filepath = record.pathname 32 | filename = os.path.basename(filepath) 33 | 34 | # Format the log message with file name, line number, thread name, and message 35 | return f"{filename:<20}:{record.lineno:<4} {self.formatTime(record)} [{record.threadName}] {record.levelname} - {record.getMessage()}" 36 | 37 | # Create a file handler that only writes to file 38 | file_handler = logging.FileHandler('whatsapp_bot.log') 39 | file_handler.setFormatter(FileLineFormatter()) 40 | 41 | # Create a logger 42 | logger = logging.getLogger('whatsapp_bot') 43 | logger.setLevel(logging.INFO) 44 | logger.addHandler(file_handler) 45 | # Prevent propagation to the root logger (which would output to console) 46 | logger.propagate = False 47 | 48 | ph = None 49 | if "POSTHOG_API_KEY" in os.environ: 50 | ph = posthog.Posthog(project_api_key=os.environ["POSTHOG_API_KEY"], host="https://us.i.posthog.com") 51 | 52 | def capture_event(distinct_id, event, props=None): 53 | global ph 54 | 55 | if not ph: 56 | return 57 | 58 | props = {} if not props else props 59 | props["source"] = "eliezer.ivrit.ai" 60 | 61 | ph.capture(distinct_id=distinct_id, event=event, properties=props) 62 | 63 | class LeakyBucket: 64 | def __init__(self, max_messages_per_hour, max_minutes_per_hour): 65 | self.max_messages = max_messages_per_hour 66 | self.max_minutes = max_minutes_per_hour * 60 # Convert to seconds 67 | self.messages_remaining = max_messages_per_hour 68 | self.seconds_remaining = max_minutes_per_hour * 60 69 | self.last_update = time.time() 70 | 71 | # Calculate fill rates (per second) 72 | self.message_fill_rate = max_messages_per_hour / 3600 73 | self.time_fill_rate = self.max_minutes / 3600 74 | 75 | def update(self): 76 | """Update bucket based on elapsed time.""" 77 | now = time.time() 78 | elapsed = now - self.last_update 79 | self.last_update = now 80 | 81 | # Add resources based on fill rate 82 | self.messages_remaining = min(self.max_messages, self.messages_remaining + self.message_fill_rate * elapsed) 83 | self.seconds_remaining = min(self.max_minutes, self.seconds_remaining + self.time_fill_rate * elapsed) 84 | 85 | def can_transcribe(self, duration_seconds): 86 | """Check if transcription is allowed.""" 87 | self.update() 88 | return self.messages_remaining >= 1 and self.seconds_remaining >= duration_seconds 89 | 90 | def consume(self, duration_seconds): 91 | """Consume resources for transcription.""" 92 | self.update() 93 | self.messages_remaining -= 1 94 | self.seconds_remaining -= duration_seconds 95 | return self.messages_remaining > 0 and self.seconds_remaining > 0 96 | 97 | def is_full(self): 98 | """Check if the bucket is full (or nearly full).""" 99 | self.update() 100 | return (self.messages_remaining >= self.max_messages * 0.95 and 101 | self.seconds_remaining >= self.max_minutes * 0.95) 102 | 103 | class WhatsAppBot: 104 | def __init__(self, nudge_interval, user_max_messages_per_hour, user_max_minutes_per_hour, cleanup_frequency): 105 | self.sqs = boto3.client('sqs') 106 | self.queue_url = os.getenv('APP_SQS_QUEUE') 107 | self.api_token = os.getenv('WHATSAPP_API_TOKEN') 108 | self.phone_number_id = os.getenv('WHATSAPP_PHONE_NUMBER_ID') 109 | self.api_version = 'v22.0' 110 | self.base_url = f'https://graph.facebook.com/{self.api_version}' 111 | 112 | # Initialize RunPod 113 | runpod.api_key = os.getenv('RUNPOD_API_KEY') 114 | self.runpod_endpoint = runpod.Endpoint(os.getenv('RUNPOD_ENDPOINT_ID')) 115 | 116 | # Thread control 117 | self.stop_event = threading.Event() 118 | self.worker_threads = [] 119 | self.num_workers = 10 120 | 121 | # Logger 122 | self.logger = logging.getLogger('whatsapp_bot') 123 | 124 | # Nudge interval for donation messages 125 | self.nudge_interval = nudge_interval 126 | 127 | # Transcription counter and duration tracker 128 | self.transcription_counter = 0 129 | self.total_duration = 0 130 | self.counter_lock = threading.Lock() 131 | 132 | # Leaky bucket rate limiter settings 133 | self.user_max_messages_per_hour = user_max_messages_per_hour 134 | self.user_max_minutes_per_hour = user_max_minutes_per_hour 135 | self.cleanup_frequency = cleanup_frequency 136 | 137 | # User buckets with lock for thread safety 138 | self.user_buckets = {} 139 | self.bucket_lock = threading.Lock() 140 | 141 | def is_allowed_region(self, phone_number): 142 | """Check if the phone number is from an allowed region (Israeli, American/Canadian, or European).""" 143 | try: 144 | # Parse the phone number 145 | parsed_number = phonenumbers.parse("+" + phone_number) 146 | 147 | # Get the region code (e.g., 'US', 'IL', 'GB') 148 | region = region_code_for_number(parsed_number) 149 | 150 | # List of allowed regions - North America, Europe, and Israel combined 151 | allowed_regions = [ 152 | # North America 153 | 'US', 'CA', 154 | 155 | # Europe (EU countries and other European countries) 156 | 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 157 | 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 158 | 'SE', 'GB', 'IS', 'LI', 'NO', 'CH', 'AL', 'AD', 'BA', 'BY', 'FO', 'GI', 'VA', 159 | 'IM', 'JE', 'XK', 'MK', 'MD', 'MC', 'ME', 'RU', 'SM', 'RS', 'SJ', 'TR', 'UA', 160 | 161 | # Israel 162 | 'IL' 163 | ] 164 | 165 | # Check if the region is in the allowed list 166 | return region in allowed_regions 167 | 168 | except phonenumbers.phonenumberutil.NumberParseException: 169 | self.logger.error(f"Failed to parse phone number: {phone_number}") 170 | return False 171 | 172 | def mark_message_as_read(self, message_id): 173 | """Mark a WhatsApp message as read.""" 174 | url = f'{self.base_url}/{self.phone_number_id}/messages' 175 | headers = { 176 | 'Authorization': f'Bearer {self.api_token}', 177 | 'Content-Type': 'application/json' 178 | } 179 | data = { 180 | 'messaging_product': 'whatsapp', 181 | 'status': 'read', 182 | 'message_id': message_id 183 | } 184 | response = requests.post(url, headers=headers, json=data) 185 | try: 186 | response.raise_for_status() 187 | except requests.exceptions.HTTPError as e: 188 | self.logger.error(f"Error marking message {message_id} as read: Status {response.status_code}, Response: {response.text}") 189 | print(f"Error marking message {message_id} as read: Status {response.status_code}, Response: {response.text}") 190 | raise 191 | return response.json() 192 | 193 | def send_reply(self, to_number, message_id, text): 194 | """Send a reply message with quote. Splits long messages into chunks of 4000 characters.""" 195 | # Define the maximum message length. 196 | # WhatsApp's maximu message length is 4096, so keep some distance from that limit. 197 | MAX_MESSAGE_LENGTH = 4000 198 | 199 | # If the message is shorter than the limit, send it as is 200 | if len(text) <= MAX_MESSAGE_LENGTH: 201 | return self._send_single_message(to_number, message_id, text) 202 | 203 | # Split the message into chunks 204 | message_chunks = [] 205 | for i in range(0, len(text), MAX_MESSAGE_LENGTH): 206 | message_chunks.append(text[i:i + MAX_MESSAGE_LENGTH]) 207 | 208 | # Send each chunk as a separate message 209 | responses = [] 210 | for i, chunk in enumerate(message_chunks): 211 | # Only the first message should be in reply to the original message 212 | chunk_message_id = message_id if i == 0 else None 213 | 214 | # Add part indicator if there are multiple chunks 215 | if len(message_chunks) > 1: 216 | chunk_with_indicator = f"[{i+1}/{len(message_chunks)}]\n{chunk}" 217 | else: 218 | chunk_with_indicator = chunk 219 | 220 | response = self._send_single_message(to_number, chunk_message_id, chunk_with_indicator) 221 | responses.append(response) 222 | 223 | # Add a small delay between messages to prevent rate limiting 224 | if i < len(message_chunks) - 1: 225 | time.sleep(0.1) 226 | 227 | # Return the response from the last chunk 228 | return responses[-1] 229 | 230 | def send_typing_indicator(self, message_id): 231 | """Send a typing indicator to show the user that you're preparing a response.""" 232 | url = f'{self.base_url}/{self.phone_number_id}/messages' 233 | headers = { 234 | 'Authorization': f'Bearer {self.api_token}', 235 | 'Content-Type': 'application/json' 236 | } 237 | data = { 238 | 'messaging_product': 'whatsapp', 239 | 'status': 'read', 240 | 'message_id': message_id, 241 | 'typing_indicator': { 242 | 'type': 'text' 243 | } 244 | } 245 | 246 | response = requests.post(url, headers=headers, json=data) 247 | try: 248 | response.raise_for_status() 249 | except requests.exceptions.HTTPError as e: 250 | self.logger.error(f"Error sending typing indicator: Status {response.status_code}, Response: {response.text}") 251 | print(f"Error sending typing indicator: Status {response.status_code}, Response: {response.text}") 252 | raise 253 | 254 | return response.json() 255 | 256 | def _send_single_message(self, to_number, message_id, text): 257 | """Send a single WhatsApp message.""" 258 | url = f'{self.base_url}/{self.phone_number_id}/messages' 259 | headers = { 260 | 'Authorization': f'Bearer {self.api_token}', 261 | 'Content-Type': 'application/json' 262 | } 263 | data = { 264 | 'messaging_product': 'whatsapp', 265 | 'recipient_type': 'individual', 266 | 'to': to_number, 267 | 'type': 'text', 268 | 'text': { 269 | 'body': text 270 | }, 271 | } 272 | 273 | if message_id: 274 | data['context'] = { 'message_id': message_id } 275 | 276 | response = requests.post(url, headers=headers, json=data) 277 | try: 278 | response.raise_for_status() 279 | except requests.exceptions.HTTPError as e: 280 | self.logger.error(f"Error sending message to {to_number}: Status {response.status_code}, Response: {response.text}") 281 | print(f"Error sending message to {to_number}: Status {response.status_code}, Response: {response.text}") 282 | raise 283 | return response.json() 284 | 285 | def download_audio(self, media_id): 286 | """Download audio file from WhatsApp.""" 287 | # First, get the media URL 288 | url = f'{self.base_url}/{media_id}' 289 | headers = { 290 | 'Authorization': f'Bearer {self.api_token}' 291 | } 292 | response = requests.get(url, headers=headers) 293 | response.raise_for_status() 294 | media_url = response.json()['url'] 295 | 296 | # Download the actual media file 297 | response = requests.get(media_url, headers=headers) 298 | response.raise_for_status() 299 | 300 | # Create a temporary file with .ogg extension (WhatsApp voice messages are in OGG format) 301 | temp_file = tempfile.NamedTemporaryFile(suffix='.ogg', delete=False) 302 | temp_file.write(response.content) 303 | temp_file.close() 304 | 305 | return temp_file.name 306 | 307 | def transcribe_audio(self, audio_path): 308 | """Transcribe audio using RunPod.""" 309 | try: 310 | # Read the audio file 311 | with open(audio_path, 'rb') as audio_file: 312 | audio_data = audio_file.read() 313 | 314 | # Encode the audio data as base64 315 | encoded_data = base64.b64encode(audio_data).decode('utf-8') 316 | 317 | # Prepare the payload for RunPod 318 | payload = { 319 | 'type': 'blob', 320 | 'data': encoded_data, 321 | 'model': 'ivrit-ai/whisper-large-v3-turbo_20250403_rc0-ct2', 322 | 'engine': 'faster-whisper' 323 | } 324 | 325 | # Run the transcription. 326 | # Give it a few tries in case the server fails for any reason. 327 | RUNPOD_RETRY = 3 328 | for i in range(RUNPOD_RETRY): 329 | result = self.runpod_endpoint.run_sync(payload) 330 | if result: 331 | break 332 | 333 | # Extract the transcription from the result 334 | if len(result) == 1 and 'result' in result[0]: 335 | text_result = '\n'.join([item['text'].strip() for item in result[0]['result']]) 336 | return text_result 337 | else: 338 | print(f"Unexpected RunPod response format: {result}") 339 | return "לא הצלחתי להבין את ההודעה הקולית." 340 | except Exception as e: 341 | print(f"Error transcribing audio: {str(e)}") 342 | return "אירעה שגיאה בעיבוד ההודעה הקולית." 343 | 344 | def check_audio_duration(self, audio_path): 345 | """Get the duration of an audio file in seconds using ffprobe.""" 346 | try: 347 | cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', audio_path] 348 | result = subprocess.run(cmd, capture_output=True, text=True) 349 | duration = float(result.stdout.strip()) 350 | return duration 351 | except Exception as e: 352 | self.logger.error(f"Error checking audio duration: {str(e)}") 353 | return None 354 | 355 | def process_audio_message(self, audio_path): 356 | """Process the audio message and generate a response.""" 357 | try: 358 | # Transcribe the audio 359 | try: 360 | transcription = self.transcribe_audio(audio_path) 361 | if not transcription or transcription.strip() == "": 362 | self.logger.warning("Transcription returned empty text") 363 | return "התמלול לא החזיר טקסט. ייתכן שההקלטה שקטה מדי." 364 | return transcription 365 | except Exception as e: 366 | self.logger.error(f"Transcription failed: {str(e)}") 367 | return "אירעה שגיאה בתמלול ההקלטה." 368 | finally: 369 | # Clean up the temporary file 370 | if os.path.exists(audio_path): 371 | os.unlink(audio_path) 372 | 373 | def convert_document_to_mp3(self, document_id): 374 | """Convert a document to MP3 format.""" 375 | try: 376 | # First, get the document URL 377 | url = f'{self.base_url}/{document_id}' 378 | headers = { 379 | 'Authorization': f'Bearer {self.api_token}' 380 | } 381 | response = requests.get(url, headers=headers) 382 | response.raise_for_status() 383 | media_url = response.json()['url'] 384 | 385 | # Download the document 386 | response = requests.get(media_url, headers=headers) 387 | response.raise_for_status() 388 | 389 | # Create temporary files 390 | temp_input = tempfile.NamedTemporaryFile(delete=False) 391 | temp_output = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) 392 | 393 | try: 394 | # Save the downloaded document 395 | temp_input.write(response.content) 396 | temp_input.close() 397 | 398 | # Convert to MP3 399 | audio = AudioSegment.from_file(temp_input.name) 400 | audio.export(temp_output.name, format="mp3") 401 | 402 | return temp_output.name 403 | finally: 404 | # Clean up the input file 405 | if os.path.exists(temp_input.name): 406 | os.unlink(temp_input.name) 407 | 408 | except Exception as e: 409 | self.logger.error(f"Error converting document to MP3: {str(e)}") 410 | # Clean up output file if it exists 411 | if 'temp_output' in locals() and os.path.exists(temp_output.name): 412 | os.unlink(temp_output.name) 413 | return None 414 | 415 | def send_periodic_donation_nudge(self, to_number): 416 | """Send a donation nudge message to the user with probability 1/nudge_interval.""" 417 | if random.random() >= (1.0 / self.nudge_interval): 418 | return 419 | 420 | self.logger.info(f"Sending donation nudge to {to_number}") 421 | donation_message = ( 422 | "אליעזר, וכל פרויקט ivrit.ai, אינם למטרות רווח ומבוססים על תרומות מהציבור.\n" 423 | "אם נהניתם מהשירות, נודה לתמיכה מכם, כאן: https://patreon.com/ivrit_ai\n\n" 424 | "תודה רבה! 🙏🏻" 425 | ) 426 | self.send_reply(to_number, None, donation_message) 427 | 428 | def get_user_bucket(self, user_id): 429 | """Get or create a user's leaky bucket.""" 430 | with self.bucket_lock: 431 | if user_id not in self.user_buckets: 432 | self.user_buckets[user_id] = LeakyBucket( 433 | self.user_max_messages_per_hour, 434 | self.user_max_minutes_per_hour 435 | ) 436 | return self.user_buckets[user_id] 437 | 438 | def cleanup_full_buckets(self): 439 | """Remove full buckets to avoid memory congestion.""" 440 | with self.bucket_lock: 441 | self.logger.info(f"Starting bucket cleanup, {len(self.user_buckets)} buckets in memory") 442 | 443 | full_buckets = [] 444 | for user_id, bucket in self.user_buckets.items(): 445 | if bucket.is_full(): 446 | full_buckets.append(user_id) 447 | 448 | for user_id in full_buckets: 449 | del self.user_buckets[user_id] 450 | 451 | if full_buckets: 452 | self.logger.info(f"Cleaned up {len(full_buckets)} full buckets") 453 | 454 | def process_message(self, message): 455 | """Process a single WhatsApp message.""" 456 | try: 457 | # Extract message details 458 | entry = message['entry'][0] 459 | changes = entry['changes'][0] 460 | value = changes['value'] 461 | 462 | # Check if this is a message event 463 | if 'messages' not in value: 464 | return True # Return True to delete from queue 465 | 466 | message_data = value['messages'][0] 467 | 468 | # Get message details 469 | from_number = message_data['from'] 470 | message_id = message_data['id'] 471 | job_id = str(uuid.uuid4()) 472 | 473 | # Log incoming message 474 | self.logger.info(f"Incoming message from {from_number}") 475 | 476 | # Check if the number is allowed 477 | if not self.is_allowed_region(from_number): 478 | self.logger.info(f"Rejecting non-allowed number: {from_number}") 479 | self.send_reply(from_number, message_id, "מצטערים, השירות זמין רק כרגע למספרי טלפון מישראל, אירופה וצפון אמריקה.") 480 | return True # Return True to delete from queue 481 | 482 | # Initialize event properties 483 | event_props = { 484 | "user": from_number, 485 | "type": message_data.get('type'), 486 | "job_id": job_id 487 | } 488 | 489 | # Capture message received event 490 | capture_event(from_number, "message-received", event_props) 491 | 492 | # Handle different message types 493 | message_type = message_data.get('type') 494 | audio_path = None 495 | 496 | # Mark message as read 497 | if message_type in ['audio', 'document', 'text']: 498 | self.mark_message_as_read(message_id) 499 | else: 500 | return True 501 | 502 | try: 503 | if message_type == 'audio': 504 | # Process audio message 505 | media_id = message_data['audio']['id'] 506 | audio_path = self.download_audio(media_id) 507 | elif message_type == 'document': 508 | # Try to convert document to MP3 509 | media_id = message_data['document']['id'] 510 | audio_path = self.convert_document_to_mp3(media_id) 511 | if audio_path: 512 | message_type = 'audio' # Update type for further processing 513 | else: 514 | self.logger.info(f"Ignoring non-voice message of type: {message_type} from {from_number}") 515 | self.send_reply(from_number, message_id, "נכון להיום אני יודע לתמלל הקלטות, לא מעבר לזה.") 516 | return True 517 | 518 | if not audio_path: 519 | self.logger.info(f"Could not process message of type: {message_type} from {from_number}") 520 | self.send_reply(from_number, message_id, "נכון להיום אני יודע לתמלל הקלטות, לא מעבר לזה.") 521 | return True 522 | 523 | # Process the audio file 524 | try: 525 | # Check audio duration first 526 | duration = self.check_audio_duration(audio_path) 527 | if duration is None: 528 | self.logger.error(f"Failed to get duration for audio from {from_number}") 529 | self.send_reply(from_number, message_id, "אירעה שגיאה בבדיקת אורך הקובץ.") 530 | return True 531 | 532 | # Log duration 533 | self.logger.info(f"Audio duration for {from_number}: {duration:.2f} seconds") 534 | event_props["audio_duration_seconds"] = duration 535 | 536 | # Check if audio is longer than 10 minutes (600 seconds) 537 | if duration > 600: 538 | self.logger.info(f"Audio from {from_number} too long: {duration:.2f} seconds") 539 | self.send_reply(from_number, message_id, "אני מתנצל, אך קיבלתי הנחיה שלא לתמלל קבצים שארוכים מ-10 דקות.") 540 | return True 541 | 542 | # Check rate limits using leaky bucket 543 | user_bucket = self.get_user_bucket(from_number) 544 | if not user_bucket.can_transcribe(duration): 545 | self.logger.info(f"Rate limit exceeded for {from_number}") 546 | 547 | # Calculate remaining time in minutes (approximate) 548 | if user_bucket.messages_remaining == 0: 549 | remaining_time = 1 / self.user_max_messages_per_hour 550 | else: 551 | # Must be time limit that's causing the issue 552 | remaining_time = (duration - user_bucket.seconds_remaining) / (self.user_max_minutes_per_hour * 60) 553 | 554 | remaining_minutes = max(1, int(remaining_time * 60)) 555 | 556 | # Capture rate limit hit event 557 | limit_hit_props = { 558 | "user": from_number, 559 | "messages_remaining": user_bucket.messages_remaining, 560 | "seconds_remaining": user_bucket.seconds_remaining, 561 | "requested_duration": duration, 562 | "job_id": job_id 563 | } 564 | capture_event(from_number, "rate-limit-hit", limit_hit_props) 565 | 566 | self.send_reply(from_number, message_id, 567 | f"מצטערים, אך הגעת למגבלת השימוש של השירות. " 568 | f"ניתן לנסות שוב בעוד כ-{remaining_minutes} דקות.") 569 | return True 570 | 571 | # If audio is valid, proceed with processing 572 | self.logger.info(f"Starting transcription for {from_number}") 573 | 574 | # Record start time for transcription 575 | self.send_typing_indicator(message_id) 576 | transcription_start = datetime.now(timezone.utc) 577 | 578 | response_text = self.process_audio_message(audio_path) 579 | 580 | # Calculate transcription time 581 | transcription_time = (datetime.now(timezone.utc) - transcription_start).total_seconds() 582 | 583 | # Consume from the user's bucket 584 | has_resources_left = user_bucket.consume(duration) 585 | 586 | # Increment counter and get current count 587 | with self.counter_lock: 588 | self.transcription_counter += 1 589 | self.total_duration += duration 590 | current_count = self.transcription_counter 591 | total_duration_minutes = self.total_duration / 60 592 | 593 | # Capture transcription event 594 | transcription_props = { 595 | "user": from_number, 596 | "audio_duration_seconds": duration, 597 | "transcription_seconds": transcription_time, 598 | "has_resources_left": has_resources_left, 599 | "job_id": job_id 600 | } 601 | capture_event(from_number, "transcribe-done", transcription_props) 602 | 603 | self.logger.info(f"Completed transcription #{current_count} for {from_number} (Total duration: {total_duration_minutes:.1f} minutes)") 604 | # We used to have an awesome speaking header silhouette. 605 | # Removed so people can copy-and-paste the transcription easily. 606 | #response_text = "\N{SPEAKING HEAD IN SILHOUETTE}\N{MEMO}: " + response_text 607 | self.send_reply(from_number, message_id, response_text) 608 | 609 | # Send donation nudge with probability 1/nudge_interval 610 | self.send_periodic_donation_nudge(from_number) 611 | 612 | # Perform deterministic cleanup after sending all messages 613 | if self.transcription_counter % self.cleanup_frequency == 0: 614 | self.cleanup_full_buckets() 615 | 616 | finally: 617 | # Ensure the audio file is deleted even if processing fails 618 | if audio_path and os.path.exists(audio_path): 619 | os.unlink(audio_path) 620 | 621 | return True 622 | except Exception as e: 623 | self.logger.error(f"Error processing message: {str(e)}") 624 | if audio_path and os.path.exists(audio_path): 625 | os.unlink(audio_path) 626 | return False 627 | except Exception as e: 628 | self.logger.error(f"Error processing message: {str(e)}") 629 | return False 630 | 631 | def worker(self, worker_id): 632 | """Worker thread function to poll SQS and process messages.""" 633 | thread_name = f"Worker-{worker_id}" 634 | # Set thread name 635 | threading.current_thread().name = thread_name 636 | 637 | self.logger.info("Starting worker thread") 638 | 639 | while not self.stop_event.is_set(): 640 | try: 641 | # Receive message from SQS 642 | response = self.sqs.receive_message( 643 | QueueUrl=self.queue_url, 644 | MaxNumberOfMessages=1, 645 | WaitTimeSeconds=20 646 | ) 647 | 648 | if 'Messages' in response: 649 | for message in response['Messages']: 650 | try: 651 | # Parse message body 652 | message_body = json.loads(message['Body']) 653 | 654 | # Process the message 655 | if self.process_message(message_body): 656 | # Delete message from queue if processed successfully 657 | self.sqs.delete_message( 658 | QueueUrl=self.queue_url, 659 | ReceiptHandle=message['ReceiptHandle'] 660 | ) 661 | except Exception as e: 662 | self.logger.error(f"Error processing message: {str(e)}") 663 | 664 | except Exception as e: 665 | self.logger.error(f"Error in worker thread: {str(e)}") 666 | time.sleep(5) # Wait a bit before retrying 667 | continue 668 | 669 | def run(self): 670 | """Start worker threads to poll SQS.""" 671 | # Set main thread name 672 | threading.current_thread().name = "Main" 673 | 674 | self.logger.info(f"Starting WhatsApp bot with {self.num_workers} workers...") 675 | 676 | # Start worker threads 677 | for i in range(self.num_workers): 678 | thread = threading.Thread( 679 | target=self.worker, 680 | args=(i,), 681 | name=f"Worker-{i}", 682 | daemon=True 683 | ) 684 | self.worker_threads.append(thread) 685 | thread.start() 686 | self.logger.info(f"Started thread {thread.name}") 687 | 688 | try: 689 | # Keep the main thread alive 690 | while not self.stop_event.is_set(): 691 | time.sleep(1) 692 | 693 | except KeyboardInterrupt: 694 | self.logger.info("Shutting down...") 695 | self.stop_event.set() 696 | 697 | # Wait for all threads to finish 698 | for thread in self.worker_threads: 699 | thread.join() 700 | 701 | self.logger.info("Shutdown complete") 702 | 703 | if __name__ == "__main__": 704 | # Parse command line arguments 705 | parser = argparse.ArgumentParser(description='WhatsApp Bot for audio transcription') 706 | parser.add_argument('--nudge-interval', type=int, default=100, help='Interval for donation nudges (1:N probability)') 707 | parser.add_argument('--user-max-messages-per-hour', type=float, default=10, help='Maximum messages per hour per user') 708 | parser.add_argument('--user-max-minutes-per-hour', type=float, default=20, help='Maximum audio minutes per hour per user') 709 | parser.add_argument('--cleanup-frequency', type=int, default=50, help='Perform bucket cleanup every N transcriptions') 710 | args = parser.parse_args() 711 | 712 | # Initialize and run the bot 713 | bot = WhatsAppBot( 714 | nudge_interval=args.nudge_interval, 715 | user_max_messages_per_hour=args.user_max_messages_per_hour, 716 | user_max_minutes_per_hour=args.user_max_minutes_per_hour, 717 | cleanup_frequency=args.cleanup_frequency 718 | ) 719 | bot.run() 720 | --------------------------------------------------------------------------------