wappa 0.1.0__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.
Potentially problematic release.
This version of wappa might be problematic. Click here for more details.
- wappa/__init__.py +85 -0
- wappa/api/__init__.py +1 -0
- wappa/api/controllers/__init__.py +10 -0
- wappa/api/controllers/webhook_controller.py +441 -0
- wappa/api/dependencies/__init__.py +15 -0
- wappa/api/dependencies/whatsapp_dependencies.py +220 -0
- wappa/api/dependencies/whatsapp_media_dependencies.py +26 -0
- wappa/api/middleware/__init__.py +7 -0
- wappa/api/middleware/error_handler.py +158 -0
- wappa/api/middleware/owner.py +99 -0
- wappa/api/middleware/request_logging.py +184 -0
- wappa/api/routes/__init__.py +6 -0
- wappa/api/routes/health.py +102 -0
- wappa/api/routes/webhooks.py +211 -0
- wappa/api/routes/whatsapp/__init__.py +15 -0
- wappa/api/routes/whatsapp/whatsapp_interactive.py +429 -0
- wappa/api/routes/whatsapp/whatsapp_media.py +440 -0
- wappa/api/routes/whatsapp/whatsapp_messages.py +195 -0
- wappa/api/routes/whatsapp/whatsapp_specialized.py +516 -0
- wappa/api/routes/whatsapp/whatsapp_templates.py +431 -0
- wappa/api/routes/whatsapp_combined.py +35 -0
- wappa/cli/__init__.py +9 -0
- wappa/cli/main.py +199 -0
- wappa/core/__init__.py +6 -0
- wappa/core/config/__init__.py +5 -0
- wappa/core/config/settings.py +161 -0
- wappa/core/events/__init__.py +41 -0
- wappa/core/events/default_handlers.py +642 -0
- wappa/core/events/event_dispatcher.py +244 -0
- wappa/core/events/event_handler.py +247 -0
- wappa/core/events/webhook_factory.py +219 -0
- wappa/core/factory/__init__.py +15 -0
- wappa/core/factory/plugin.py +68 -0
- wappa/core/factory/wappa_builder.py +326 -0
- wappa/core/logging/__init__.py +5 -0
- wappa/core/logging/context.py +100 -0
- wappa/core/logging/logger.py +343 -0
- wappa/core/plugins/__init__.py +34 -0
- wappa/core/plugins/auth_plugin.py +169 -0
- wappa/core/plugins/cors_plugin.py +128 -0
- wappa/core/plugins/custom_middleware_plugin.py +182 -0
- wappa/core/plugins/database_plugin.py +235 -0
- wappa/core/plugins/rate_limit_plugin.py +183 -0
- wappa/core/plugins/redis_plugin.py +224 -0
- wappa/core/plugins/wappa_core_plugin.py +261 -0
- wappa/core/plugins/webhook_plugin.py +253 -0
- wappa/core/types.py +108 -0
- wappa/core/wappa_app.py +546 -0
- wappa/database/__init__.py +18 -0
- wappa/database/adapter.py +107 -0
- wappa/database/adapters/__init__.py +17 -0
- wappa/database/adapters/mysql_adapter.py +187 -0
- wappa/database/adapters/postgresql_adapter.py +169 -0
- wappa/database/adapters/sqlite_adapter.py +174 -0
- wappa/domain/__init__.py +28 -0
- wappa/domain/builders/__init__.py +5 -0
- wappa/domain/builders/message_builder.py +189 -0
- wappa/domain/entities/__init__.py +5 -0
- wappa/domain/enums/messenger_platform.py +123 -0
- wappa/domain/factories/__init__.py +6 -0
- wappa/domain/factories/media_factory.py +450 -0
- wappa/domain/factories/message_factory.py +497 -0
- wappa/domain/factories/messenger_factory.py +244 -0
- wappa/domain/interfaces/__init__.py +32 -0
- wappa/domain/interfaces/base_repository.py +94 -0
- wappa/domain/interfaces/cache_factory.py +85 -0
- wappa/domain/interfaces/cache_interface.py +199 -0
- wappa/domain/interfaces/expiry_repository.py +68 -0
- wappa/domain/interfaces/media_interface.py +311 -0
- wappa/domain/interfaces/messaging_interface.py +523 -0
- wappa/domain/interfaces/pubsub_repository.py +151 -0
- wappa/domain/interfaces/repository_factory.py +108 -0
- wappa/domain/interfaces/shared_state_repository.py +122 -0
- wappa/domain/interfaces/state_repository.py +123 -0
- wappa/domain/interfaces/tables_repository.py +215 -0
- wappa/domain/interfaces/user_repository.py +114 -0
- wappa/domain/interfaces/webhooks/__init__.py +1 -0
- wappa/domain/models/media_result.py +110 -0
- wappa/domain/models/platforms/__init__.py +15 -0
- wappa/domain/models/platforms/platform_config.py +104 -0
- wappa/domain/services/__init__.py +11 -0
- wappa/domain/services/tenant_credentials_service.py +56 -0
- wappa/messaging/__init__.py +7 -0
- wappa/messaging/whatsapp/__init__.py +1 -0
- wappa/messaging/whatsapp/client/__init__.py +5 -0
- wappa/messaging/whatsapp/client/whatsapp_client.py +417 -0
- wappa/messaging/whatsapp/handlers/__init__.py +13 -0
- wappa/messaging/whatsapp/handlers/whatsapp_interactive_handler.py +653 -0
- wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +579 -0
- wappa/messaging/whatsapp/handlers/whatsapp_specialized_handler.py +434 -0
- wappa/messaging/whatsapp/handlers/whatsapp_template_handler.py +416 -0
- wappa/messaging/whatsapp/messenger/__init__.py +5 -0
- wappa/messaging/whatsapp/messenger/whatsapp_messenger.py +904 -0
- wappa/messaging/whatsapp/models/__init__.py +61 -0
- wappa/messaging/whatsapp/models/basic_models.py +65 -0
- wappa/messaging/whatsapp/models/interactive_models.py +287 -0
- wappa/messaging/whatsapp/models/media_models.py +215 -0
- wappa/messaging/whatsapp/models/specialized_models.py +304 -0
- wappa/messaging/whatsapp/models/template_models.py +261 -0
- wappa/persistence/cache_factory.py +93 -0
- wappa/persistence/json/__init__.py +14 -0
- wappa/persistence/json/cache_adapters.py +271 -0
- wappa/persistence/json/handlers/__init__.py +1 -0
- wappa/persistence/json/handlers/state_handler.py +250 -0
- wappa/persistence/json/handlers/table_handler.py +263 -0
- wappa/persistence/json/handlers/user_handler.py +213 -0
- wappa/persistence/json/handlers/utils/__init__.py +1 -0
- wappa/persistence/json/handlers/utils/file_manager.py +153 -0
- wappa/persistence/json/handlers/utils/key_factory.py +11 -0
- wappa/persistence/json/handlers/utils/serialization.py +121 -0
- wappa/persistence/json/json_cache_factory.py +76 -0
- wappa/persistence/json/storage_manager.py +285 -0
- wappa/persistence/memory/__init__.py +14 -0
- wappa/persistence/memory/cache_adapters.py +271 -0
- wappa/persistence/memory/handlers/__init__.py +1 -0
- wappa/persistence/memory/handlers/state_handler.py +250 -0
- wappa/persistence/memory/handlers/table_handler.py +280 -0
- wappa/persistence/memory/handlers/user_handler.py +213 -0
- wappa/persistence/memory/handlers/utils/__init__.py +1 -0
- wappa/persistence/memory/handlers/utils/key_factory.py +11 -0
- wappa/persistence/memory/handlers/utils/memory_store.py +317 -0
- wappa/persistence/memory/handlers/utils/ttl_manager.py +235 -0
- wappa/persistence/memory/memory_cache_factory.py +76 -0
- wappa/persistence/memory/storage_manager.py +235 -0
- wappa/persistence/redis/README.md +699 -0
- wappa/persistence/redis/__init__.py +11 -0
- wappa/persistence/redis/cache_adapters.py +285 -0
- wappa/persistence/redis/ops.py +880 -0
- wappa/persistence/redis/redis_cache_factory.py +71 -0
- wappa/persistence/redis/redis_client.py +231 -0
- wappa/persistence/redis/redis_handler/__init__.py +26 -0
- wappa/persistence/redis/redis_handler/state_handler.py +176 -0
- wappa/persistence/redis/redis_handler/table.py +158 -0
- wappa/persistence/redis/redis_handler/user.py +138 -0
- wappa/persistence/redis/redis_handler/utils/__init__.py +12 -0
- wappa/persistence/redis/redis_handler/utils/key_factory.py +32 -0
- wappa/persistence/redis/redis_handler/utils/serde.py +146 -0
- wappa/persistence/redis/redis_handler/utils/tenant_cache.py +268 -0
- wappa/persistence/redis/redis_manager.py +189 -0
- wappa/processors/__init__.py +6 -0
- wappa/processors/base_processor.py +262 -0
- wappa/processors/factory.py +550 -0
- wappa/processors/whatsapp_processor.py +810 -0
- wappa/schemas/__init__.py +6 -0
- wappa/schemas/core/__init__.py +71 -0
- wappa/schemas/core/base_message.py +499 -0
- wappa/schemas/core/base_status.py +322 -0
- wappa/schemas/core/base_webhook.py +312 -0
- wappa/schemas/core/types.py +253 -0
- wappa/schemas/core/webhook_interfaces/__init__.py +48 -0
- wappa/schemas/core/webhook_interfaces/base_components.py +293 -0
- wappa/schemas/core/webhook_interfaces/universal_webhooks.py +348 -0
- wappa/schemas/factory.py +754 -0
- wappa/schemas/webhooks/__init__.py +3 -0
- wappa/schemas/whatsapp/__init__.py +6 -0
- wappa/schemas/whatsapp/base_models.py +285 -0
- wappa/schemas/whatsapp/message_types/__init__.py +93 -0
- wappa/schemas/whatsapp/message_types/audio.py +350 -0
- wappa/schemas/whatsapp/message_types/button.py +267 -0
- wappa/schemas/whatsapp/message_types/contact.py +464 -0
- wappa/schemas/whatsapp/message_types/document.py +421 -0
- wappa/schemas/whatsapp/message_types/errors.py +195 -0
- wappa/schemas/whatsapp/message_types/image.py +424 -0
- wappa/schemas/whatsapp/message_types/interactive.py +430 -0
- wappa/schemas/whatsapp/message_types/location.py +416 -0
- wappa/schemas/whatsapp/message_types/order.py +372 -0
- wappa/schemas/whatsapp/message_types/reaction.py +271 -0
- wappa/schemas/whatsapp/message_types/sticker.py +328 -0
- wappa/schemas/whatsapp/message_types/system.py +317 -0
- wappa/schemas/whatsapp/message_types/text.py +411 -0
- wappa/schemas/whatsapp/message_types/unsupported.py +273 -0
- wappa/schemas/whatsapp/message_types/video.py +344 -0
- wappa/schemas/whatsapp/status_models.py +479 -0
- wappa/schemas/whatsapp/validators.py +454 -0
- wappa/schemas/whatsapp/webhook_container.py +438 -0
- wappa/webhooks/__init__.py +17 -0
- wappa/webhooks/core/__init__.py +71 -0
- wappa/webhooks/core/base_message.py +499 -0
- wappa/webhooks/core/base_status.py +322 -0
- wappa/webhooks/core/base_webhook.py +312 -0
- wappa/webhooks/core/types.py +253 -0
- wappa/webhooks/core/webhook_interfaces/__init__.py +48 -0
- wappa/webhooks/core/webhook_interfaces/base_components.py +293 -0
- wappa/webhooks/core/webhook_interfaces/universal_webhooks.py +441 -0
- wappa/webhooks/factory.py +754 -0
- wappa/webhooks/whatsapp/__init__.py +6 -0
- wappa/webhooks/whatsapp/base_models.py +285 -0
- wappa/webhooks/whatsapp/message_types/__init__.py +93 -0
- wappa/webhooks/whatsapp/message_types/audio.py +350 -0
- wappa/webhooks/whatsapp/message_types/button.py +267 -0
- wappa/webhooks/whatsapp/message_types/contact.py +464 -0
- wappa/webhooks/whatsapp/message_types/document.py +421 -0
- wappa/webhooks/whatsapp/message_types/errors.py +195 -0
- wappa/webhooks/whatsapp/message_types/image.py +424 -0
- wappa/webhooks/whatsapp/message_types/interactive.py +430 -0
- wappa/webhooks/whatsapp/message_types/location.py +416 -0
- wappa/webhooks/whatsapp/message_types/order.py +372 -0
- wappa/webhooks/whatsapp/message_types/reaction.py +271 -0
- wappa/webhooks/whatsapp/message_types/sticker.py +328 -0
- wappa/webhooks/whatsapp/message_types/system.py +317 -0
- wappa/webhooks/whatsapp/message_types/text.py +411 -0
- wappa/webhooks/whatsapp/message_types/unsupported.py +273 -0
- wappa/webhooks/whatsapp/message_types/video.py +344 -0
- wappa/webhooks/whatsapp/status_models.py +479 -0
- wappa/webhooks/whatsapp/validators.py +454 -0
- wappa/webhooks/whatsapp/webhook_container.py +438 -0
- wappa-0.1.0.dist-info/METADATA +269 -0
- wappa-0.1.0.dist-info/RECORD +211 -0
- wappa-0.1.0.dist-info/WHEEL +4 -0
- wappa-0.1.0.dist-info/entry_points.txt +2 -0
- wappa-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WhatsApp implementation of the IMediaHandler interface.
|
|
3
|
+
|
|
4
|
+
Refactored from existing WhatsAppServiceMedia in whatsapp_latest/services/handle_media.py
|
|
5
|
+
to follow SOLID principles with dependency injection and proper separation of concerns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import mimetypes
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, BinaryIO
|
|
13
|
+
|
|
14
|
+
from wappa.core.logging.logger import get_logger
|
|
15
|
+
from wappa.domain.interfaces.media_interface import IMediaHandler
|
|
16
|
+
from wappa.domain.models.media_result import (
|
|
17
|
+
MediaDeleteResult,
|
|
18
|
+
MediaDownloadResult,
|
|
19
|
+
MediaInfoResult,
|
|
20
|
+
MediaUploadResult,
|
|
21
|
+
)
|
|
22
|
+
from wappa.messaging.whatsapp.client.whatsapp_client import WhatsAppClient
|
|
23
|
+
from wappa.messaging.whatsapp.models.media_models import MediaType
|
|
24
|
+
from wappa.schemas.core.types import PlatformType
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WhatsAppMediaHandler(IMediaHandler):
|
|
28
|
+
"""
|
|
29
|
+
WhatsApp implementation of the media handler interface.
|
|
30
|
+
|
|
31
|
+
Refactored from existing WhatsAppServiceMedia to follow SOLID principles:
|
|
32
|
+
- Single Responsibility: Only handles media operations
|
|
33
|
+
- Open/Closed: Extensible through interface implementation
|
|
34
|
+
- Dependency Inversion: Depends on WhatsAppClient abstraction
|
|
35
|
+
|
|
36
|
+
Based on WhatsApp Cloud API 2025 endpoints:
|
|
37
|
+
- POST /PHONE_NUMBER_ID/media (upload)
|
|
38
|
+
- GET /MEDIA_ID (get info/URL)
|
|
39
|
+
- DELETE /MEDIA_ID (delete)
|
|
40
|
+
- GET /MEDIA_URL (download)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, client: WhatsAppClient, tenant_id: str):
|
|
44
|
+
"""Initialize WhatsApp media handler with client and tenant context.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
client: Configured WhatsApp client for API operations
|
|
48
|
+
tenant_id: Tenant identifier (phone_number_id in WhatsApp context)
|
|
49
|
+
"""
|
|
50
|
+
self.client = client
|
|
51
|
+
self._tenant_id = tenant_id
|
|
52
|
+
self.logger = get_logger(__name__)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def platform(self) -> PlatformType:
|
|
56
|
+
"""Get the platform this handler manages."""
|
|
57
|
+
return PlatformType.WHATSAPP
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def tenant_id(self) -> str:
|
|
61
|
+
"""Get the tenant ID this handler serves."""
|
|
62
|
+
return self._tenant_id
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def supported_media_types(self) -> set[str]:
|
|
66
|
+
"""Get supported MIME types for WhatsApp."""
|
|
67
|
+
supported_types = set()
|
|
68
|
+
for media_type in MediaType:
|
|
69
|
+
supported_types.update(MediaType.get_supported_mime_types(media_type))
|
|
70
|
+
return supported_types
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def max_file_size(self) -> dict[str, int]:
|
|
74
|
+
"""Get maximum file sizes by media category."""
|
|
75
|
+
return {
|
|
76
|
+
"image": 5 * 1024 * 1024, # 5MB
|
|
77
|
+
"video": 16 * 1024 * 1024, # 16MB
|
|
78
|
+
"audio": 16 * 1024 * 1024, # 16MB
|
|
79
|
+
"document": 100 * 1024 * 1024, # 100MB
|
|
80
|
+
"sticker": 500 * 1024, # 500KB (animated), 100KB (static)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async def upload_media(
|
|
84
|
+
self,
|
|
85
|
+
file_path: str | Path,
|
|
86
|
+
media_type: str | None = None,
|
|
87
|
+
filename: str | None = None,
|
|
88
|
+
) -> MediaUploadResult:
|
|
89
|
+
"""
|
|
90
|
+
Upload media file to WhatsApp servers.
|
|
91
|
+
|
|
92
|
+
Based on existing WhatsAppServiceMedia.upload_media() method.
|
|
93
|
+
Implements POST /PHONE_NUMBER_ID/media endpoint.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
media_path = Path(file_path)
|
|
97
|
+
if not media_path.exists():
|
|
98
|
+
return MediaUploadResult(
|
|
99
|
+
success=False,
|
|
100
|
+
error=f"Media file not found: {media_path}",
|
|
101
|
+
error_code="FILE_NOT_FOUND",
|
|
102
|
+
tenant_id=self._tenant_id,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Auto-detect MIME type if not provided
|
|
106
|
+
if media_type is None:
|
|
107
|
+
media_type = mimetypes.guess_type(media_path)[0]
|
|
108
|
+
if not media_type:
|
|
109
|
+
return MediaUploadResult(
|
|
110
|
+
success=False,
|
|
111
|
+
error=f"Could not determine MIME type for file: {media_path}",
|
|
112
|
+
error_code="MIME_TYPE_UNKNOWN",
|
|
113
|
+
tenant_id=self._tenant_id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Validate MIME type
|
|
117
|
+
if not self.validate_media_type(media_type):
|
|
118
|
+
return MediaUploadResult(
|
|
119
|
+
success=False,
|
|
120
|
+
error=f"Unsupported MIME type '{media_type}'. Supported types: {sorted(self.supported_media_types)}",
|
|
121
|
+
error_code="MIME_TYPE_UNSUPPORTED",
|
|
122
|
+
tenant_id=self._tenant_id,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Validate file size
|
|
126
|
+
file_size = media_path.stat().st_size
|
|
127
|
+
if not self.validate_file_size(file_size, media_type):
|
|
128
|
+
max_size = self._get_max_size_for_mime_type(media_type)
|
|
129
|
+
return MediaUploadResult(
|
|
130
|
+
success=False,
|
|
131
|
+
error=f"File size ({file_size} bytes) exceeds the limit ({max_size} bytes) for type {media_type}",
|
|
132
|
+
error_code="FILE_SIZE_EXCEEDED",
|
|
133
|
+
tenant_id=self._tenant_id,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Prepare upload data
|
|
137
|
+
data = {"messaging_product": "whatsapp", "type": media_type}
|
|
138
|
+
|
|
139
|
+
# Construct upload URL using client's URL builder
|
|
140
|
+
upload_url = self.client.url_builder.get_media_url()
|
|
141
|
+
|
|
142
|
+
self.logger.debug(f"Uploading media file {media_path.name} to {upload_url}")
|
|
143
|
+
|
|
144
|
+
with open(media_path, "rb") as file_handle:
|
|
145
|
+
files = {"file": (filename or media_path.name, file_handle, media_type)}
|
|
146
|
+
|
|
147
|
+
# Use the injected client for upload
|
|
148
|
+
result = await self.client.post_request(
|
|
149
|
+
payload=data, custom_url=upload_url, files=files
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
media_id = result.get("id")
|
|
153
|
+
if not media_id:
|
|
154
|
+
return MediaUploadResult(
|
|
155
|
+
success=False,
|
|
156
|
+
error=f"No media ID in response for {media_path.name}: {result}",
|
|
157
|
+
error_code="NO_MEDIA_ID",
|
|
158
|
+
tenant_id=self._tenant_id,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self.logger.info(
|
|
162
|
+
f"Successfully uploaded {media_path.name} (ID: {media_id})"
|
|
163
|
+
)
|
|
164
|
+
return MediaUploadResult(
|
|
165
|
+
success=True,
|
|
166
|
+
media_id=media_id,
|
|
167
|
+
file_size=file_size,
|
|
168
|
+
mime_type=media_type,
|
|
169
|
+
platform=PlatformType.WHATSAPP,
|
|
170
|
+
tenant_id=self._tenant_id,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
except Exception as e:
|
|
174
|
+
self.logger.exception(f"Failed to upload {file_path}: {e}")
|
|
175
|
+
return MediaUploadResult(
|
|
176
|
+
success=False,
|
|
177
|
+
error=str(e),
|
|
178
|
+
error_code="UPLOAD_FAILED",
|
|
179
|
+
tenant_id=self._tenant_id,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def upload_media_from_bytes(
|
|
183
|
+
self, file_data: bytes, media_type: str, filename: str
|
|
184
|
+
) -> MediaUploadResult:
|
|
185
|
+
"""Upload media from bytes data."""
|
|
186
|
+
try:
|
|
187
|
+
# Validate MIME type
|
|
188
|
+
if not self.validate_media_type(media_type):
|
|
189
|
+
return MediaUploadResult(
|
|
190
|
+
success=False,
|
|
191
|
+
error=f"Unsupported MIME type '{media_type}'. Supported types: {sorted(self.supported_media_types)}",
|
|
192
|
+
error_code="MIME_TYPE_UNSUPPORTED",
|
|
193
|
+
tenant_id=self._tenant_id,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Validate file size
|
|
197
|
+
file_size = len(file_data)
|
|
198
|
+
if not self.validate_file_size(file_size, media_type):
|
|
199
|
+
max_size = self._get_max_size_for_mime_type(media_type)
|
|
200
|
+
return MediaUploadResult(
|
|
201
|
+
success=False,
|
|
202
|
+
error=f"File size ({file_size} bytes) exceeds the limit ({max_size} bytes) for type {media_type}",
|
|
203
|
+
error_code="FILE_SIZE_EXCEEDED",
|
|
204
|
+
tenant_id=self._tenant_id,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Prepare upload data
|
|
208
|
+
data = {"messaging_product": "whatsapp", "type": media_type}
|
|
209
|
+
|
|
210
|
+
# Construct upload URL using client's URL builder
|
|
211
|
+
upload_url = self.client.url_builder.get_media_url()
|
|
212
|
+
|
|
213
|
+
self.logger.debug(f"Uploading media from bytes: {filename}")
|
|
214
|
+
|
|
215
|
+
files = {"file": (filename, file_data, media_type)}
|
|
216
|
+
|
|
217
|
+
result = await self.client.post_request(
|
|
218
|
+
payload=data, custom_url=upload_url, files=files
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
media_id = result.get("id")
|
|
222
|
+
if not media_id:
|
|
223
|
+
return MediaUploadResult(
|
|
224
|
+
success=False,
|
|
225
|
+
error=f"No media ID in response for {filename}: {result}",
|
|
226
|
+
error_code="NO_MEDIA_ID",
|
|
227
|
+
tenant_id=self._tenant_id,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
self.logger.info(
|
|
231
|
+
f"Successfully uploaded {filename} from bytes (ID: {media_id})"
|
|
232
|
+
)
|
|
233
|
+
return MediaUploadResult(
|
|
234
|
+
success=True,
|
|
235
|
+
media_id=media_id,
|
|
236
|
+
file_size=file_size,
|
|
237
|
+
mime_type=media_type,
|
|
238
|
+
platform=PlatformType.WHATSAPP,
|
|
239
|
+
tenant_id=self._tenant_id,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
self.logger.exception(f"Failed to upload {filename} from bytes: {e}")
|
|
244
|
+
return MediaUploadResult(
|
|
245
|
+
success=False,
|
|
246
|
+
error=str(e),
|
|
247
|
+
error_code="UPLOAD_FAILED",
|
|
248
|
+
tenant_id=self._tenant_id,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
async def upload_media_from_stream(
|
|
252
|
+
self,
|
|
253
|
+
file_stream: BinaryIO,
|
|
254
|
+
media_type: str,
|
|
255
|
+
filename: str,
|
|
256
|
+
file_size: int | None = None,
|
|
257
|
+
) -> MediaUploadResult:
|
|
258
|
+
"""Upload media from file stream."""
|
|
259
|
+
try:
|
|
260
|
+
# Read stream data
|
|
261
|
+
file_data = file_stream.read()
|
|
262
|
+
|
|
263
|
+
# Use the bytes upload method
|
|
264
|
+
return await self.upload_media_from_bytes(file_data, media_type, filename)
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
self.logger.exception(f"Failed to upload {filename} from stream: {e}")
|
|
268
|
+
return MediaUploadResult(
|
|
269
|
+
success=False,
|
|
270
|
+
error=str(e),
|
|
271
|
+
error_code="UPLOAD_FAILED",
|
|
272
|
+
tenant_id=self._tenant_id,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
async def get_media_info(self, media_id: str) -> MediaInfoResult:
|
|
276
|
+
"""
|
|
277
|
+
Retrieve media information using media ID.
|
|
278
|
+
|
|
279
|
+
Based on existing WhatsAppServiceMedia.get_media_url() method.
|
|
280
|
+
Implements GET /MEDIA_ID endpoint.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
endpoint = f"{media_id}/"
|
|
284
|
+
self.logger.debug(f"Fetching media info for ID: {media_id}")
|
|
285
|
+
|
|
286
|
+
result = await self.client.get_request(endpoint=endpoint)
|
|
287
|
+
|
|
288
|
+
if not result or "url" not in result:
|
|
289
|
+
return MediaInfoResult(
|
|
290
|
+
success=False,
|
|
291
|
+
error=f"Invalid response for media ID {media_id}: {result}",
|
|
292
|
+
error_code="INVALID_RESPONSE",
|
|
293
|
+
tenant_id=self._tenant_id,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
self.logger.info(f"Successfully retrieved media URL for ID: {media_id}")
|
|
297
|
+
return MediaInfoResult(
|
|
298
|
+
success=True,
|
|
299
|
+
media_id=media_id,
|
|
300
|
+
url=result.get("url"),
|
|
301
|
+
mime_type=result.get("mime_type"),
|
|
302
|
+
file_size=result.get("file_size"),
|
|
303
|
+
sha256=result.get("sha256"),
|
|
304
|
+
platform=PlatformType.WHATSAPP,
|
|
305
|
+
tenant_id=self._tenant_id,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
self.logger.exception(f"Error getting info for media ID {media_id}: {e}")
|
|
310
|
+
return MediaInfoResult(
|
|
311
|
+
success=False,
|
|
312
|
+
error=str(e),
|
|
313
|
+
error_code="INFO_RETRIEVAL_FAILED",
|
|
314
|
+
tenant_id=self._tenant_id,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def download_media(
|
|
318
|
+
self,
|
|
319
|
+
media_id: str,
|
|
320
|
+
destination_path: str | Path | None = None,
|
|
321
|
+
sender_id: str | None = None,
|
|
322
|
+
) -> MediaDownloadResult:
|
|
323
|
+
"""
|
|
324
|
+
Download WhatsApp media using its media ID.
|
|
325
|
+
|
|
326
|
+
Based on existing WhatsAppServiceMedia.download_media() method.
|
|
327
|
+
Implements workflow: GET /MEDIA_ID -> GET /MEDIA_URL
|
|
328
|
+
"""
|
|
329
|
+
try:
|
|
330
|
+
# Get media info first
|
|
331
|
+
media_info_result = await self.get_media_info(media_id)
|
|
332
|
+
if not media_info_result.success:
|
|
333
|
+
return MediaDownloadResult(
|
|
334
|
+
success=False,
|
|
335
|
+
error=f"Failed to get media URL for ID {media_id}: {media_info_result.error}",
|
|
336
|
+
error_code="MEDIA_INFO_FAILED",
|
|
337
|
+
tenant_id=self._tenant_id,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
media_url = media_info_result.url
|
|
341
|
+
content_type = media_info_result.mime_type
|
|
342
|
+
|
|
343
|
+
self.logger.debug(
|
|
344
|
+
f"Starting download for media ID: {media_id} from URL: {media_url}"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Use the client for streaming request
|
|
348
|
+
session, response = await self.client.get_request_stream(media_url)
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
if response.status != 200:
|
|
352
|
+
error_text = await response.text()
|
|
353
|
+
return MediaDownloadResult(
|
|
354
|
+
success=False,
|
|
355
|
+
error=f"Download failed for {media_id}: {response.status} - {error_text}",
|
|
356
|
+
error_code=f"HTTP_{response.status}",
|
|
357
|
+
tenant_id=self._tenant_id,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Validate content type and size
|
|
361
|
+
response_content_type = response.headers.get(
|
|
362
|
+
"content-type", content_type
|
|
363
|
+
)
|
|
364
|
+
content_length_str = response.headers.get("content-length", "0")
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
content_length = int(content_length_str)
|
|
368
|
+
except ValueError:
|
|
369
|
+
content_length = 0
|
|
370
|
+
|
|
371
|
+
# Validate against platform limits
|
|
372
|
+
if not self.validate_file_size(content_length, response_content_type):
|
|
373
|
+
max_size = self._get_max_size_for_mime_type(response_content_type)
|
|
374
|
+
return MediaDownloadResult(
|
|
375
|
+
success=False,
|
|
376
|
+
error=f"Media file size ({content_length} bytes) exceeds max allowed ({max_size} bytes) for type {response_content_type}",
|
|
377
|
+
error_code="FILE_SIZE_EXCEEDED",
|
|
378
|
+
tenant_id=self._tenant_id,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Read response data
|
|
382
|
+
data = bytearray()
|
|
383
|
+
downloaded_size = 0
|
|
384
|
+
max_size = self._get_max_size_for_mime_type(response_content_type)
|
|
385
|
+
|
|
386
|
+
async for chunk in response.content.iter_chunked(8192):
|
|
387
|
+
if chunk:
|
|
388
|
+
downloaded_size += len(chunk)
|
|
389
|
+
if downloaded_size > max_size:
|
|
390
|
+
return MediaDownloadResult(
|
|
391
|
+
success=False,
|
|
392
|
+
error=f"Download aborted: file size ({downloaded_size}) exceeded max ({max_size}) bytes for type {response_content_type}",
|
|
393
|
+
error_code="FILE_SIZE_EXCEEDED",
|
|
394
|
+
tenant_id=self._tenant_id,
|
|
395
|
+
)
|
|
396
|
+
data.extend(chunk)
|
|
397
|
+
|
|
398
|
+
# Save to file if destination_path provided
|
|
399
|
+
final_path = None
|
|
400
|
+
if destination_path:
|
|
401
|
+
extension_map = self._get_extension_map()
|
|
402
|
+
extension = extension_map.get(response_content_type, "")
|
|
403
|
+
media_type_base = response_content_type.split("/")[0]
|
|
404
|
+
timestamp = int(time.time())
|
|
405
|
+
filename_final = f"{media_type_base}_{sender_id or 'unknown'}_{timestamp}{extension}"
|
|
406
|
+
|
|
407
|
+
path = Path(destination_path)
|
|
408
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
final_path = path / filename_final
|
|
410
|
+
|
|
411
|
+
with open(final_path, "wb") as f:
|
|
412
|
+
f.write(data)
|
|
413
|
+
|
|
414
|
+
self.logger.info(
|
|
415
|
+
f"Media successfully downloaded to {final_path} ({downloaded_size} bytes)"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return MediaDownloadResult(
|
|
419
|
+
success=True,
|
|
420
|
+
file_data=bytes(data),
|
|
421
|
+
file_path=str(final_path) if final_path else None,
|
|
422
|
+
mime_type=response_content_type,
|
|
423
|
+
file_size=downloaded_size,
|
|
424
|
+
sha256=media_info_result.sha256,
|
|
425
|
+
platform=PlatformType.WHATSAPP,
|
|
426
|
+
tenant_id=self._tenant_id,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
finally:
|
|
430
|
+
# Ensure response is closed
|
|
431
|
+
if response and not response.closed:
|
|
432
|
+
response.release()
|
|
433
|
+
|
|
434
|
+
except Exception as e:
|
|
435
|
+
self.logger.exception(f"Error downloading media ID {media_id}: {e}")
|
|
436
|
+
return MediaDownloadResult(
|
|
437
|
+
success=False,
|
|
438
|
+
error=str(e),
|
|
439
|
+
error_code="DOWNLOAD_FAILED",
|
|
440
|
+
tenant_id=self._tenant_id,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
async def stream_media(
|
|
444
|
+
self, media_id: str, chunk_size: int = 8192
|
|
445
|
+
) -> AsyncIterator[bytes]:
|
|
446
|
+
"""Stream media by ID for large files."""
|
|
447
|
+
try:
|
|
448
|
+
# Get media info first
|
|
449
|
+
media_info_result = await self.get_media_info(media_id)
|
|
450
|
+
if not media_info_result.success:
|
|
451
|
+
raise RuntimeError(
|
|
452
|
+
f"Failed to get media URL for ID {media_id}: {media_info_result.error}"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
media_url = media_info_result.url
|
|
456
|
+
|
|
457
|
+
# Use the client for streaming request
|
|
458
|
+
session, response = await self.client.get_request_stream(media_url)
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
if response.status != 200:
|
|
462
|
+
error_text = await response.text()
|
|
463
|
+
raise RuntimeError(
|
|
464
|
+
f"Download failed for {media_id}: {response.status} - {error_text}"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
async for chunk in response.content.iter_chunked(chunk_size):
|
|
468
|
+
if chunk:
|
|
469
|
+
yield chunk
|
|
470
|
+
|
|
471
|
+
finally:
|
|
472
|
+
# Ensure response is closed
|
|
473
|
+
if response and not response.closed:
|
|
474
|
+
response.release()
|
|
475
|
+
|
|
476
|
+
except Exception as e:
|
|
477
|
+
self.logger.exception(f"Error streaming media ID {media_id}: {e}")
|
|
478
|
+
raise
|
|
479
|
+
|
|
480
|
+
async def delete_media(self, media_id: str) -> MediaDeleteResult:
|
|
481
|
+
"""
|
|
482
|
+
Delete media from WhatsApp servers using the media ID.
|
|
483
|
+
|
|
484
|
+
Based on existing WhatsAppServiceMedia.delete_media() method.
|
|
485
|
+
Implements DELETE /MEDIA_ID endpoint.
|
|
486
|
+
"""
|
|
487
|
+
try:
|
|
488
|
+
endpoint = f"{media_id}"
|
|
489
|
+
params = {}
|
|
490
|
+
|
|
491
|
+
self.logger.debug(f"Attempting to delete media ID: {media_id}")
|
|
492
|
+
|
|
493
|
+
result = await self.client.delete_request(endpoint=endpoint, params=params)
|
|
494
|
+
|
|
495
|
+
if result.get("success"):
|
|
496
|
+
self.logger.info(f"Successfully deleted media ID: {media_id}")
|
|
497
|
+
return MediaDeleteResult(
|
|
498
|
+
success=True,
|
|
499
|
+
media_id=media_id,
|
|
500
|
+
platform=PlatformType.WHATSAPP,
|
|
501
|
+
tenant_id=self._tenant_id,
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
error_msg = result.get("error", {}).get("message", "Unknown reason")
|
|
505
|
+
return MediaDeleteResult(
|
|
506
|
+
success=False,
|
|
507
|
+
media_id=media_id,
|
|
508
|
+
error=f"API indicated deletion failed: {error_msg}",
|
|
509
|
+
error_code="DELETION_FAILED",
|
|
510
|
+
platform=PlatformType.WHATSAPP,
|
|
511
|
+
tenant_id=self._tenant_id,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
except Exception as e:
|
|
515
|
+
self.logger.exception(f"Error deleting media ID {media_id}: {e}")
|
|
516
|
+
return MediaDeleteResult(
|
|
517
|
+
success=False,
|
|
518
|
+
media_id=media_id,
|
|
519
|
+
error=str(e),
|
|
520
|
+
error_code="DELETION_FAILED",
|
|
521
|
+
platform=PlatformType.WHATSAPP,
|
|
522
|
+
tenant_id=self._tenant_id,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
def validate_media_type(self, mime_type: str) -> bool:
|
|
526
|
+
"""Validate if MIME type is supported by WhatsApp."""
|
|
527
|
+
return mime_type in self.supported_media_types
|
|
528
|
+
|
|
529
|
+
def validate_file_size(self, file_size: int, mime_type: str) -> bool:
|
|
530
|
+
"""Validate if file size is within WhatsApp limits."""
|
|
531
|
+
max_size = self._get_max_size_for_mime_type(mime_type)
|
|
532
|
+
return file_size <= max_size
|
|
533
|
+
|
|
534
|
+
def get_media_limits(self) -> dict[str, Any]:
|
|
535
|
+
"""Get WhatsApp-specific media limits and constraints."""
|
|
536
|
+
return {
|
|
537
|
+
"max_sizes": self.max_file_size,
|
|
538
|
+
"supported_types": sorted(self.supported_media_types),
|
|
539
|
+
"url_expiry_minutes": 5,
|
|
540
|
+
"media_persistence_days": 30,
|
|
541
|
+
"platform": "whatsapp",
|
|
542
|
+
"api_version": self.client.api_version,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
def _get_max_size_for_mime_type(self, mime_type: str) -> int:
|
|
546
|
+
"""Get maximum file size for a specific MIME type."""
|
|
547
|
+
if mime_type.startswith("audio/") or mime_type.startswith("video/"):
|
|
548
|
+
return 16 * 1024 * 1024 # 16MB
|
|
549
|
+
elif mime_type.startswith("image/"):
|
|
550
|
+
if mime_type == "image/webp":
|
|
551
|
+
return 500 * 1024 # 500KB for animated stickers
|
|
552
|
+
return 5 * 1024 * 1024 # 5MB for regular images
|
|
553
|
+
elif mime_type.startswith("application/") or mime_type == "text/plain":
|
|
554
|
+
return 100 * 1024 * 1024 # 100MB
|
|
555
|
+
else:
|
|
556
|
+
return 100 * 1024 * 1024 # Default to 100MB
|
|
557
|
+
|
|
558
|
+
def _get_extension_map(self) -> dict[str, str]:
|
|
559
|
+
"""Get file extension mapping by MIME type."""
|
|
560
|
+
return {
|
|
561
|
+
"audio/aac": ".aac",
|
|
562
|
+
"audio/amr": ".amr",
|
|
563
|
+
"audio/mpeg": ".mp3",
|
|
564
|
+
"audio/mp4": ".m4a",
|
|
565
|
+
"audio/ogg": ".ogg",
|
|
566
|
+
"text/plain": ".txt",
|
|
567
|
+
"application/vnd.ms-excel": ".xls",
|
|
568
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
569
|
+
"application/msword": ".doc",
|
|
570
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
571
|
+
"application/vnd.ms-powerpoint": ".ppt",
|
|
572
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
573
|
+
"application/pdf": ".pdf",
|
|
574
|
+
"image/jpeg": ".jpg",
|
|
575
|
+
"image/png": ".png",
|
|
576
|
+
"image/webp": ".webp",
|
|
577
|
+
"video/3gpp": ".3gp",
|
|
578
|
+
"video/mp4": ".mp4",
|
|
579
|
+
}
|