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.

Files changed (211) hide show
  1. wappa/__init__.py +85 -0
  2. wappa/api/__init__.py +1 -0
  3. wappa/api/controllers/__init__.py +10 -0
  4. wappa/api/controllers/webhook_controller.py +441 -0
  5. wappa/api/dependencies/__init__.py +15 -0
  6. wappa/api/dependencies/whatsapp_dependencies.py +220 -0
  7. wappa/api/dependencies/whatsapp_media_dependencies.py +26 -0
  8. wappa/api/middleware/__init__.py +7 -0
  9. wappa/api/middleware/error_handler.py +158 -0
  10. wappa/api/middleware/owner.py +99 -0
  11. wappa/api/middleware/request_logging.py +184 -0
  12. wappa/api/routes/__init__.py +6 -0
  13. wappa/api/routes/health.py +102 -0
  14. wappa/api/routes/webhooks.py +211 -0
  15. wappa/api/routes/whatsapp/__init__.py +15 -0
  16. wappa/api/routes/whatsapp/whatsapp_interactive.py +429 -0
  17. wappa/api/routes/whatsapp/whatsapp_media.py +440 -0
  18. wappa/api/routes/whatsapp/whatsapp_messages.py +195 -0
  19. wappa/api/routes/whatsapp/whatsapp_specialized.py +516 -0
  20. wappa/api/routes/whatsapp/whatsapp_templates.py +431 -0
  21. wappa/api/routes/whatsapp_combined.py +35 -0
  22. wappa/cli/__init__.py +9 -0
  23. wappa/cli/main.py +199 -0
  24. wappa/core/__init__.py +6 -0
  25. wappa/core/config/__init__.py +5 -0
  26. wappa/core/config/settings.py +161 -0
  27. wappa/core/events/__init__.py +41 -0
  28. wappa/core/events/default_handlers.py +642 -0
  29. wappa/core/events/event_dispatcher.py +244 -0
  30. wappa/core/events/event_handler.py +247 -0
  31. wappa/core/events/webhook_factory.py +219 -0
  32. wappa/core/factory/__init__.py +15 -0
  33. wappa/core/factory/plugin.py +68 -0
  34. wappa/core/factory/wappa_builder.py +326 -0
  35. wappa/core/logging/__init__.py +5 -0
  36. wappa/core/logging/context.py +100 -0
  37. wappa/core/logging/logger.py +343 -0
  38. wappa/core/plugins/__init__.py +34 -0
  39. wappa/core/plugins/auth_plugin.py +169 -0
  40. wappa/core/plugins/cors_plugin.py +128 -0
  41. wappa/core/plugins/custom_middleware_plugin.py +182 -0
  42. wappa/core/plugins/database_plugin.py +235 -0
  43. wappa/core/plugins/rate_limit_plugin.py +183 -0
  44. wappa/core/plugins/redis_plugin.py +224 -0
  45. wappa/core/plugins/wappa_core_plugin.py +261 -0
  46. wappa/core/plugins/webhook_plugin.py +253 -0
  47. wappa/core/types.py +108 -0
  48. wappa/core/wappa_app.py +546 -0
  49. wappa/database/__init__.py +18 -0
  50. wappa/database/adapter.py +107 -0
  51. wappa/database/adapters/__init__.py +17 -0
  52. wappa/database/adapters/mysql_adapter.py +187 -0
  53. wappa/database/adapters/postgresql_adapter.py +169 -0
  54. wappa/database/adapters/sqlite_adapter.py +174 -0
  55. wappa/domain/__init__.py +28 -0
  56. wappa/domain/builders/__init__.py +5 -0
  57. wappa/domain/builders/message_builder.py +189 -0
  58. wappa/domain/entities/__init__.py +5 -0
  59. wappa/domain/enums/messenger_platform.py +123 -0
  60. wappa/domain/factories/__init__.py +6 -0
  61. wappa/domain/factories/media_factory.py +450 -0
  62. wappa/domain/factories/message_factory.py +497 -0
  63. wappa/domain/factories/messenger_factory.py +244 -0
  64. wappa/domain/interfaces/__init__.py +32 -0
  65. wappa/domain/interfaces/base_repository.py +94 -0
  66. wappa/domain/interfaces/cache_factory.py +85 -0
  67. wappa/domain/interfaces/cache_interface.py +199 -0
  68. wappa/domain/interfaces/expiry_repository.py +68 -0
  69. wappa/domain/interfaces/media_interface.py +311 -0
  70. wappa/domain/interfaces/messaging_interface.py +523 -0
  71. wappa/domain/interfaces/pubsub_repository.py +151 -0
  72. wappa/domain/interfaces/repository_factory.py +108 -0
  73. wappa/domain/interfaces/shared_state_repository.py +122 -0
  74. wappa/domain/interfaces/state_repository.py +123 -0
  75. wappa/domain/interfaces/tables_repository.py +215 -0
  76. wappa/domain/interfaces/user_repository.py +114 -0
  77. wappa/domain/interfaces/webhooks/__init__.py +1 -0
  78. wappa/domain/models/media_result.py +110 -0
  79. wappa/domain/models/platforms/__init__.py +15 -0
  80. wappa/domain/models/platforms/platform_config.py +104 -0
  81. wappa/domain/services/__init__.py +11 -0
  82. wappa/domain/services/tenant_credentials_service.py +56 -0
  83. wappa/messaging/__init__.py +7 -0
  84. wappa/messaging/whatsapp/__init__.py +1 -0
  85. wappa/messaging/whatsapp/client/__init__.py +5 -0
  86. wappa/messaging/whatsapp/client/whatsapp_client.py +417 -0
  87. wappa/messaging/whatsapp/handlers/__init__.py +13 -0
  88. wappa/messaging/whatsapp/handlers/whatsapp_interactive_handler.py +653 -0
  89. wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +579 -0
  90. wappa/messaging/whatsapp/handlers/whatsapp_specialized_handler.py +434 -0
  91. wappa/messaging/whatsapp/handlers/whatsapp_template_handler.py +416 -0
  92. wappa/messaging/whatsapp/messenger/__init__.py +5 -0
  93. wappa/messaging/whatsapp/messenger/whatsapp_messenger.py +904 -0
  94. wappa/messaging/whatsapp/models/__init__.py +61 -0
  95. wappa/messaging/whatsapp/models/basic_models.py +65 -0
  96. wappa/messaging/whatsapp/models/interactive_models.py +287 -0
  97. wappa/messaging/whatsapp/models/media_models.py +215 -0
  98. wappa/messaging/whatsapp/models/specialized_models.py +304 -0
  99. wappa/messaging/whatsapp/models/template_models.py +261 -0
  100. wappa/persistence/cache_factory.py +93 -0
  101. wappa/persistence/json/__init__.py +14 -0
  102. wappa/persistence/json/cache_adapters.py +271 -0
  103. wappa/persistence/json/handlers/__init__.py +1 -0
  104. wappa/persistence/json/handlers/state_handler.py +250 -0
  105. wappa/persistence/json/handlers/table_handler.py +263 -0
  106. wappa/persistence/json/handlers/user_handler.py +213 -0
  107. wappa/persistence/json/handlers/utils/__init__.py +1 -0
  108. wappa/persistence/json/handlers/utils/file_manager.py +153 -0
  109. wappa/persistence/json/handlers/utils/key_factory.py +11 -0
  110. wappa/persistence/json/handlers/utils/serialization.py +121 -0
  111. wappa/persistence/json/json_cache_factory.py +76 -0
  112. wappa/persistence/json/storage_manager.py +285 -0
  113. wappa/persistence/memory/__init__.py +14 -0
  114. wappa/persistence/memory/cache_adapters.py +271 -0
  115. wappa/persistence/memory/handlers/__init__.py +1 -0
  116. wappa/persistence/memory/handlers/state_handler.py +250 -0
  117. wappa/persistence/memory/handlers/table_handler.py +280 -0
  118. wappa/persistence/memory/handlers/user_handler.py +213 -0
  119. wappa/persistence/memory/handlers/utils/__init__.py +1 -0
  120. wappa/persistence/memory/handlers/utils/key_factory.py +11 -0
  121. wappa/persistence/memory/handlers/utils/memory_store.py +317 -0
  122. wappa/persistence/memory/handlers/utils/ttl_manager.py +235 -0
  123. wappa/persistence/memory/memory_cache_factory.py +76 -0
  124. wappa/persistence/memory/storage_manager.py +235 -0
  125. wappa/persistence/redis/README.md +699 -0
  126. wappa/persistence/redis/__init__.py +11 -0
  127. wappa/persistence/redis/cache_adapters.py +285 -0
  128. wappa/persistence/redis/ops.py +880 -0
  129. wappa/persistence/redis/redis_cache_factory.py +71 -0
  130. wappa/persistence/redis/redis_client.py +231 -0
  131. wappa/persistence/redis/redis_handler/__init__.py +26 -0
  132. wappa/persistence/redis/redis_handler/state_handler.py +176 -0
  133. wappa/persistence/redis/redis_handler/table.py +158 -0
  134. wappa/persistence/redis/redis_handler/user.py +138 -0
  135. wappa/persistence/redis/redis_handler/utils/__init__.py +12 -0
  136. wappa/persistence/redis/redis_handler/utils/key_factory.py +32 -0
  137. wappa/persistence/redis/redis_handler/utils/serde.py +146 -0
  138. wappa/persistence/redis/redis_handler/utils/tenant_cache.py +268 -0
  139. wappa/persistence/redis/redis_manager.py +189 -0
  140. wappa/processors/__init__.py +6 -0
  141. wappa/processors/base_processor.py +262 -0
  142. wappa/processors/factory.py +550 -0
  143. wappa/processors/whatsapp_processor.py +810 -0
  144. wappa/schemas/__init__.py +6 -0
  145. wappa/schemas/core/__init__.py +71 -0
  146. wappa/schemas/core/base_message.py +499 -0
  147. wappa/schemas/core/base_status.py +322 -0
  148. wappa/schemas/core/base_webhook.py +312 -0
  149. wappa/schemas/core/types.py +253 -0
  150. wappa/schemas/core/webhook_interfaces/__init__.py +48 -0
  151. wappa/schemas/core/webhook_interfaces/base_components.py +293 -0
  152. wappa/schemas/core/webhook_interfaces/universal_webhooks.py +348 -0
  153. wappa/schemas/factory.py +754 -0
  154. wappa/schemas/webhooks/__init__.py +3 -0
  155. wappa/schemas/whatsapp/__init__.py +6 -0
  156. wappa/schemas/whatsapp/base_models.py +285 -0
  157. wappa/schemas/whatsapp/message_types/__init__.py +93 -0
  158. wappa/schemas/whatsapp/message_types/audio.py +350 -0
  159. wappa/schemas/whatsapp/message_types/button.py +267 -0
  160. wappa/schemas/whatsapp/message_types/contact.py +464 -0
  161. wappa/schemas/whatsapp/message_types/document.py +421 -0
  162. wappa/schemas/whatsapp/message_types/errors.py +195 -0
  163. wappa/schemas/whatsapp/message_types/image.py +424 -0
  164. wappa/schemas/whatsapp/message_types/interactive.py +430 -0
  165. wappa/schemas/whatsapp/message_types/location.py +416 -0
  166. wappa/schemas/whatsapp/message_types/order.py +372 -0
  167. wappa/schemas/whatsapp/message_types/reaction.py +271 -0
  168. wappa/schemas/whatsapp/message_types/sticker.py +328 -0
  169. wappa/schemas/whatsapp/message_types/system.py +317 -0
  170. wappa/schemas/whatsapp/message_types/text.py +411 -0
  171. wappa/schemas/whatsapp/message_types/unsupported.py +273 -0
  172. wappa/schemas/whatsapp/message_types/video.py +344 -0
  173. wappa/schemas/whatsapp/status_models.py +479 -0
  174. wappa/schemas/whatsapp/validators.py +454 -0
  175. wappa/schemas/whatsapp/webhook_container.py +438 -0
  176. wappa/webhooks/__init__.py +17 -0
  177. wappa/webhooks/core/__init__.py +71 -0
  178. wappa/webhooks/core/base_message.py +499 -0
  179. wappa/webhooks/core/base_status.py +322 -0
  180. wappa/webhooks/core/base_webhook.py +312 -0
  181. wappa/webhooks/core/types.py +253 -0
  182. wappa/webhooks/core/webhook_interfaces/__init__.py +48 -0
  183. wappa/webhooks/core/webhook_interfaces/base_components.py +293 -0
  184. wappa/webhooks/core/webhook_interfaces/universal_webhooks.py +441 -0
  185. wappa/webhooks/factory.py +754 -0
  186. wappa/webhooks/whatsapp/__init__.py +6 -0
  187. wappa/webhooks/whatsapp/base_models.py +285 -0
  188. wappa/webhooks/whatsapp/message_types/__init__.py +93 -0
  189. wappa/webhooks/whatsapp/message_types/audio.py +350 -0
  190. wappa/webhooks/whatsapp/message_types/button.py +267 -0
  191. wappa/webhooks/whatsapp/message_types/contact.py +464 -0
  192. wappa/webhooks/whatsapp/message_types/document.py +421 -0
  193. wappa/webhooks/whatsapp/message_types/errors.py +195 -0
  194. wappa/webhooks/whatsapp/message_types/image.py +424 -0
  195. wappa/webhooks/whatsapp/message_types/interactive.py +430 -0
  196. wappa/webhooks/whatsapp/message_types/location.py +416 -0
  197. wappa/webhooks/whatsapp/message_types/order.py +372 -0
  198. wappa/webhooks/whatsapp/message_types/reaction.py +271 -0
  199. wappa/webhooks/whatsapp/message_types/sticker.py +328 -0
  200. wappa/webhooks/whatsapp/message_types/system.py +317 -0
  201. wappa/webhooks/whatsapp/message_types/text.py +411 -0
  202. wappa/webhooks/whatsapp/message_types/unsupported.py +273 -0
  203. wappa/webhooks/whatsapp/message_types/video.py +344 -0
  204. wappa/webhooks/whatsapp/status_models.py +479 -0
  205. wappa/webhooks/whatsapp/validators.py +454 -0
  206. wappa/webhooks/whatsapp/webhook_container.py +438 -0
  207. wappa-0.1.0.dist-info/METADATA +269 -0
  208. wappa-0.1.0.dist-info/RECORD +211 -0
  209. wappa-0.1.0.dist-info/WHEEL +4 -0
  210. wappa-0.1.0.dist-info/entry_points.txt +2 -0
  211. wappa-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,350 @@
