agentle 0.9.4__py3-none-any.whl → 0.9.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. agentle/agents/agent.py +175 -10
  2. agentle/agents/agent_run_output.py +8 -1
  3. agentle/agents/apis/__init__.py +79 -6
  4. agentle/agents/apis/api.py +342 -73
  5. agentle/agents/apis/api_key_authentication.py +43 -0
  6. agentle/agents/apis/api_key_location.py +11 -0
  7. agentle/agents/apis/api_metrics.py +16 -0
  8. agentle/agents/apis/auth_type.py +17 -0
  9. agentle/agents/apis/authentication.py +32 -0
  10. agentle/agents/apis/authentication_base.py +42 -0
  11. agentle/agents/apis/authentication_config.py +117 -0
  12. agentle/agents/apis/basic_authentication.py +34 -0
  13. agentle/agents/apis/bearer_authentication.py +52 -0
  14. agentle/agents/apis/cache_strategy.py +12 -0
  15. agentle/agents/apis/circuit_breaker.py +69 -0
  16. agentle/agents/apis/circuit_breaker_error.py +7 -0
  17. agentle/agents/apis/circuit_breaker_state.py +11 -0
  18. agentle/agents/apis/endpoint.py +413 -254
  19. agentle/agents/apis/file_upload.py +23 -0
  20. agentle/agents/apis/hmac_authentication.py +56 -0
  21. agentle/agents/apis/no_authentication.py +27 -0
  22. agentle/agents/apis/oauth2_authentication.py +111 -0
  23. agentle/agents/apis/oauth2_grant_type.py +12 -0
  24. agentle/agents/apis/object_schema.py +86 -1
  25. agentle/agents/apis/params/__init__.py +10 -1
  26. agentle/agents/apis/params/boolean_param.py +44 -0
  27. agentle/agents/apis/params/number_param.py +56 -0
  28. agentle/agents/apis/rate_limit_error.py +7 -0
  29. agentle/agents/apis/rate_limiter.py +57 -0
  30. agentle/agents/apis/request_config.py +126 -4
  31. agentle/agents/apis/request_hook.py +16 -0
  32. agentle/agents/apis/response_cache.py +49 -0
  33. agentle/agents/apis/retry_strategy.py +12 -0
  34. agentle/agents/whatsapp/human_delay_calculator.py +462 -0
  35. agentle/agents/whatsapp/models/audio_message.py +6 -4
  36. agentle/agents/whatsapp/models/key.py +2 -2
  37. agentle/agents/whatsapp/models/whatsapp_bot_config.py +375 -21
  38. agentle/agents/whatsapp/models/whatsapp_response_base.py +31 -0
  39. agentle/agents/whatsapp/models/whatsapp_webhook_payload.py +5 -1
  40. agentle/agents/whatsapp/providers/base/whatsapp_provider.py +51 -0
  41. agentle/agents/whatsapp/providers/evolution/evolution_api_provider.py +237 -10
  42. agentle/agents/whatsapp/providers/meta/meta_whatsapp_provider.py +126 -0
  43. agentle/agents/whatsapp/v2/batch_processor_manager.py +4 -0
  44. agentle/agents/whatsapp/v2/bot_config.py +188 -0
  45. agentle/agents/whatsapp/v2/message_limit.py +9 -0
  46. agentle/agents/whatsapp/v2/payload.py +0 -0
  47. agentle/agents/whatsapp/v2/whatsapp_bot.py +13 -0
  48. agentle/agents/whatsapp/v2/whatsapp_cloud_api_provider.py +0 -0
  49. agentle/agents/whatsapp/v2/whatsapp_provider.py +0 -0
  50. agentle/agents/whatsapp/whatsapp_bot.py +827 -45
  51. agentle/generations/providers/google/adapters/generate_generate_content_response_to_generation_adapter.py +13 -10
  52. agentle/generations/providers/google/google_generation_provider.py +35 -5
  53. agentle/generations/providers/openrouter/_adapters/openrouter_message_to_generated_assistant_message_adapter.py +35 -1
  54. agentle/mcp/servers/stdio_mcp_server.py +23 -4
  55. agentle/parsing/parsers/docx.py +8 -0
  56. agentle/parsing/parsers/file_parser.py +4 -0
  57. agentle/parsing/parsers/pdf.py +7 -1
  58. agentle/storage/__init__.py +11 -0
  59. agentle/storage/file_storage_manager.py +44 -0
  60. agentle/storage/local_file_storage_manager.py +122 -0
  61. agentle/storage/s3_file_storage_manager.py +124 -0
  62. agentle/tts/audio_format.py +6 -0
  63. agentle/tts/elevenlabs_tts_provider.py +108 -0
  64. agentle/tts/output_format_type.py +26 -0
  65. agentle/tts/speech_config.py +14 -0
  66. agentle/tts/speech_result.py +15 -0
  67. agentle/tts/tts_provider.py +16 -0
  68. agentle/tts/voice_settings.py +30 -0
  69. agentle/utils/parse_streaming_json.py +39 -13
  70. agentle/voice_cloning/__init__.py +0 -0
  71. agentle/voice_cloning/voice_cloner.py +0 -0
  72. agentle/web/extractor.py +282 -148
  73. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/METADATA +1 -1
  74. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/RECORD +78 -39
  75. agentle/tts/real_time/definitions/audio_data.py +0 -20
  76. agentle/tts/real_time/definitions/speech_config.py +0 -27
  77. agentle/tts/real_time/definitions/speech_result.py +0 -14
  78. agentle/tts/real_time/definitions/tts_stream_chunk.py +0 -15
  79. agentle/tts/real_time/definitions/voice_gender.py +0 -9
  80. agentle/tts/real_time/definitions/voice_info.py +0 -18
  81. agentle/tts/real_time/real_time_speech_to_text_provider.py +0 -66
  82. /agentle/{tts/real_time → agents/whatsapp/v2}/__init__.py +0 -0
  83. /agentle/{tts/real_time/definitions/__init__.py → agents/whatsapp/v2/in_memory_batch_processor_manager.py} +0 -0
  84. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/WHEEL +0 -0
  85. {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/licenses/LICENSE +0 -0
@@ -33,6 +33,7 @@ from agentle.agents.whatsapp.models.whatsapp_document_message import (
33
33
  from agentle.agents.whatsapp.models.whatsapp_image_message import WhatsAppImageMessage
34
34
  from agentle.agents.whatsapp.models.whatsapp_media_message import WhatsAppMediaMessage
35
35
  from agentle.agents.whatsapp.models.whatsapp_message import WhatsAppMessage
36
+ from agentle.agents.whatsapp.models.whatsapp_response_base import WhatsAppResponseBase
36
37
  from agentle.agents.whatsapp.models.whatsapp_session import WhatsAppSession
37
38
  from agentle.agents.whatsapp.models.whatsapp_text_message import WhatsAppTextMessage
38
39
  from agentle.agents.whatsapp.models.whatsapp_video_message import WhatsAppVideoMessage
@@ -43,6 +44,7 @@ from agentle.agents.whatsapp.providers.base.whatsapp_provider import WhatsAppPro
43
44
  from agentle.agents.whatsapp.providers.evolution.evolution_api_provider import (
44
45
  EvolutionAPIProvider,
45
46
  )
47
+ from agentle.agents.whatsapp.human_delay_calculator import HumanDelayCalculator
46
48
  from agentle.generations.models.message_parts.file import FilePart
47
49
  from agentle.generations.models.message_parts.text import TextPart
48
50
  from agentle.generations.models.message_parts.tool_execution_suggestion import (
@@ -54,7 +56,8 @@ from agentle.generations.models.messages.generated_assistant_message import (
54
56
  from agentle.generations.models.messages.user_message import UserMessage
55
57
  from agentle.generations.tools.tool import Tool
56
58
  from agentle.generations.tools.tool_execution_result import ToolExecutionResult
57
-
59
+ from agentle.storage.file_storage_manager import FileStorageManager
60
+ from agentle.tts.tts_provider import TtsProvider
58
61
 
59
62
  if TYPE_CHECKING:
60
63
  from blacksheep import Application
@@ -126,19 +129,47 @@ class CallbackWithContext:
126
129
  context: dict[str, Any] = field(default_factory=dict)
127
130
 
128
131
 
129
- class WhatsAppBot(BaseModel):
132
+ class WhatsAppBot[T_Schema: WhatsAppResponseBase = WhatsAppResponseBase](BaseModel):
130
133
  """
131
134
  WhatsApp bot that wraps an Agentle agent with enhanced message batching and spam protection.
132
135
 
133
- Now uses the Agent's conversation store directly instead of managing contexts separately.
136
+ Now supports structured outputs through generic type parameter T_Schema.
137
+ The schema must extend WhatsAppResponseBase to ensure a 'response' field is always present.
138
+
139
+ Examples:
140
+ ```python
141
+ # Basic usage (no structured output)
142
+ agent = Agent(...)
143
+ bot = WhatsAppBot(agent=agent, provider=provider)
144
+
145
+ # With structured output
146
+ class MyResponse(WhatsAppResponseBase):
147
+ sentiment: Literal["happy", "sad", "neutral"]
148
+ urgency_level: int
149
+
150
+ agent = Agent[MyResponse](
151
+ response_schema=MyResponse,
152
+ instructions="Extract sentiment and urgency from the conversation..."
153
+ )
154
+ bot = WhatsAppBot[MyResponse](agent=agent, provider=provider)
155
+
156
+ # Access structured data in callbacks
157
+ async def my_callback(phone, chat_id, response, context):
158
+ if response and response.parsed:
159
+ print(f"Sentiment: {response.parsed.sentiment}")
160
+ print(f"Urgency: {response.parsed.urgency_level}")
161
+ # response.parsed.response is automatically sent to WhatsApp
162
+
163
+ bot.add_response_callback(my_callback)
164
+ ```
134
165
  """
135
166
 
136
- agent: Agent[Any]
167
+ agent: Agent[T_Schema]
137
168
  provider: WhatsAppProvider
169
+ tts_provider: TtsProvider | None = Field(default=None)
170
+ file_storage_manager: FileStorageManager | None = Field(default=None)
138
171
  config: WhatsAppBotConfig = Field(default_factory=WhatsAppBotConfig)
139
172
 
140
- # REMOVED: context_manager field - no longer needed
141
-
142
173
  _running: bool = PrivateAttr(default=False)
143
174
  _webhook_handlers: MutableSequence[Callable[..., Any]] = PrivateAttr(
144
175
  default_factory=list
@@ -153,6 +184,7 @@ class WhatsAppBot(BaseModel):
153
184
  _response_callbacks: MutableSequence[CallbackWithContext] = PrivateAttr(
154
185
  default_factory=list
155
186
  )
187
+ _delay_calculator: HumanDelayCalculator | None = PrivateAttr(default=None)
156
188
 
157
189
  model_config = ConfigDict(arbitrary_types_allowed=True)
158
190
 
@@ -164,6 +196,55 @@ class WhatsAppBot(BaseModel):
164
196
  + "Please set agent.conversation_store before creating WhatsAppBot."
165
197
  )
166
198
 
199
+ # Log configuration validation
200
+ validation_issues = self.config.validate_config()
201
+ if validation_issues:
202
+ logger.warning(
203
+ f"[CONFIG_VALIDATION] Configuration has {len(validation_issues)} validation issue(s):"
204
+ )
205
+ for issue in validation_issues:
206
+ logger.warning(f"[CONFIG_VALIDATION] - {issue}")
207
+ else:
208
+ logger.info("[CONFIG_VALIDATION] Configuration validation passed")
209
+
210
+ # Initialize delay calculator if human delays are enabled
211
+ if self.config.enable_human_delays:
212
+ logger.info(
213
+ "[DELAY_CONFIG] ═══════════ HUMAN-LIKE DELAYS ENABLED ═══════════"
214
+ )
215
+ logger.info(
216
+ "[DELAY_CONFIG] Read delay bounds: "
217
+ + f"[{self.config.min_read_delay_seconds:.2f}s - {self.config.max_read_delay_seconds:.2f}s]"
218
+ )
219
+ logger.info(
220
+ "[DELAY_CONFIG] Typing delay bounds: "
221
+ + f"[{self.config.min_typing_delay_seconds:.2f}s - {self.config.max_typing_delay_seconds:.2f}s]"
222
+ )
223
+ logger.info(
224
+ "[DELAY_CONFIG] Send delay bounds: "
225
+ + f"[{self.config.min_send_delay_seconds:.2f}s - {self.config.max_send_delay_seconds:.2f}s]"
226
+ )
227
+ logger.info(
228
+ "[DELAY_CONFIG] Delay behavior settings: "
229
+ + f"jitter_enabled={self.config.enable_delay_jitter}, "
230
+ + f"show_typing={self.config.show_typing_during_delay}, "
231
+ + f"batch_compression={self.config.batch_read_compression_factor:.2f}"
232
+ )
233
+
234
+ # Initialize delay calculator
235
+ self._delay_calculator = HumanDelayCalculator(self.config)
236
+ logger.info("[DELAY_CONFIG] Delay calculator initialized successfully")
237
+ logger.info(
238
+ "[DELAY_CONFIG] ═══════════════════════════════════════════════"
239
+ )
240
+ else:
241
+ logger.info(
242
+ "[DELAY_CONFIG] Human-like delays disabled (enable_human_delays=False)"
243
+ )
244
+ logger.debug(
245
+ "[DELAY_CONFIG] To enable delays, set enable_human_delays=True in WhatsAppBotConfig"
246
+ )
247
+
167
248
  def start(self) -> None:
168
249
  """Start the WhatsApp bot."""
169
250
  run_sync(self.start_async)
@@ -224,7 +305,53 @@ class WhatsAppBot(BaseModel):
224
305
  ) -> GeneratedAssistantMessage[Any] | None:
225
306
  """
226
307
  Handle incoming WhatsApp message with enhanced error handling and batching.
308
+
309
+ This is the main entry point for processing incoming WhatsApp messages. It handles
310
+ rate limiting, spam protection, message batching, and applies human-like delays
311
+ to simulate realistic behavior patterns.
312
+
313
+ Message Processing Flow:
314
+ 1. Retrieve or create user session
315
+ 2. Check rate limiting (if spam protection enabled)
316
+ 3. Apply read delay (if human delays enabled) - simulates reading time
317
+ 4. Mark message as read (if auto_read_messages enabled)
318
+ 5. Send welcome message (if first interaction)
319
+ 6. Process message (with batching if enabled) or immediately
320
+ 7. Return generated response
321
+
322
+ Human-Like Delays:
323
+ When enable_human_delays is True, this method applies a read delay before
324
+ marking the message as read. The delay simulates the time a human would take
325
+ to read and comprehend the incoming message, creating a realistic gap between
326
+ message receipt and read receipt.
327
+
328
+ For batched messages, a batch read delay is applied instead, which accounts
329
+ for reading multiple messages in sequence with compression for faster batch
330
+ reading.
331
+
332
+ Args:
333
+ message: The incoming WhatsApp message to process.
334
+ chat_id: Optional custom chat identifier for conversation tracking.
335
+ If not provided, uses the sender's phone number.
336
+
337
+ Returns:
338
+ Generated assistant response message, or None if processing failed or
339
+ was rate limited.
340
+
341
+ Raises:
342
+ Exceptions are caught and logged. User-facing errors trigger error messages.
343
+
344
+ Example:
345
+ >>> message = WhatsAppTextMessage(
346
+ ... from_number="1234567890",
347
+ ... text="Hello!",
348
+ ... id="msg_123"
349
+ ... )
350
+ >>> response = await bot.handle_message(message)
351
+ >>> if response:
352
+ ... print(f"Response: {response.text}")
227
353
  """
354
+
228
355
  logger.info("[MESSAGE_HANDLER] ═══════════ MESSAGE HANDLER ENTRY ═══════════")
229
356
  logger.info(
230
357
  f"[MESSAGE_HANDLER] Received message from {message.from_number}: ID={message.id}, Type={type(message).__name__}"
@@ -271,6 +398,9 @@ class WhatsAppBot(BaseModel):
271
398
  await self.provider.update_session(session)
272
399
  return None
273
400
 
401
+ # Apply read delay before marking message as read (simulates human reading time)
402
+ await self._apply_read_delay(message)
403
+
274
404
  # Mark as read if configured (only after rate limiting check passes)
275
405
  if self.config.auto_read_messages:
276
406
  logger.debug(f"[MESSAGE_HANDLER] Marking message {message.id} as read")
@@ -1201,8 +1331,55 @@ class WhatsAppBot(BaseModel):
1201
1331
 
1202
1332
  async def _process_message_batch(
1203
1333
  self, phone_number: PhoneNumber, session: WhatsAppSession, processing_token: str
1204
- ) -> GeneratedAssistantMessage[Any] | None:
1205
- """Process a batch of messages for a user with enhanced timeout protection."""
1334
+ ) -> GeneratedAssistantMessage[T_Schema] | None:
1335
+ """Process a batch of messages for a user with enhanced timeout protection.
1336
+
1337
+ This method processes multiple messages that were received in quick succession
1338
+ as a single batch. It applies batch-specific delays and combines all messages
1339
+ into a single conversation context for more coherent responses.
1340
+
1341
+ Batch Processing Flow:
1342
+ 1. Validate pending messages exist
1343
+ 2. Mark session as sending to prevent cleanup
1344
+ 3. Apply batch read delay (if human delays enabled) - simulates reading all messages
1345
+ 4. Convert message batch to agent input
1346
+ 5. Generate single response for entire batch
1347
+ 6. Send response to user
1348
+ 7. Mark all messages as read
1349
+ 8. Update session state
1350
+ 9. Execute response callbacks
1351
+
1352
+ Human-Like Delays:
1353
+ When enable_human_delays is True, this method applies a batch read delay
1354
+ at the start of processing. The delay simulates the time a human would take
1355
+ to read multiple messages in sequence, accounting for:
1356
+ - Individual reading time for each message
1357
+ - Brief pauses between messages (0.5s each)
1358
+ - Compression factor (default 0.7x) for faster batch reading
1359
+
1360
+ This creates a realistic gap before the batch is processed, making the bot
1361
+ appear more human-like when handling rapid message sequences.
1362
+
1363
+ Args:
1364
+ phone_number: Phone number of the user whose messages are being processed.
1365
+ session: The user's WhatsApp session containing pending messages.
1366
+ processing_token: Unique token to prevent duplicate batch processing.
1367
+
1368
+ Returns:
1369
+ Generated assistant response for the batch, or None if processing failed
1370
+ or no messages were pending.
1371
+
1372
+ Raises:
1373
+ Exceptions are caught and logged. Session state is cleaned up on errors.
1374
+
1375
+ Example:
1376
+ >>> # Called automatically by batch processor task
1377
+ >>> response = await self._process_message_batch(
1378
+ ... phone_number="1234567890",
1379
+ ... session=session,
1380
+ ... processing_token="batch_123"
1381
+ ... )
1382
+ """
1206
1383
  logger.info("[BATCH_PROCESSING] ═══════════ BATCH PROCESSING START ═══════════")
1207
1384
  logger.info(
1208
1385
  f"[BATCH_PROCESSING] Phone: {phone_number}, Token: {processing_token}"
@@ -1236,14 +1413,8 @@ class WhatsAppBot(BaseModel):
1236
1413
  session.context_data["sending_started_at"] = datetime.now().isoformat()
1237
1414
  await self.provider.update_session(session)
1238
1415
 
1239
- # Show typing indicator
1240
- if self.config.typing_indicator:
1241
- logger.debug(
1242
- f"[BATCH_PROCESSING] Sending typing indicator to {phone_number}"
1243
- )
1244
- await self.provider.send_typing_indicator(
1245
- phone_number, self.config.typing_duration
1246
- )
1416
+ # Note: Typing indicator is now sent in _send_response after TTS decision
1417
+ # to avoid sending it before determining if audio should be sent
1247
1418
 
1248
1419
  # Get all pending messages
1249
1420
  pending_messages = session.clear_pending_messages()
@@ -1251,6 +1422,9 @@ class WhatsAppBot(BaseModel):
1251
1422
  f"[BATCH_PROCESSING] 📦 Processing batch of {len(pending_messages)} messages for {phone_number}"
1252
1423
  )
1253
1424
 
1425
+ # Apply batch read delay before processing (simulates human reading multiple messages)
1426
+ await self._apply_batch_read_delay(list(pending_messages))
1427
+
1254
1428
  # Convert message batch to agent input
1255
1429
  logger.debug(
1256
1430
  f"[BATCH_PROCESSING] Converting message batch to agent input for {phone_number}"
@@ -1359,7 +1533,7 @@ class WhatsAppBot(BaseModel):
1359
1533
  message: WhatsAppMessage,
1360
1534
  session: WhatsAppSession,
1361
1535
  chat_id: ChatId | None = None,
1362
- ) -> GeneratedAssistantMessage[Any]:
1536
+ ) -> GeneratedAssistantMessage[T_Schema]:
1363
1537
  """Process a single message immediately with quote message support."""
1364
1538
  logger.info(
1365
1539
  "[SINGLE_MESSAGE] ═══════════ SINGLE MESSAGE PROCESSING START ═══════════"
@@ -2062,16 +2236,72 @@ class WhatsAppBot(BaseModel):
2062
2236
  async def _send_response(
2063
2237
  self,
2064
2238
  to: PhoneNumber,
2065
- response: GeneratedAssistantMessage[Any] | str,
2239
+ response: GeneratedAssistantMessage[T_Schema] | str,
2066
2240
  reply_to: str | None = None,
2067
2241
  ) -> None:
2068
- """Send response message(s) to user with enhanced error handling and retry logic."""
2069
- # Extract text from GeneratedAssistantMessage if needed
2070
- response_text = (
2071
- response.text
2072
- if isinstance(response, GeneratedAssistantMessage)
2073
- else response
2074
- )
2242
+ """Send response message(s) to user with enhanced error handling and retry logic.
2243
+
2244
+ This method handles the complete response sending flow including text-to-speech,
2245
+ human-like delays, typing indicators, message splitting, and error handling.
2246
+
2247
+ Response Sending Flow:
2248
+ 1. Extract and format response text
2249
+ 2. Attempt TTS audio generation (if configured and chance succeeds)
2250
+ 3. Apply typing delay (if human delays enabled and TTS not sent)
2251
+ 4. Show typing indicator (if configured and not already shown during delay)
2252
+ 5. Split long messages if needed
2253
+ 6. Send each message part with send delay between parts
2254
+ 7. Handle errors with retry logic
2255
+
2256
+ Human-Like Delays:
2257
+ When enable_human_delays is True, this method applies two types of delays:
2258
+
2259
+ 1. Typing Delay: Applied before sending the response to simulate the time
2260
+ a human would take to compose and type the message. The delay is based
2261
+ on response length and includes composition planning time.
2262
+
2263
+ 2. Send Delay: Applied immediately before each message transmission to
2264
+ simulate the brief final review time before hitting send. This delay
2265
+ is applied to each message part independently.
2266
+
2267
+ If TTS audio is successfully sent, the typing delay is skipped since the
2268
+ audio generation time already provides a natural delay.
2269
+
2270
+ Args:
2271
+ to: Phone number of the recipient.
2272
+ response: The response to send. Can be a GeneratedAssistantMessage or string.
2273
+ reply_to: Optional message ID to reply to (for message quoting).
2274
+
2275
+ Raises:
2276
+ Exceptions are caught and logged. Failed messages trigger retry logic
2277
+ if configured.
2278
+
2279
+ Example:
2280
+ >>> response = GeneratedAssistantMessage(text="Hello! How can I help?")
2281
+ >>> await self._send_response(
2282
+ ... to="1234567890",
2283
+ ... response=response,
2284
+ ... reply_to="msg_123"
2285
+ ... )
2286
+ """
2287
+ response_text = ""
2288
+
2289
+ if isinstance(response, GeneratedAssistantMessage):
2290
+ # Check if we have structured output (parsed)
2291
+ if response.parsed:
2292
+ # Use the 'response' field from structured output
2293
+ response_text = response.parsed.response
2294
+ logger.debug(
2295
+ "[SEND_RESPONSE] Using structured output 'response' field "
2296
+ + f"(schema: {type(response.parsed).__name__})"
2297
+ )
2298
+ else:
2299
+ # Fallback to text field
2300
+ response_text = response.text
2301
+ logger.debug("[SEND_RESPONSE] Using standard text response")
2302
+ else:
2303
+ # Direct string
2304
+ response_text = response
2075
2305
 
2076
2306
  # Apply WhatsApp-specific markdown formatting
2077
2307
  response_text = self._format_whatsapp_markdown(response_text)
@@ -2080,10 +2310,217 @@ class WhatsAppBot(BaseModel):
2080
2310
  f"[SEND_RESPONSE] Sending response to {to} (length: {len(response_text)}, reply_to: {reply_to})"
2081
2311
  )
2082
2312
 
2313
+ # Track if TTS was successfully sent (to skip typing delay for audio)
2314
+ tts_sent_successfully = False
2315
+
2316
+ # Check if we should send audio via TTS
2317
+ should_attempt_tts = (
2318
+ self.tts_provider
2319
+ and self.config.speech_config
2320
+ and self.config.speech_play_chance > 0
2321
+ and self._validate_tts_configuration()
2322
+ )
2323
+
2324
+ if should_attempt_tts:
2325
+ import random
2326
+
2327
+ # Determine if we should play speech based on chance
2328
+ should_play_speech = random.random() < self.config.speech_play_chance
2329
+
2330
+ if should_play_speech:
2331
+ logger.info(
2332
+ f"[TTS] Attempting to send audio response to {to} (chance: {self.config.speech_play_chance * 100}%)"
2333
+ )
2334
+ try:
2335
+ # Show recording indicator while synthesizing
2336
+ if self.config.typing_indicator:
2337
+ logger.debug(
2338
+ f"[TTS] Sending recording indicator to {to} during synthesis"
2339
+ )
2340
+ # Use a more appropriate duration for recording indicator
2341
+ # Based on text length: minimum 2s, maximum 10s, or estimated synthesis time
2342
+ estimated_duration = max(
2343
+ 2, min(10, len(response_text) // 50 + 2)
2344
+ )
2345
+ await self.provider.send_recording_indicator(
2346
+ to, estimated_duration
2347
+ )
2348
+
2349
+ # Synthesize speech
2350
+ # We know these are not None due to validation above
2351
+ assert self.tts_provider is not None
2352
+ assert self.config.speech_config is not None
2353
+ speech_result = await self.tts_provider.synthesize_async(
2354
+ response_text, config=self.config.speech_config
2355
+ )
2356
+
2357
+ # Try to upload to file storage if available
2358
+ audio_url = None
2359
+ if self.file_storage_manager:
2360
+ try:
2361
+ import base64
2362
+ import time
2363
+
2364
+ # Decode base64 to bytes
2365
+ audio_bytes = base64.b64decode(speech_result.audio)
2366
+
2367
+ # Generate unique filename
2368
+ timestamp = int(time.time())
2369
+ extension = self._get_audio_extension(speech_result.format)
2370
+ filename = f"tts_{timestamp}.{extension}"
2371
+
2372
+ # Upload to storage
2373
+ audio_url = await self.file_storage_manager.upload_file(
2374
+ file_data=audio_bytes,
2375
+ filename=filename,
2376
+ mime_type=str(speech_result.mime_type),
2377
+ )
2378
+
2379
+ logger.info(f"[TTS] Audio uploaded to storage: {audio_url}")
2380
+
2381
+ except Exception as e:
2382
+ logger.warning(
2383
+ f"[TTS] Failed to upload to storage, falling back to base64: {e}"
2384
+ )
2385
+ audio_url = None
2386
+
2387
+ # Send audio message (URL or base64)
2388
+ if audio_url:
2389
+ # Try URL method first
2390
+ try:
2391
+ await self.provider.send_audio_message_by_url(
2392
+ to=to,
2393
+ audio_url=audio_url,
2394
+ quoted_message_id=reply_to
2395
+ if self.config.quote_messages
2396
+ else None,
2397
+ )
2398
+ logger.info(f"[TTS] Audio sent via URL to {to}")
2399
+ except Exception as e:
2400
+ logger.warning(
2401
+ f"[TTS] URL method failed, falling back to base64: {e}"
2402
+ )
2403
+ # Fallback to base64
2404
+ await self.provider.send_audio_message(
2405
+ to=to,
2406
+ audio_base64=speech_result.audio,
2407
+ quoted_message_id=reply_to
2408
+ if self.config.quote_messages
2409
+ else None,
2410
+ )
2411
+ logger.info(f"[TTS] Audio sent via base64 to {to}")
2412
+ else:
2413
+ # Use base64 method (current behavior)
2414
+ await self.provider.send_audio_message(
2415
+ to=to,
2416
+ audio_base64=speech_result.audio,
2417
+ quoted_message_id=reply_to
2418
+ if self.config.quote_messages
2419
+ else None,
2420
+ )
2421
+ logger.info(f"[TTS] Audio sent via base64 to {to}")
2422
+
2423
+ logger.info(
2424
+ f"[TTS] Successfully sent audio response to {to}",
2425
+ extra={
2426
+ "to_number": to,
2427
+ "text_length": len(response_text),
2428
+ "mime_type": str(speech_result.mime_type),
2429
+ "format": str(speech_result.format),
2430
+ },
2431
+ )
2432
+ # Audio sent successfully, mark flag and return early
2433
+ tts_sent_successfully = True
2434
+ logger.info(
2435
+ "[TTS] Skipping typing delay since TTS audio was sent successfully"
2436
+ )
2437
+ return
2438
+
2439
+ except Exception as e:
2440
+ # Check if this is a specific Evolution API media upload error
2441
+ error_message = str(e).lower()
2442
+ if "media upload failed" in error_message or "400" in error_message:
2443
+ logger.warning(
2444
+ f"[TTS] Evolution API media upload failed for {to}, falling back to text: {e}",
2445
+ extra={
2446
+ "to_number": to,
2447
+ "error_type": type(e).__name__,
2448
+ "error": str(e),
2449
+ "fallback_reason": "evolution_api_media_upload_failed",
2450
+ },
2451
+ )
2452
+ else:
2453
+ logger.warning(
2454
+ f"[TTS] Failed to send audio response to {to}, falling back to text: {e}",
2455
+ extra={
2456
+ "to_number": to,
2457
+ "error_type": type(e).__name__,
2458
+ "error": str(e),
2459
+ "fallback_reason": "tts_synthesis_or_send_failed",
2460
+ },
2461
+ )
2462
+ # Fall through to send text message instead
2463
+
2083
2464
  # Split messages by line breaks and length
2084
2465
  messages = self._split_message_by_line_breaks(response_text)
2085
2466
  logger.info(f"[SEND_RESPONSE] Split response into {len(messages)} parts")
2086
2467
 
2468
+ # Apply typing delay before sending messages (simulates human typing time)
2469
+ # This should be done before the typing indicator to coordinate properly
2470
+ # Note: This is only reached if TTS was not used or if TTS failed and fell back to text
2471
+ if should_attempt_tts and not tts_sent_successfully:
2472
+ logger.info(
2473
+ "[SEND_RESPONSE] TTS failed, applying typing delay for text fallback"
2474
+ )
2475
+ await self._apply_typing_delay(response_text, to)
2476
+
2477
+ # Show typing indicator ONCE before sending all messages
2478
+ # Only send typing indicator if we're not attempting TTS or if TTS failed
2479
+ # Skip if typing delay already handled the indicator
2480
+ typing_delay_handled_indicator = (
2481
+ self.config.enable_human_delays
2482
+ and self.config.show_typing_during_delay
2483
+ and self.config.typing_indicator
2484
+ )
2485
+
2486
+ if typing_delay_handled_indicator:
2487
+ logger.debug(
2488
+ "[SEND_RESPONSE] Skipping redundant typing indicator - already sent during typing delay"
2489
+ )
2490
+
2491
+ if (
2492
+ self.config.typing_indicator
2493
+ and not should_attempt_tts
2494
+ and not typing_delay_handled_indicator
2495
+ ):
2496
+ try:
2497
+ logger.debug(
2498
+ f"[SEND_RESPONSE] Sending typing indicator to {to} before sending {len(messages)} message(s)"
2499
+ )
2500
+ await self.provider.send_typing_indicator(
2501
+ to, self.config.typing_duration
2502
+ )
2503
+ except Exception as e:
2504
+ # Don't let typing indicator failures break message sending
2505
+ logger.warning(f"[SEND_RESPONSE] Failed to send typing indicator: {e}")
2506
+ elif (
2507
+ self.config.typing_indicator
2508
+ and should_attempt_tts
2509
+ and not typing_delay_handled_indicator
2510
+ ):
2511
+ # TTS was attempted but failed, send typing indicator for text fallback
2512
+ # Skip if typing delay already handled the indicator
2513
+ try:
2514
+ logger.debug(
2515
+ f"[SEND_RESPONSE] TTS failed, sending typing indicator to {to} for text fallback"
2516
+ )
2517
+ await self.provider.send_typing_indicator(
2518
+ to, self.config.typing_duration
2519
+ )
2520
+ except Exception as e:
2521
+ # Don't let typing indicator failures break message sending
2522
+ logger.warning(f"[SEND_RESPONSE] Failed to send typing indicator: {e}")
2523
+
2087
2524
  # Track sending state to handle partial failures
2088
2525
  successfully_sent_count = 0
2089
2526
  failed_parts: list[dict[str, Any]] = []
@@ -2093,21 +2530,6 @@ class WhatsAppBot(BaseModel):
2093
2530
  f"[SEND_RESPONSE] Sending message part {i + 1}/{len(messages)} to {to}"
2094
2531
  )
2095
2532
 
2096
- # Show typing indicator before each message if configured
2097
- if self.config.typing_indicator:
2098
- try:
2099
- logger.debug(
2100
- f"[SEND_RESPONSE] Sending typing indicator to {to} for message {i + 1}"
2101
- )
2102
- await self.provider.send_typing_indicator(
2103
- to, self.config.typing_duration
2104
- )
2105
- except Exception as e:
2106
- # Don't let typing indicator failures break message sending
2107
- logger.warning(
2108
- f"[SEND_RESPONSE] Failed to send typing indicator: {e}"
2109
- )
2110
-
2111
2533
  # Only quote the first message if quote_messages is enabled
2112
2534
  quoted_id = reply_to if i == 0 else None
2113
2535
 
@@ -2118,6 +2540,9 @@ class WhatsAppBot(BaseModel):
2118
2540
 
2119
2541
  for attempt in range(max_retries + 1):
2120
2542
  try:
2543
+ # Apply send delay before transmitting message (simulates final review)
2544
+ await self._apply_send_delay()
2545
+
2121
2546
  sent_message = await self.provider.send_text_message(
2122
2547
  to=to, text=msg, quoted_message_id=quoted_id
2123
2548
  )
@@ -2158,15 +2583,30 @@ class WhatsAppBot(BaseModel):
2158
2583
  # Delay between messages (respecting typing duration + small buffer)
2159
2584
  if i < len(messages) - 1:
2160
2585
  # Use typing duration if typing indicator is enabled, otherwise use a small delay
2161
- delay = (
2586
+ inter_message_delay = (
2162
2587
  self.config.typing_duration + 0.5
2163
2588
  if self.config.typing_indicator
2164
2589
  else 1.0
2165
2590
  )
2166
- logger.debug(
2167
- f"[SEND_RESPONSE] Waiting {delay}s before sending next message part"
2168
- )
2169
- await asyncio.sleep(delay)
2591
+
2592
+ # Calculate total delay including send delay if human delays are enabled
2593
+ if self.config.enable_human_delays and self._delay_calculator:
2594
+ # Send delay will be applied before next message, so log total expected delay
2595
+ estimated_send_delay = (
2596
+ self.config.min_send_delay_seconds
2597
+ + self.config.max_send_delay_seconds
2598
+ ) / 2
2599
+ total_delay = inter_message_delay + estimated_send_delay
2600
+ logger.debug(
2601
+ f"[SEND_RESPONSE] Inter-message delay: {inter_message_delay:.2f}s "
2602
+ + f"(+ ~{estimated_send_delay:.2f}s send delay = ~{total_delay:.2f}s total)"
2603
+ )
2604
+ else:
2605
+ logger.debug(
2606
+ f"[SEND_RESPONSE] Waiting {inter_message_delay}s before sending next message part"
2607
+ )
2608
+
2609
+ await asyncio.sleep(inter_message_delay)
2170
2610
 
2171
2611
  # Log final sending results
2172
2612
  if failed_parts:
@@ -2196,6 +2636,48 @@ class WhatsAppBot(BaseModel):
2196
2636
  f"[SEND_RESPONSE] Successfully sent all {len(messages)} message parts to {to}"
2197
2637
  )
2198
2638
 
2639
+ def _validate_tts_configuration(self) -> bool:
2640
+ """Validate TTS configuration before attempting synthesis."""
2641
+ try:
2642
+ if not self.config.speech_config:
2643
+ logger.debug("[TTS_VALIDATION] No speech_config provided")
2644
+ return False
2645
+
2646
+ # Check if voice_id is provided
2647
+ if not self.config.speech_config.voice_id:
2648
+ logger.warning(
2649
+ "[TTS_VALIDATION] speech_config.voice_id is required but not provided"
2650
+ )
2651
+ return False
2652
+
2653
+ # Check if TTS provider is properly configured
2654
+ if not self.tts_provider:
2655
+ logger.warning("[TTS_VALIDATION] TTS provider is not configured")
2656
+ return False
2657
+
2658
+ logger.debug(
2659
+ f"[TTS_VALIDATION] TTS configuration is valid: voice_id={self.config.speech_config.voice_id}"
2660
+ )
2661
+ return True
2662
+
2663
+ except Exception as e:
2664
+ logger.warning(
2665
+ f"[TTS_VALIDATION] Failed to validate TTS configuration: {e}"
2666
+ )
2667
+ return False
2668
+
2669
+ def _get_audio_extension(self, format_type: Any) -> str:
2670
+ """Get file extension from TTS format."""
2671
+ format_str = str(format_type)
2672
+ if "mp3" in format_str:
2673
+ return "mp3"
2674
+ elif "wav" in format_str:
2675
+ return "wav"
2676
+ elif "ogg" in format_str:
2677
+ return "ogg"
2678
+ else:
2679
+ return "mp3" # default
2680
+
2199
2681
  def _split_message_by_line_breaks(self, text: str) -> Sequence[str]:
2200
2682
  """Split message by line breaks first, then by length if needed with enhanced validation."""
2201
2683
  if not text or not text.strip():
@@ -2519,6 +3001,306 @@ class WhatsAppBot(BaseModel):
2519
3001
  f"[RATE_LIMIT_ERROR] Failed to send rate limit message to {to}: {e}"
2520
3002
  )
2521
3003
 
3004
+ async def _apply_read_delay(self, message: WhatsAppMessage) -> None:
3005
+ """Apply human-like read delay before marking message as read.
3006
+
3007
+ This method simulates the time a human would take to read and comprehend
3008
+ an incoming message. The delay is calculated based on message content length
3009
+ and includes reading time, context switching, and comprehension time.
3010
+
3011
+ The delay is applied BEFORE marking the message as read, creating a realistic
3012
+ gap between message receipt and read receipt that matches human behavior.
3013
+
3014
+ Behavior:
3015
+ - Skips delay if enable_human_delays is False
3016
+ - Extracts text content from message (text or media caption)
3017
+ - Calculates delay using HumanDelayCalculator
3018
+ - Applies delay using asyncio.sleep (non-blocking)
3019
+ - Logs delay start and completion
3020
+ - Handles cancellation and errors gracefully
3021
+
3022
+ Args:
3023
+ message: The WhatsApp message to process. Can be text or media message.
3024
+
3025
+ Raises:
3026
+ asyncio.CancelledError: Re-raised to allow proper task cancellation.
3027
+ Other exceptions are caught and logged, processing continues without delay.
3028
+
3029
+ Example:
3030
+ >>> # Called automatically in handle_message() before marking as read
3031
+ >>> await self._apply_read_delay(message)
3032
+ >>> await self.provider.mark_message_as_read(message.id)
3033
+ """
3034
+ if not self.config.enable_human_delays or not self._delay_calculator:
3035
+ logger.debug("[HUMAN_DELAY] ⏱️ Read delay skipped (delays disabled)")
3036
+ return
3037
+
3038
+ try:
3039
+ # Extract text content from message
3040
+ text_content = ""
3041
+ message_type = type(message).__name__
3042
+ if isinstance(message, WhatsAppTextMessage):
3043
+ text_content = message.text
3044
+ elif isinstance(message, WhatsAppMediaMessage):
3045
+ # For media messages, use caption if available
3046
+ text_content = message.caption or ""
3047
+
3048
+ # Calculate read delay
3049
+ delay = self._delay_calculator.calculate_read_delay(text_content)
3050
+
3051
+ # Log delay start
3052
+ logger.info(
3053
+ f"[HUMAN_DELAY] ⏱️ Starting read delay: {delay:.2f}s "
3054
+ + f"for {len(text_content)} chars (message_type={message_type}, message_id={message.id})"
3055
+ )
3056
+
3057
+ # Apply delay
3058
+ await asyncio.sleep(delay)
3059
+
3060
+ # Log delay completion
3061
+ logger.info(
3062
+ f"[HUMAN_DELAY] ⏱️ Read delay completed: {delay:.2f}s "
3063
+ + f"(message_id={message.id})"
3064
+ )
3065
+
3066
+ except asyncio.CancelledError:
3067
+ logger.warning(
3068
+ f"[HUMAN_DELAY] ⏱️ Read delay cancelled for message {message.id}"
3069
+ )
3070
+ raise # Re-raise to allow proper cancellation
3071
+ except Exception as e:
3072
+ logger.error(
3073
+ f"[HUMAN_DELAY] ⏱️ Error applying read delay for message {message.id}: {e}",
3074
+ exc_info=True,
3075
+ )
3076
+ # Continue without delay on error
3077
+
3078
+ async def _apply_typing_delay(self, response_text: str, to: PhoneNumber) -> None:
3079
+ """Apply human-like typing delay before sending response.
3080
+
3081
+ This method simulates the time a human would take to compose and type
3082
+ a response. The delay is calculated based on response content length
3083
+ and includes composition planning, typing time, and multitasking overhead.
3084
+
3085
+ The delay is applied AFTER response generation but BEFORE sending the message,
3086
+ creating a realistic gap that matches human typing behavior.
3087
+
3088
+ Behavior:
3089
+ - Skips delay if enable_human_delays is False
3090
+ - Calculates delay using HumanDelayCalculator based on response length
3091
+ - Optionally sends typing indicator during delay (if show_typing_during_delay is True)
3092
+ - Applies delay using asyncio.sleep (non-blocking)
3093
+ - Logs delay start and completion
3094
+ - Handles typing indicator failures gracefully
3095
+ - Handles cancellation and errors gracefully
3096
+
3097
+ Args:
3098
+ response_text: The response text that will be sent to the user.
3099
+ to: The phone number of the recipient.
3100
+
3101
+ Raises:
3102
+ asyncio.CancelledError: Re-raised to allow proper task cancellation.
3103
+ Other exceptions are caught and logged, processing continues without delay.
3104
+
3105
+ Example:
3106
+ >>> # Called automatically in _send_response() before sending
3107
+ >>> response_text = "Hello! How can I help you?"
3108
+ >>> await self._apply_typing_delay(response_text, phone_number)
3109
+ >>> await self.provider.send_text_message(phone_number, response_text)
3110
+ """
3111
+ if not self.config.enable_human_delays or not self._delay_calculator:
3112
+ logger.debug("[HUMAN_DELAY] ⌨️ Typing delay skipped (delays disabled)")
3113
+ return
3114
+
3115
+ try:
3116
+ # Calculate typing delay
3117
+ delay = self._delay_calculator.calculate_typing_delay(response_text)
3118
+
3119
+ # Log delay start
3120
+ logger.info(
3121
+ f"[HUMAN_DELAY] ⌨️ Starting typing delay: {delay:.2f}s "
3122
+ + f"for {len(response_text)} chars (to={to})"
3123
+ )
3124
+
3125
+ # Show typing indicator during delay if configured
3126
+ if self.config.show_typing_during_delay and self.config.typing_indicator:
3127
+ try:
3128
+ logger.debug(
3129
+ f"[HUMAN_DELAY] ⌨️ Sending typing indicator for {int(delay)}s to {to}"
3130
+ )
3131
+ # Send typing indicator for the duration of the delay
3132
+ await self.provider.send_typing_indicator(to, int(delay))
3133
+ except Exception as indicator_error:
3134
+ logger.warning(
3135
+ f"[HUMAN_DELAY] ⌨️ Failed to send typing indicator during delay to {to}: "
3136
+ + f"{indicator_error}"
3137
+ )
3138
+ # Continue with delay even if indicator fails
3139
+
3140
+ # Apply delay
3141
+ await asyncio.sleep(delay)
3142
+
3143
+ # Log delay completion
3144
+ logger.info(
3145
+ f"[HUMAN_DELAY] ⌨️ Typing delay completed: {delay:.2f}s (to={to})"
3146
+ )
3147
+
3148
+ except asyncio.CancelledError:
3149
+ logger.warning(f"[HUMAN_DELAY] ⌨️ Typing delay cancelled for {to}")
3150
+ raise # Re-raise to allow proper cancellation
3151
+ except Exception as e:
3152
+ logger.error(
3153
+ f"[HUMAN_DELAY] ⌨️ Error applying typing delay for {to}: {e}",
3154
+ exc_info=True,
3155
+ )
3156
+ # Continue without delay on error
3157
+
3158
+ async def _apply_send_delay(self) -> None:
3159
+ """Apply brief delay before sending message.
3160
+
3161
+ This method simulates the final review time before a human sends a message.
3162
+ The delay is a random value within configured bounds, representing the brief
3163
+ moment a human takes to review their message before hitting send.
3164
+
3165
+ The delay is applied immediately BEFORE each message transmission, creating
3166
+ a small gap that adds to the natural feel of the conversation.
3167
+
3168
+ Behavior:
3169
+ - Skips delay if enable_human_delays is False
3170
+ - Generates random delay within configured send delay bounds
3171
+ - Applies optional jitter if enabled
3172
+ - Applies delay using asyncio.sleep (non-blocking)
3173
+ - Logs delay start and completion
3174
+ - Handles cancellation and errors gracefully
3175
+
3176
+ Raises:
3177
+ asyncio.CancelledError: Re-raised to allow proper task cancellation.
3178
+ Other exceptions are caught and logged, processing continues without delay.
3179
+
3180
+ Example:
3181
+ >>> # Called automatically before each message transmission
3182
+ >>> for message_part in message_parts:
3183
+ ... await self._apply_send_delay()
3184
+ ... await self.provider.send_text_message(phone_number, message_part)
3185
+ """
3186
+ if not self.config.enable_human_delays or not self._delay_calculator:
3187
+ logger.debug("[HUMAN_DELAY] 📤 Send delay skipped (delays disabled)")
3188
+ return
3189
+
3190
+ try:
3191
+ # Calculate send delay
3192
+ delay = self._delay_calculator.calculate_send_delay()
3193
+
3194
+ # Log delay start
3195
+ logger.info(f"[HUMAN_DELAY] 📤 Starting send delay: {delay:.2f}s")
3196
+
3197
+ # Apply delay
3198
+ await asyncio.sleep(delay)
3199
+
3200
+ # Log delay completion
3201
+ logger.debug(f"[HUMAN_DELAY] 📤 Send delay completed: {delay:.2f}s")
3202
+
3203
+ except asyncio.CancelledError:
3204
+ logger.warning("[HUMAN_DELAY] 📤 Send delay cancelled")
3205
+ raise # Re-raise to allow proper cancellation
3206
+ except Exception as e:
3207
+ logger.error(
3208
+ f"[HUMAN_DELAY] 📤 Error applying send delay: {e}", exc_info=True
3209
+ )
3210
+ # Continue without delay on error
3211
+
3212
+ async def _apply_batch_read_delay(self, messages: list[dict[str, Any]]) -> None:
3213
+ """Apply human-like read delay for batch of messages.
3214
+
3215
+ This method simulates the time a human would take to read multiple messages
3216
+ in sequence. The delay accounts for reading each message individually, with
3217
+ brief pauses between messages, and applies a compression factor to simulate
3218
+ faster batch reading compared to reading messages one at a time.
3219
+
3220
+ The delay is applied at the START of batch processing, before any message
3221
+ processing begins, creating a realistic gap that matches human batch reading.
3222
+
3223
+ Behavior:
3224
+ - Skips delay if enable_human_delays is False
3225
+ - Extracts text content from all messages (text and media captions)
3226
+ - Calculates individual read delays for each message
3227
+ - Adds 0.5s pause between each message
3228
+ - Applies compression factor (default 0.7x for 30% faster reading)
3229
+ - Clamps to reasonable bounds (2-20 seconds suggested)
3230
+ - Applies delay using asyncio.sleep (non-blocking)
3231
+ - Logs delay start and completion with message count
3232
+ - Handles cancellation and errors gracefully
3233
+
3234
+ Args:
3235
+ messages: List of message dictionaries from the batch. Each dict should
3236
+ contain 'type' and either 'text' or 'caption' fields.
3237
+
3238
+ Raises:
3239
+ asyncio.CancelledError: Re-raised to allow proper task cancellation.
3240
+ Other exceptions are caught and logged, processing continues without delay.
3241
+
3242
+ Example:
3243
+ >>> # Called automatically in _process_message_batch() before processing
3244
+ >>> pending_messages = [msg1_dict, msg2_dict, msg3_dict]
3245
+ >>> await self._apply_batch_read_delay(pending_messages)
3246
+ >>> # Now process the batch...
3247
+ """
3248
+ if not self.config.enable_human_delays or not self._delay_calculator:
3249
+ logger.debug("[HUMAN_DELAY] 📚 Batch read delay skipped (delays disabled)")
3250
+ return
3251
+
3252
+ try:
3253
+ # Extract text content from all messages in batch
3254
+ message_texts: list[str] = []
3255
+ total_chars = 0
3256
+ for msg in messages:
3257
+ if msg.get("type") == "WhatsAppTextMessage":
3258
+ text = msg.get("text", "")
3259
+ if text:
3260
+ message_texts.append(text)
3261
+ total_chars += len(text)
3262
+ elif msg.get("type") in [
3263
+ "WhatsAppImageMessage",
3264
+ "WhatsAppDocumentMessage",
3265
+ "WhatsAppAudioMessage",
3266
+ "WhatsAppVideoMessage",
3267
+ ]:
3268
+ # For media messages, use caption if available
3269
+ caption = msg.get("caption", "")
3270
+ if caption:
3271
+ message_texts.append(caption)
3272
+ total_chars += len(caption)
3273
+
3274
+ # Calculate batch read delay
3275
+ delay = self._delay_calculator.calculate_batch_read_delay(message_texts)
3276
+
3277
+ # Log delay start
3278
+ logger.info(
3279
+ f"[HUMAN_DELAY] 📚 Starting batch read delay: {delay:.2f}s "
3280
+ + f"for {len(messages)} messages ({total_chars} total chars)"
3281
+ )
3282
+
3283
+ # Apply delay
3284
+ await asyncio.sleep(delay)
3285
+
3286
+ # Log delay completion
3287
+ logger.info(
3288
+ f"[HUMAN_DELAY] 📚 Batch read delay completed: {delay:.2f}s "
3289
+ + f"for {len(messages)} messages"
3290
+ )
3291
+
3292
+ except asyncio.CancelledError:
3293
+ logger.warning(
3294
+ f"[HUMAN_DELAY] 📚 Batch read delay cancelled for {len(messages)} messages"
3295
+ )
3296
+ raise # Re-raise to allow proper cancellation
3297
+ except Exception as e:
3298
+ logger.error(
3299
+ f"[HUMAN_DELAY] 📚 Error applying batch read delay for {len(messages)} messages: {e}",
3300
+ exc_info=True,
3301
+ )
3302
+ # Continue without delay on error
3303
+
2522
3304
  def _split_message(self, text: str) -> Sequence[str]:
2523
3305
  """Split long message into chunks."""
2524
3306
  if len(text) <= self.config.max_message_length: