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,479 @@
1
+ """
2
+ WhatsApp message status schema.
3
+
4
+ This module contains Pydantic models for processing WhatsApp message status
5
+ updates including delivery receipts, read receipts, and failure notifications.
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_status import BaseMessageStatus
13
+ from wappa.webhooks.core.types import MessageStatus
14
+ from wappa.webhooks.whatsapp.base_models import Conversation, MessageError, Pricing
15
+
16
+
17
+ class WhatsAppMessageStatus(BaseMessageStatus):
18
+ """
19
+ WhatsApp message status model.
20
+
21
+ Represents status updates for messages sent by the business to WhatsApp users.
22
+ Status updates include sent, delivered, read, or failed notifications.
23
+ """
24
+
25
+ model_config = ConfigDict(
26
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
27
+ )
28
+
29
+ # Core status fields
30
+ id: str = Field(..., description="WhatsApp message ID that this status refers to")
31
+ wa_status: Literal["sent", "delivered", "read", "failed"] = Field(
32
+ ..., alias="status", description="Message delivery status"
33
+ )
34
+ wa_timestamp: str = Field(
35
+ ...,
36
+ alias="timestamp",
37
+ description="Unix timestamp when the status event occurred",
38
+ )
39
+ wa_recipient_id: str = Field(
40
+ ...,
41
+ alias="recipient_id",
42
+ description="WhatsApp user ID who received (or should have received) the message",
43
+ )
44
+
45
+ # Optional fields
46
+ recipient_identity_key_hash: str | None = Field(
47
+ None, description="Identity key hash (only if identity change check enabled)"
48
+ )
49
+ biz_opaque_callback_data: str | None = Field(
50
+ None, description="Business opaque data (only if set when sending message)"
51
+ )
52
+
53
+ # Pricing and conversation info (present for sent and first delivered/read)
54
+ conversation: Conversation | None = Field(
55
+ None,
56
+ description="Conversation information (omitted in v24.0+ unless free entry point)",
57
+ )
58
+ pricing: Pricing | None = Field(
59
+ None,
60
+ description="Pricing information (present with sent and first delivered/read)",
61
+ )
62
+
63
+ # Error information (only for failed status)
64
+ errors: list[MessageError] | None = Field(
65
+ None, description="Error details (only present if status='failed')"
66
+ )
67
+
68
+ @field_validator("id")
69
+ @classmethod
70
+ def validate_message_id(cls, v: str) -> str:
71
+ """Validate WhatsApp message ID format."""
72
+ if not v or len(v) < 10:
73
+ raise ValueError("WhatsApp message ID must be at least 10 characters")
74
+ # WhatsApp message IDs typically start with 'wamid.'
75
+ if not v.startswith("wamid."):
76
+ raise ValueError("WhatsApp message ID should start with 'wamid.'")
77
+ return v
78
+
79
+ @field_validator("wa_timestamp")
80
+ @classmethod
81
+ def validate_timestamp(cls, v: str) -> str:
82
+ """Validate Unix timestamp format."""
83
+ if not v.isdigit():
84
+ raise ValueError("Timestamp must be numeric")
85
+ # Validate reasonable timestamp range (after 2020, before 2100)
86
+ timestamp_int = int(v)
87
+ if timestamp_int < 1577836800 or timestamp_int > 4102444800:
88
+ raise ValueError("Timestamp must be a valid Unix timestamp")
89
+ return v
90
+
91
+ @field_validator("wa_recipient_id")
92
+ @classmethod
93
+ def validate_recipient_id(cls, v: str) -> str:
94
+ """Validate recipient ID format."""
95
+ if not v or len(v) < 8:
96
+ raise ValueError("Recipient ID must be at least 8 characters")
97
+ return v
98
+
99
+ @model_validator(mode="after")
100
+ def validate_status_consistency(self):
101
+ """Validate status-specific field consistency."""
102
+ # Failed status must have errors, others should not
103
+ if self.wa_status == "failed":
104
+ if not self.errors or len(self.errors) == 0:
105
+ raise ValueError("Failed status must include error information")
106
+ else:
107
+ if self.errors and len(self.errors) > 0:
108
+ raise ValueError(
109
+ f"Status '{self.wa_status}' should not have error information"
110
+ )
111
+
112
+ # Pricing information is typically present for sent and first delivered/read
113
+ # but we won't enforce this as it can vary based on WhatsApp API version
114
+
115
+ return self
116
+
117
+ # Abstract property implementations
118
+ @property
119
+ def status(self) -> MessageStatus:
120
+ """Get the universal message status."""
121
+ return MessageStatus(self.wa_status)
122
+
123
+ @property
124
+ def timestamp(self) -> int:
125
+ """Get the status timestamp as Unix timestamp."""
126
+ return int(self.wa_timestamp)
127
+
128
+ @property
129
+ def recipient_id(self) -> str:
130
+ """Get the recipient's universal identifier."""
131
+ return self.wa_recipient_id
132
+
133
+ @property
134
+ def is_sent(self) -> bool:
135
+ """Check if message was sent."""
136
+ return self.wa_status == "sent"
137
+
138
+ @property
139
+ def is_delivered(self) -> bool:
140
+ """Check if message was delivered."""
141
+ return self.wa_status == "delivered"
142
+
143
+ @property
144
+ def is_read(self) -> bool:
145
+ """Check if message was read."""
146
+ return self.wa_status == "read"
147
+
148
+ @property
149
+ def is_failed(self) -> bool:
150
+ """Check if message failed."""
151
+ return self.wa_status == "failed"
152
+
153
+ @property
154
+ def is_successful(self) -> bool:
155
+ """Check if message was successfully processed (sent, delivered, or read)."""
156
+ return self.wa_status in ["sent", "delivered", "read"]
157
+
158
+ @property
159
+ def unix_timestamp(self) -> int:
160
+ """Get the timestamp as an integer."""
161
+ return int(self.wa_timestamp)
162
+
163
+ def get_error_info(self) -> dict[str, Any] | None:
164
+ """Get error information if the message failed."""
165
+ if not self.is_failed or not self.errors:
166
+ return None
167
+
168
+ # Return the first error (WhatsApp typically sends one error per status)
169
+ error = self.errors[0] if self.errors else None
170
+ if not error:
171
+ return None
172
+
173
+ return {
174
+ "code": error.code,
175
+ "title": error.title,
176
+ "message": error.message,
177
+ "details": error.error_data.details if error.error_data else None,
178
+ "href": error.href,
179
+ }
180
+
181
+ def get_delivery_info(self) -> dict[str, Any]:
182
+ """Get detailed delivery information."""
183
+ info = {
184
+ "status": self.wa_status,
185
+ "timestamp": self.timestamp,
186
+ "recipient_id": self.wa_recipient_id,
187
+ "message_id": self.id,
188
+ }
189
+
190
+ # Add conversation info if present
191
+ if self.conversation:
192
+ info["conversation"] = {
193
+ "id": self.conversation.id,
194
+ "type": self.conversation.origin.type,
195
+ "expiration_timestamp": self.conversation.expiration_timestamp,
196
+ }
197
+
198
+ # Add pricing info if present
199
+ if self.pricing:
200
+ info["pricing"] = {
201
+ "billable": self.pricing.billable,
202
+ "pricing_model": self.pricing.pricing_model,
203
+ "category": self.pricing.category,
204
+ "type": self.pricing.type,
205
+ }
206
+
207
+ return info
208
+
209
+ def to_universal_dict(self) -> dict[str, Any]:
210
+ """Convert to platform-agnostic dictionary representation."""
211
+ return {
212
+ "platform": "whatsapp",
213
+ "message_id": self.id,
214
+ "status": self.wa_status,
215
+ "timestamp": self.timestamp,
216
+ "recipient_id": self.wa_recipient_id,
217
+ "is_successful": self.is_successful,
218
+ "error_info": self.get_error_info(),
219
+ "delivery_info": self.get_delivery_info(),
220
+ }
221
+
222
+ def get_platform_data(self) -> dict[str, Any]:
223
+ """Get platform-specific data for advanced processing."""
224
+ return {
225
+ "whatsapp_message_id": self.id,
226
+ "recipient_identity_key_hash": self.recipient_identity_key_hash,
227
+ "biz_opaque_callback_data": self.biz_opaque_callback_data,
228
+ "conversation": self.conversation.model_dump()
229
+ if self.conversation
230
+ else None,
231
+ "pricing": self.pricing.model_dump() if self.pricing else None,
232
+ "errors": [error.model_dump() for error in self.errors]
233
+ if self.errors
234
+ else None,
235
+ }
236
+
237
+ def get_status_summary(self) -> dict[str, Any]:
238
+ """Get a summary of the status update for logging and analytics."""
239
+ summary = {
240
+ "message_id": self.id,
241
+ "status": self.wa_status,
242
+ "recipient": self.wa_recipient_id,
243
+ "timestamp": self.timestamp,
244
+ }
245
+
246
+ # Add conversation type if available (useful for template/utility messages)
247
+ if self.conversation and self.conversation.origin:
248
+ summary["conversation_type"] = self.conversation.origin.type
249
+
250
+ # Add pricing category if available
251
+ if self.pricing:
252
+ summary["pricing_category"] = self.pricing.category
253
+ summary["billable"] = self.pricing.billable
254
+
255
+ # Add error summary if failed
256
+ if self.is_failed and self.errors:
257
+ error = self.errors[0]
258
+ summary["error_code"] = error.code
259
+ summary["error_title"] = error.title
260
+
261
+ return summary
262
+
263
+ @property
264
+ def conversation_id(self) -> str | None:
265
+ """Get conversation ID from status data."""
266
+ return self.conversation.id if self.conversation else None
267
+
268
+ @property
269
+ def message_id(self) -> str:
270
+ """Get the message ID this status refers to."""
271
+ return self.id
272
+
273
+ @property
274
+ def platform(self) -> str:
275
+ """Get the platform name."""
276
+ return "whatsapp"
277
+
278
+ @classmethod
279
+ def from_platform_data(
280
+ cls, data: dict[str, Any], **kwargs
281
+ ) -> "WhatsAppMessageStatus":
282
+ """Create instance from platform-specific data."""
283
+ return cls.model_validate(data)
284
+
285
+ @property
286
+ def has_billing_info(self) -> bool:
287
+ """Check if this status includes billing/pricing information."""
288
+ return self.pricing is not None
289
+
290
+ @property
291
+ def is_billable(self) -> bool:
292
+ """Check if this message is billable (if pricing info available)."""
293
+ if self.pricing:
294
+ return self.pricing.billable
295
+ return False
296
+
297
+ def get_conversation_info(self) -> tuple[str | None, str | None]:
298
+ """
299
+ Get conversation information.
300
+
301
+ Returns:
302
+ Tuple of (conversation_id, conversation_category) if available,
303
+ (None, None) otherwise.
304
+ """
305
+ if self.conversation:
306
+ return (self.conversation.id, self.conversation.origin.type)
307
+ return (None, None)
308
+
309
+ def get_pricing_info(self) -> tuple[bool | None, str | None, str | None]:
310
+ """
311
+ Get pricing information.
312
+
313
+ Returns:
314
+ Tuple of (is_billable, pricing_model, pricing_category) if available,
315
+ (None, None, None) otherwise.
316
+ """
317
+ if self.pricing:
318
+ return (
319
+ self.pricing.billable,
320
+ self.pricing.pricing_model,
321
+ self.pricing.category,
322
+ )
323
+ return (None, None, None)
324
+
325
+ def get_error_info(self) -> list[dict[str, str | int]]:
326
+ """
327
+ Get error information for failed messages.
328
+
329
+ Returns:
330
+ List of error dictionaries with code, title, message, and details.
331
+ Empty list if no errors.
332
+ """
333
+ if not self.errors:
334
+ return []
335
+
336
+ return [
337
+ {
338
+ "code": error.code,
339
+ "title": error.title,
340
+ "message": error.message,
341
+ "details": error.error_data.details,
342
+ "docs_url": error.href,
343
+ }
344
+ for error in self.errors
345
+ ]
346
+
347
+ def get_primary_error(self) -> dict[str, str | int] | None:
348
+ """
349
+ Get the primary (first) error for failed messages.
350
+
351
+ Returns:
352
+ Error dictionary or None if no errors.
353
+ """
354
+ errors = self.get_error_info()
355
+ return errors[0] if errors else None
356
+
357
+ def to_summary_dict(self) -> dict[str, str | bool | int | None]:
358
+ """
359
+ Create a summary dictionary for logging and analysis.
360
+
361
+ Returns:
362
+ Dictionary with key status information for structured logging.
363
+ """
364
+ summary = {
365
+ "message_id": self.id,
366
+ "status": self.status,
367
+ "timestamp": self.unix_timestamp,
368
+ "recipient_id": self.recipient_id,
369
+ "is_successful": self.is_successful,
370
+ "is_billable": self.is_billable,
371
+ "has_callback_data": self.biz_opaque_callback_data is not None,
372
+ "has_identity_check": self.recipient_identity_key_hash is not None,
373
+ }
374
+
375
+ # Add conversation info if available
376
+ conv_id, conv_category = self.get_conversation_info()
377
+ if conv_id:
378
+ summary["conversation_id"] = conv_id
379
+ summary["conversation_category"] = conv_category
380
+
381
+ # Add pricing info if available
382
+ is_billable, pricing_model, pricing_category = self.get_pricing_info()
383
+ if pricing_model:
384
+ summary["pricing_model"] = pricing_model
385
+ summary["pricing_category"] = pricing_category
386
+
387
+ # Add error info for failed messages
388
+ if self.is_failed:
389
+ primary_error = self.get_primary_error()
390
+ if primary_error:
391
+ summary["error_code"] = primary_error["code"]
392
+ summary["error_title"] = primary_error["title"]
393
+
394
+ return summary
395
+
396
+
397
+ class WhatsAppStatusWebhook(BaseModel):
398
+ """
399
+ Container for WhatsApp status webhook data.
400
+
401
+ Convenience model for handling status-only webhooks.
402
+ """
403
+
404
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
405
+
406
+ statuses: list[WhatsAppMessageStatus] = Field(
407
+ ..., description="Array of message status updates"
408
+ )
409
+
410
+ @field_validator("statuses")
411
+ @classmethod
412
+ def validate_statuses_not_empty(
413
+ cls, v: list[WhatsAppMessageStatus]
414
+ ) -> list[WhatsAppMessageStatus]:
415
+ """Validate statuses array is not empty."""
416
+ if not v or len(v) == 0:
417
+ raise ValueError("Status webhook must contain at least one status")
418
+ return v
419
+
420
+ @property
421
+ def status_count(self) -> int:
422
+ """Get the number of status updates."""
423
+ return len(self.statuses)
424
+
425
+ def get_statuses_by_type(self, status_type: str) -> list[WhatsAppMessageStatus]:
426
+ """
427
+ Get statuses filtered by type.
428
+
429
+ Args:
430
+ status_type: One of 'sent', 'delivered', 'read', 'failed'
431
+
432
+ Returns:
433
+ List of statuses matching the specified type.
434
+ """
435
+ return [status for status in self.statuses if status.status == status_type]
436
+
437
+ def get_failed_statuses(self) -> list[WhatsAppMessageStatus]:
438
+ """Get all failed message statuses."""
439
+ return self.get_statuses_by_type("failed")
440
+
441
+ def get_successful_statuses(self) -> list[WhatsAppMessageStatus]:
442
+ """Get all successful message statuses (sent, delivered, read)."""
443
+ return [status for status in self.statuses if status.is_successful]
444
+
445
+ def has_failures(self) -> bool:
446
+ """Check if any messages failed."""
447
+ return any(status.is_failed for status in self.statuses)
448
+
449
+ def to_summary_dict(self) -> dict[str, int | bool | list]:
450
+ """
451
+ Create a summary dictionary for the entire status webhook.
452
+
453
+ Returns:
454
+ Dictionary with aggregate status information.
455
+ """
456
+ status_counts = {
457
+ "sent": len(self.get_statuses_by_type("sent")),
458
+ "delivered": len(self.get_statuses_by_type("delivered")),
459
+ "read": len(self.get_statuses_by_type("read")),
460
+ "failed": len(self.get_statuses_by_type("failed")),
461
+ }
462
+
463
+ return {
464
+ "total_statuses": self.status_count,
465
+ "status_counts": status_counts,
466
+ "has_failures": self.has_failures(),
467
+ "success_rate": (
468
+ (
469
+ status_counts["sent"]
470
+ + status_counts["delivered"]
471
+ + status_counts["read"]
472
+ )
473
+ / self.status_count
474
+ * 100
475
+ )
476
+ if self.status_count > 0
477
+ else 0,
478
+ "failed_message_ids": [status.id for status in self.get_failed_statuses()],
479
+ }