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.
- agentle/agents/agent.py +175 -10
- agentle/agents/agent_run_output.py +8 -1
- agentle/agents/apis/__init__.py +79 -6
- agentle/agents/apis/api.py +342 -73
- agentle/agents/apis/api_key_authentication.py +43 -0
- agentle/agents/apis/api_key_location.py +11 -0
- agentle/agents/apis/api_metrics.py +16 -0
- agentle/agents/apis/auth_type.py +17 -0
- agentle/agents/apis/authentication.py +32 -0
- agentle/agents/apis/authentication_base.py +42 -0
- agentle/agents/apis/authentication_config.py +117 -0
- agentle/agents/apis/basic_authentication.py +34 -0
- agentle/agents/apis/bearer_authentication.py +52 -0
- agentle/agents/apis/cache_strategy.py +12 -0
- agentle/agents/apis/circuit_breaker.py +69 -0
- agentle/agents/apis/circuit_breaker_error.py +7 -0
- agentle/agents/apis/circuit_breaker_state.py +11 -0
- agentle/agents/apis/endpoint.py +413 -254
- agentle/agents/apis/file_upload.py +23 -0
- agentle/agents/apis/hmac_authentication.py +56 -0
- agentle/agents/apis/no_authentication.py +27 -0
- agentle/agents/apis/oauth2_authentication.py +111 -0
- agentle/agents/apis/oauth2_grant_type.py +12 -0
- agentle/agents/apis/object_schema.py +86 -1
- agentle/agents/apis/params/__init__.py +10 -1
- agentle/agents/apis/params/boolean_param.py +44 -0
- agentle/agents/apis/params/number_param.py +56 -0
- agentle/agents/apis/rate_limit_error.py +7 -0
- agentle/agents/apis/rate_limiter.py +57 -0
- agentle/agents/apis/request_config.py +126 -4
- agentle/agents/apis/request_hook.py +16 -0
- agentle/agents/apis/response_cache.py +49 -0
- agentle/agents/apis/retry_strategy.py +12 -0
- agentle/agents/whatsapp/human_delay_calculator.py +462 -0
- agentle/agents/whatsapp/models/audio_message.py +6 -4
- agentle/agents/whatsapp/models/key.py +2 -2
- agentle/agents/whatsapp/models/whatsapp_bot_config.py +375 -21
- agentle/agents/whatsapp/models/whatsapp_response_base.py +31 -0
- agentle/agents/whatsapp/models/whatsapp_webhook_payload.py +5 -1
- agentle/agents/whatsapp/providers/base/whatsapp_provider.py +51 -0
- agentle/agents/whatsapp/providers/evolution/evolution_api_provider.py +237 -10
- agentle/agents/whatsapp/providers/meta/meta_whatsapp_provider.py +126 -0
- agentle/agents/whatsapp/v2/batch_processor_manager.py +4 -0
- agentle/agents/whatsapp/v2/bot_config.py +188 -0
- agentle/agents/whatsapp/v2/message_limit.py +9 -0
- agentle/agents/whatsapp/v2/payload.py +0 -0
- agentle/agents/whatsapp/v2/whatsapp_bot.py +13 -0
- agentle/agents/whatsapp/v2/whatsapp_cloud_api_provider.py +0 -0
- agentle/agents/whatsapp/v2/whatsapp_provider.py +0 -0
- agentle/agents/whatsapp/whatsapp_bot.py +827 -45
- agentle/generations/providers/google/adapters/generate_generate_content_response_to_generation_adapter.py +13 -10
- agentle/generations/providers/google/google_generation_provider.py +35 -5
- agentle/generations/providers/openrouter/_adapters/openrouter_message_to_generated_assistant_message_adapter.py +35 -1
- agentle/mcp/servers/stdio_mcp_server.py +23 -4
- agentle/parsing/parsers/docx.py +8 -0
- agentle/parsing/parsers/file_parser.py +4 -0
- agentle/parsing/parsers/pdf.py +7 -1
- agentle/storage/__init__.py +11 -0
- agentle/storage/file_storage_manager.py +44 -0
- agentle/storage/local_file_storage_manager.py +122 -0
- agentle/storage/s3_file_storage_manager.py +124 -0
- agentle/tts/audio_format.py +6 -0
- agentle/tts/elevenlabs_tts_provider.py +108 -0
- agentle/tts/output_format_type.py +26 -0
- agentle/tts/speech_config.py +14 -0
- agentle/tts/speech_result.py +15 -0
- agentle/tts/tts_provider.py +16 -0
- agentle/tts/voice_settings.py +30 -0
- agentle/utils/parse_streaming_json.py +39 -13
- agentle/voice_cloning/__init__.py +0 -0
- agentle/voice_cloning/voice_cloner.py +0 -0
- agentle/web/extractor.py +282 -148
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/METADATA +1 -1
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/RECORD +78 -39
- agentle/tts/real_time/definitions/audio_data.py +0 -20
- agentle/tts/real_time/definitions/speech_config.py +0 -27
- agentle/tts/real_time/definitions/speech_result.py +0 -14
- agentle/tts/real_time/definitions/tts_stream_chunk.py +0 -15
- agentle/tts/real_time/definitions/voice_gender.py +0 -9
- agentle/tts/real_time/definitions/voice_info.py +0 -18
- agentle/tts/real_time/real_time_speech_to_text_provider.py +0 -66
- /agentle/{tts/real_time → agents/whatsapp/v2}/__init__.py +0 -0
- /agentle/{tts/real_time/definitions/__init__.py → agents/whatsapp/v2/in_memory_batch_processor_manager.py} +0 -0
- {agentle-0.9.4.dist-info → agentle-0.9.28.dist-info}/WHEEL +0 -0
- {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
|
|
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[
|
|
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[
|
|
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
|
-
#
|
|
1240
|
-
if
|
|
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[
|
|
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[
|
|
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
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
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:
|