1
+ """
2
+ WhatsApp audio message schema.
3
+
4
+ This module contains Pydantic models for processing WhatsApp audio messages,
5
+ including voice recordings and audio files sent via Click-to-WhatsApp ads.
6
+ """
7
+
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
11
+
12
+ from wappa.webhooks.core.base_message import BaseAudioMessage, BaseMessageContext
13
+ from wappa.webhooks.core.types import (
14
+ ConversationType,
15
+ MediaType,
16
+ PlatformType,
17
+ UniversalMessageData,
18
+ )
19
+ from wappa.webhooks.whatsapp.base_models import AdReferral, MessageContext
20
+
21
+
22
+ class AudioContent(BaseModel):
23
+ """Audio message content."""
24
+
25
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
26
+
27
+ mime_type: str = Field(
28
+ ..., description="MIME type of the audio file (e.g., 'audio/ogg; codecs=opus')"
29
+ )
30
+ sha256: str = Field(..., description="SHA-256 hash of the audio file")
31
+ id: str = Field(..., description="Media asset ID for retrieving the audio file")
32
+ voice: bool = Field(
33
+ ..., description="True if audio is a voice recording, False if audio file"
34
+ )
35
+
36
+ @field_validator("mime_type")
37
+ @classmethod
38
+ def validate_mime_type(cls, v: str) -> str:
39
+ """Validate audio MIME type format."""
40
+ if not v.startswith("audio/"):
41
+ raise ValueError("Audio MIME type must start with 'audio/'")
42
+ return v.lower()
43
+
44
+ @field_validator("id")
45
+ @classmethod
46
+ def validate_media_id(cls, v: str) -> str:
47
+ """Validate media asset ID."""
48
+ if not v or len(v) < 10:
49
+ raise ValueError("Media asset ID must be at least 10 characters")
50
+ return v
51
+
52
+
53
+ class WhatsAppAudioMessage(BaseAudioMessage):
54
+ """
55
+ WhatsApp audio message model.
56
+
57
+ Supports various audio message scenarios:
58
+ - Voice recordings from WhatsApp client
59
+ - Audio file uploads
60
+ - Click-to-WhatsApp ad audio messages
61
+ """
62
+
63
+ model_config = ConfigDict(
64
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
65
+ )
66
+
67
+ # Standard message fields
68
+ from_: str = Field(
69
+ ..., alias="from", description="WhatsApp user phone number who sent the message"
70
+ )
71
+ id: str = Field(..., description="Unique WhatsApp message ID")
72
+ timestamp_str: str = Field(
73
+ ..., alias="timestamp", description="Unix timestamp when the message was sent"
74
+ )
75
+ type: Literal["audio"] = Field(
76
+ ..., description="Message type, always 'audio' for audio messages"
77
+ )
78
+
79
+ # Audio content
80
+ audio: AudioContent = Field(..., description="Audio message content and metadata")
81
+
82
+ # Optional context fields
83
+ context: MessageContext | None = Field(
84
+ None, description="Context for forwards (audio messages don't support replies)"
85
+ )
86
+ referral: AdReferral | None = Field(
87
+ None, description="Click-to-WhatsApp ad referral information"
88
+ )
89
+
90
+ @field_validator("from_")
91
+ @classmethod
92
+ def validate_from_phone(cls, v: str) -> str:
93
+ """Validate sender phone number format."""
94
+ if not v or len(v) < 8:
95
+ raise ValueError("Sender phone number must be at least 8 characters")
96
+ # Remove common prefixes and validate numeric
97
+ phone = v.replace("+", "").replace("-", "").replace(" ", "")
98
+ if not phone.isdigit():
99
+ raise ValueError("Phone number must contain only digits (and +)")
100
+ return v
101
+
102
+ @field_validator("id")
103
+ @classmethod
104
+ def validate_message_id(cls, v: str) -> str:
105
+ """Validate WhatsApp message ID format."""
106
+ if not v or len(v) < 10:
107
+ raise ValueError("WhatsApp message ID must be at least 10 characters")
108
+ # WhatsApp message IDs typically start with 'wamid.'
109
+ if not v.startswith("wamid."):
110
+ raise ValueError("WhatsApp message ID should start with 'wamid.'")
111
+ return v
112
+
113
+ @field_validator("timestamp_str")
114
+ @classmethod
115
+ def validate_timestamp(cls, v: str) -> str:
116
+ """Validate Unix timestamp format."""
117
+ if not v.isdigit():
118
+ raise ValueError("Timestamp must be numeric")
119
+ # Validate reasonable timestamp range (after 2020, before 2100)
120
+ timestamp_int = int(v)
121
+ if timestamp_int < 1577836800 or timestamp_int > 4102444800:
122
+ raise ValueError("Timestamp must be a valid Unix timestamp")
123
+ return v
124
+
125
+ @model_validator(mode="after")
126
+ def validate_message_consistency(self):
127
+ """Validate message field consistency."""
128
+ # If we have a referral, this should be from an ad
129
+ if self.referral and self.context:
130
+ raise ValueError(
131
+ "Message cannot have both referral (ad) and context (forward)"
132
+ )
133
+
134
+ # Audio messages typically only support forwarding context, not replies
135
+ if (
136
+ self.context
137
+ and self.context.id
138
+ and not (self.context.forwarded or self.context.frequently_forwarded)
139
+ ):
140
+ raise ValueError(
141
+ "Audio messages do not support reply context, only forwarding"
142
+ )
143
+
144
+ return self
145
+
146
+ @property
147
+ def is_forwarded(self) -> bool:
148
+ """Check if this audio message was forwarded."""
149
+ return self.context is not None and (
150
+ self.context.forwarded or self.context.frequently_forwarded
151
+ )
152
+
153
+ @property
154
+ def is_frequently_forwarded(self) -> bool:
155
+ """Check if this audio message was forwarded more than 5 times."""
156
+ return self.context is not None and self.context.frequently_forwarded is True
157
+
158
+ @property
159
+ def is_voice_recording(self) -> bool:
160
+ """Check if this is a voice recording made with WhatsApp client."""
161
+ return self.audio.voice
162
+
163
+ @property
164
+ def is_audio_file(self) -> bool:
165
+ """Check if this is an uploaded audio file (not voice recording)."""
166
+ return not self.audio.voice
167
+
168
+ @property
169
+ def is_ad_message(self) -> bool:
170
+ """Check if this audio message came from a Click-to-WhatsApp ad."""
171
+ return self.referral is not None
172
+
173
+ @property
174
+ def sender_phone(self) -> str:
175
+ """Get the sender's phone number (clean accessor)."""
176
+ return self.from_
177
+
178
+ @property
179
+ def media_id(self) -> str:
180
+ """Get the media asset ID for downloading the audio file."""
181
+ return self.audio.id
182
+
183
+ @property
184
+ def mime_type(self) -> str:
185
+ """Get the audio MIME type."""
186
+ return self.audio.mime_type
187
+
188
+ @property
189
+ def file_hash(self) -> str:
190
+ """Get the SHA-256 hash of the audio file."""
191
+ return self.audio.sha256
192
+
193
+ @property
194
+ def unix_timestamp(self) -> int:
195
+ """Get the timestamp as an integer."""
196
+ return self.timestamp
197
+
198
+ def get_ad_context(self) -> tuple[str | None, str | None]:
199
+ """
200
+ Get ad context information for Click-to-WhatsApp audio messages.
201
+
202
+ Returns:
203
+ Tuple of (ad_id, ad_click_id) if this came from an ad,
204
+ (None, None) otherwise.
205
+ """
206
+ if self.is_ad_message and self.referral:
207
+ return (self.referral.source_id, self.referral.ctwa_clid)
208
+ return (None, None)
209
+
210
+ def to_summary_dict(self) -> dict[str, str | bool | int]:
211
+ """
212
+ Create a summary dictionary for logging and analysis.
213
+
214
+ Returns:
215
+ Dictionary with key message information for structured logging.
216
+ """
217
+ return {
218
+ "message_id": self.id,
219
+ "sender": self.sender_phone,
220
+ "timestamp": self.unix_timestamp,
221
+ "type": self.type,
222
+ "media_id": self.media_id,
223
+ "mime_type": self.mime_type,
224
+ "is_voice_recording": self.is_voice_recording(),
225
+ "is_forwarded": self.is_forwarded,
226
+ "is_frequently_forwarded": self.is_frequently_forwarded,
227
+ "is_ad_message": self.is_ad_message,
228
+ }
229
+
230
+ # Implement abstract methods from BaseMessage
231
+
232
+ @property
233
+ def platform(self) -> PlatformType:
234
+ return PlatformType.WHATSAPP
235
+
236
+ @property
237
+ def message_id(self) -> str:
238
+ return self.id
239
+
240
+ @property
241
+ def sender_id(self) -> str:
242
+ return self.from_
243
+
244
+ @property
245
+ def timestamp(self) -> int:
246
+ return int(self.timestamp_str)
247
+
248
+ @property
249
+ def conversation_id(self) -> str:
250
+ return self.from_
251
+
252
+ @property
253
+ def conversation_type(self) -> ConversationType:
254
+ return ConversationType.PRIVATE
255
+
256
+ def has_context(self) -> bool:
257
+ return self.context is not None
258
+
259
+ def get_context(self) -> BaseMessageContext | None:
260
+ from .text import WhatsAppMessageContext
261
+
262
+ return WhatsAppMessageContext(self.context) if self.context else None
263
+
264
+ def to_universal_dict(self) -> UniversalMessageData:
265
+ return {
266
+ "platform": self.platform.value,
267
+ "message_type": self.message_type.value,
268
+ "message_id": self.message_id,
269
+ "sender_id": self.sender_id,
270
+ "conversation_id": self.conversation_id,
271
+ "conversation_type": self.conversation_type.value,
272
+ "timestamp": self.timestamp,
273
+ "processed_at": self.processed_at.isoformat(),
274
+ "has_context": self.has_context(),
275
+ "media_id": self.media_id,
276
+ "media_type": self.media_type.value,
277
+ "file_size": self.file_size,
278
+ "caption": self.caption,
279
+ "is_voice_message": self.is_voice_message(),
280
+ "duration": self.duration,
281
+ "whatsapp_data": {
282
+ "whatsapp_id": self.id,
283
+ "from": self.from_,
284
+ "timestamp_str": self.timestamp_str,
285
+ "type": self.type,
286
+ "audio_content": self.audio.model_dump(),
287
+ "context": self.context.model_dump() if self.context else None,
288
+ "referral": self.referral.model_dump() if self.referral else None,
289
+ },
290
+ }
291
+
292
+ def get_platform_data(self) -> dict[str, Any]:
293
+ return {
294
+ "whatsapp_message_id": self.id,
295
+ "from_phone": self.from_,
296
+ "timestamp_str": self.timestamp_str,
297
+ "message_type": self.type,
298
+ "audio_content": self.audio.model_dump(),
299
+ "context": self.context.model_dump() if self.context else None,
300
+ "referral": self.referral.model_dump() if self.referral else None,
301
+ "is_voice_recording": self.is_voice_recording(),
302
+ }
303
+
304
+ # Implement abstract methods from BaseMediaMessage
305
+
306
+ @property
307
+ def media_id(self) -> str:
308
+ return self.audio.id
309
+
310
+ @property
311
+ def media_type(self) -> MediaType:
312
+ mime_str = self.audio.mime_type
313
+ try:
314
+ return MediaType(mime_str)
315
+ except ValueError:
316
+ return MediaType.AUDIO_OGG
317
+
318
+ @property
319
+ def file_size(self) -> int | None:
320
+ return None
321
+
322
+ @property
323
+ def caption(self) -> str | None:
324
+ return None # Audio messages don't have captions
325
+
326
+ def get_download_info(self) -> dict[str, Any]:
327
+ return {
328
+ "media_id": self.media_id,
329
+ "mime_type": self.media_type.value,
330
+ "sha256": self.audio.sha256,
331
+ "platform": "whatsapp",
332
+ "requires_auth": True,
333
+ "download_method": "whatsapp_media_api",
334
+ }
335
+
336
+ # Implement abstract methods from BaseAudioMessage
337
+
338
+ @property
339
+ def is_voice_message(self) -> bool:
340
+ return self.audio.voice
341
+
342
+ @property
343
+ def duration(self) -> int | None:
344
+ return None # WhatsApp doesn't provide duration in webhooks
345
+
346
+ @classmethod
347
+ def from_platform_data(
348
+ cls, data: dict[str, Any], **kwargs
349
+ ) -> "WhatsAppAudioMessage":
350
+ return cls.model_validate(data)
@@ -0,0 +1,267 @@
1
+ """
2
+ WhatsApp button message schema.
3
+
4
+ This module contains Pydantic models for processing WhatsApp button reply messages,
5
+ which are sent when users tap quick-reply buttons in template messages.
6
+ """
7
+
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
11
+
12
+ from wappa.webhooks.core.base_message import BaseMessage, BaseMessageContext
13
+ from wappa.webhooks.core.types import (
14
+ ConversationType,
15
+ MessageType,
16
+ PlatformType,
17
+ UniversalMessageData,
18
+ )
19
+ from wappa.webhooks.whatsapp.base_models import MessageContext
20
+
21
+
22
+ class ButtonContent(BaseModel):
23
+ """Button reply content."""
24
+
25
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
26
+
27
+ payload: str = Field(..., description="Button payload data")
28
+ text: str = Field(
29
+ ..., description="Button label text that was displayed to the user"
30
+ )
31
+
32
+ @field_validator("payload", "text")
33
+ @classmethod
34
+ def validate_button_fields(cls, v: str) -> str:
35
+ """Validate button fields are not empty."""
36
+ if not v.strip():
37
+ raise ValueError("Button payload and text cannot be empty")
38
+ return v.strip()
39
+
40
+
41
+ class WhatsAppButtonMessage(BaseMessage):
42
+ """
43
+ WhatsApp button message model.
44
+
45
+ Represents a user's response to tapping a quick-reply button in a template message.
46
+ These messages always include context information linking to the original message.
47
+ """
48
+
49
+ model_config = ConfigDict(
50
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
51
+ )
52
+
53
+ # Standard message fields
54
+ from_: str = Field(
55
+ ..., alias="from", description="WhatsApp user phone number who sent the message"
56
+ )
57
+ id: str = Field(..., description="Unique WhatsApp message ID")
58
+ timestamp_str: str = Field(
59
+ ..., alias="timestamp", description="Unix timestamp when the message was sent"
60
+ )
61
+ type: Literal["button"] = Field(
62
+ ..., description="Message type, always 'button' for button messages"
63
+ )
64
+
65
+ # Button content
66
+ button: ButtonContent = Field(..., description="Button reply content and metadata")
67
+
68
+ # Required context (button messages always have context)
69
+ context: MessageContext = Field(
70
+ ..., description="Context linking to the original message with the button"
71
+ )
72
+
73
+ @field_validator("from_")
74
+ @classmethod
75
+ def validate_from_phone(cls, v: str) -> str:
76
+ """Validate sender phone number format."""
77
+ if not v or len(v) < 8:
78
+ raise ValueError("Sender phone number must be at least 8 characters")
79
+ # Remove common prefixes and validate numeric
80
+ phone = v.replace("+", "").replace("-", "").replace(" ", "")
81
+ if not phone.isdigit():
82
+ raise ValueError("Phone number must contain only digits (and +)")
83
+ return v
84
+
85
+ @field_validator("id")
86
+ @classmethod
87
+ def validate_message_id(cls, v: str) -> str:
88
+ """Validate WhatsApp message ID format."""
89
+ if not v or len(v) < 10:
90
+ raise ValueError("WhatsApp message ID must be at least 10 characters")
91
+ # WhatsApp message IDs typically start with 'wamid.'
92
+ if not v.startswith("wamid."):
93
+ raise ValueError("WhatsApp message ID should start with 'wamid.'")
94
+ return v
95
+
96
+ @field_validator("timestamp_str")
97
+ @classmethod
98
+ def validate_timestamp(cls, v: str) -> str:
99
+ """Validate Unix timestamp format."""
100
+ if not v.isdigit():
101
+ raise ValueError("Timestamp must be numeric")
102
+ # Validate reasonable timestamp range (after 2020, before 2100)
103
+ timestamp_int = int(v)
104
+ if timestamp_int < 1577836800 or timestamp_int > 4102444800:
105
+ raise ValueError("Timestamp must be a valid Unix timestamp")
106
+ return v
107
+
108
+ @model_validator(mode="after")
109
+ def validate_button_message_context(self):
110
+ """Validate button message has proper context."""
111
+ # Button messages must have context with message ID and sender
112
+ if not self.context.id:
113
+ raise ValueError(
114
+ "Button messages must have context with original message ID"
115
+ )
116
+
117
+ if not self.context.from_:
118
+ raise ValueError("Button messages must have context with original sender")
119
+
120
+ # Button messages should not have forwarding or product context
121
+ if self.context.forwarded or self.context.frequently_forwarded:
122
+ raise ValueError("Button messages cannot be forwarded")
123
+
124
+ if self.context.referred_product:
125
+ raise ValueError("Button messages should not have product context")
126
+
127
+ return self
128
+
129
+ @property
130
+ def sender_phone(self) -> str:
131
+ """Get the sender's phone number (clean accessor)."""
132
+ return self.from_
133
+
134
+ @property
135
+ def button_text(self) -> str:
136
+ """Get the button label text."""
137
+ return self.button.text
138
+
139
+ @property
140
+ def button_payload(self) -> str:
141
+ """Get the button payload data."""
142
+ return self.button.payload
143
+
144
+ @property
145
+ def original_message_id(self) -> str:
146
+ """Get the ID of the original message that contained the button."""
147
+ return self.context.id
148
+
149
+ @property
150
+ def business_phone(self) -> str:
151
+ """Get the business phone number that sent the original message."""
152
+ return self.context.from_
153
+
154
+ @property
155
+ def unix_timestamp(self) -> int:
156
+ """Get the timestamp as an integer."""
157
+ return self.timestamp
158
+
159
+ def get_button_context(self) -> tuple[str, str]:
160
+ """
161
+ Get button context information.
162
+
163
+ Returns:
164
+ Tuple of (business_phone, original_message_id) for the button interaction.
165
+ """
166
+ return (self.business_phone, self.original_message_id)
167
+
168
+ def to_summary_dict(self) -> dict[str, str | bool | int]:
169
+ """
170
+ Create a summary dictionary for logging and analysis.
171
+
172
+ Returns:
173
+ Dictionary with key message information for structured logging.
174
+ """
175
+ return {
176
+ "message_id": self.id,
177
+ "sender": self.sender_phone,
178
+ "timestamp": self.unix_timestamp,
179
+ "type": self.type,
180
+ "button_text": self.button_text,
181
+ "button_payload": self.button_payload,
182
+ "original_message_id": self.original_message_id,
183
+ "business_phone": self.business_phone,
184
+ }
185
+
186
+ # Implement abstract methods from BaseMessage
187
+
188
+ @property
189
+ def platform(self) -> PlatformType:
190
+ return PlatformType.WHATSAPP
191
+
192
+ @property
193
+ def message_type(self) -> MessageType:
194
+ return MessageType.BUTTON
195
+
196
+ @property
197
+ def message_id(self) -> str:
198
+ return self.id
199
+
200
+ @property
201
+ def sender_id(self) -> str:
202
+ return self.from_
203
+
204
+ @property
205
+ def timestamp(self) -> int:
206
+ return int(self.timestamp_str)
207
+
208
+ @property
209
+ def conversation_id(self) -> str:
210
+ return self.from_
211
+
212
+ @property
213
+ def conversation_type(self) -> ConversationType:
214
+ return ConversationType.PRIVATE
215
+
216
+ def has_context(self) -> bool:
217
+ return True # Button messages always have context
218
+
219
+ def get_context(self) -> BaseMessageContext | None:
220
+ from .text import WhatsAppMessageContext
221
+
222
+ return WhatsAppMessageContext(self.context)
223
+
224
+ def to_universal_dict(self) -> UniversalMessageData:
225
+ return {
226
+ "platform": self.platform.value,
227
+ "message_type": self.message_type.value,
228
+ "message_id": self.message_id,
229
+ "sender_id": self.sender_id,
230
+ "conversation_id": self.conversation_id,
231
+ "conversation_type": self.conversation_type.value,
232
+ "timestamp": self.timestamp,
233
+ "processed_at": self.processed_at.isoformat(),
234
+ "has_context": self.has_context(),
235
+ "button_text": self.button_text,
236
+ "button_payload": self.button_payload,
237
+ "original_message_id": self.original_message_id,
238
+ "whatsapp_data": {
239
+ "whatsapp_id": self.id,
240
+ "from": self.from_,
241
+ "timestamp_str": self.timestamp_str,
242
+ "type": self.type,
243
+ "button_content": self.button.model_dump(),
244
+ "context": self.context.model_dump(),
245
+ },
246
+ }
247
+
248
+ def get_platform_data(self) -> dict[str, Any]:
249
+ return {
250
+ "whatsapp_message_id": self.id,
251
+ "from_phone": self.from_,
252
+ "timestamp_str": self.timestamp_str,
253
+ "message_type": self.type,
254
+ "button_content": self.button.model_dump(),
255
+ "context": self.context.model_dump(),
256
+ "interaction_details": {
257
+ "button_text": self.button_text,
258
+ "button_payload": self.button_payload,
259
+ "business_phone": self.business_phone,
260
+ },
261
+ }
262
+
263
+ @classmethod
264
+ def from_platform_data(
265
+ cls, data: dict[str, Any], **kwargs
266
+ ) -> "WhatsAppButtonMessage":
267
+ return cls.model_validate(data)