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,441 @@
1
+ """
2
+ Universal Webhook Interface definitions for platform-agnostic webhook handling.
3
+
4
+ This module defines the 4 universal webhook types that all messaging platforms
5
+ must transform their webhooks into:
6
+
7
+ 1. IncomingMessageWebhook - All user-sent messages (text, media, interactive, etc.)
8
+ 2. StatusWebhook - Message delivery status updates (sent, delivered, read, failed)
9
+ 3. ErrorWebhook - System, app, and account-level errors
10
+ 4. OutgoingMessageWebhook - Business-sent message tracking (future feature)
11
+
12
+ These interfaces represent the "universal standard" based on WhatsApp's comprehensive
13
+ webhook structure. All platforms (Teams, Telegram, Instagram) must adapt to these.
14
+ """
15
+
16
+ from datetime import datetime
17
+ from typing import Union
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ from wappa.webhooks.core.base_message import BaseMessage
22
+ from wappa.webhooks.core.types import MessageStatus, PlatformType
23
+ from wappa.webhooks.core.webhook_interfaces.base_components import (
24
+ AdReferralBase,
25
+ BusinessContextBase,
26
+ ConversationBase,
27
+ ErrorDetailBase,
28
+ ForwardContextBase,
29
+ TenantBase,
30
+ UserBase,
31
+ )
32
+
33
+
34
+ class IncomingMessageWebhook(BaseModel):
35
+ """
36
+ Universal interface for all incoming messages from users to businesses.
37
+
38
+ This interface represents any message sent by a user to a business,
39
+ regardless of platform or message type. It includes the core message
40
+ content plus optional context for advanced features.
41
+
42
+ All platforms must transform their incoming message webhooks to this format.
43
+ """
44
+
45
+ model_config = ConfigDict(
46
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
47
+ )
48
+
49
+ # Core identification
50
+ tenant: TenantBase = Field(description="Business/tenant identification")
51
+ user: UserBase = Field(description="User/sender identification")
52
+
53
+ # Message content (supports all message types)
54
+ message: BaseMessage = Field(
55
+ description="The actual message content with unified interface"
56
+ )
57
+
58
+ # Optional contexts (WhatsApp-based features that should be universal)
59
+ business_context: BusinessContextBase | None = Field(
60
+ default=None,
61
+ description="Context when message originated from business interactions (catalogs, buttons)",
62
+ )
63
+ forward_context: ForwardContextBase | None = Field(
64
+ default=None, description="Context when message was forwarded by the user"
65
+ )
66
+ ad_referral: AdReferralBase | None = Field(
67
+ default=None,
68
+ description="Context when message originated from advertisement interaction",
69
+ )
70
+
71
+ # Universal metadata
72
+ timestamp: datetime = Field(description="When the message was received")
73
+ platform: PlatformType = Field(description="Source messaging platform")
74
+
75
+ # Webhook identification
76
+ webhook_id: str = Field(description="Unique identifier for this webhook event")
77
+
78
+ # Raw webhook data (for debugging and inspection)
79
+ raw_webhook_data: dict | None = Field(
80
+ default=None,
81
+ description="Original raw webhook JSON payload",
82
+ exclude=True, # Don't include in serialization by default
83
+ )
84
+
85
+ def get_message_text(self) -> str:
86
+ """
87
+ Get text content from the message, regardless of message type.
88
+
89
+ Returns:
90
+ Text content or empty string if no text available
91
+ """
92
+ # For text messages, get the text_content property
93
+ if hasattr(self.message, "text_content"):
94
+ return self.message.text_content
95
+
96
+ # For interactive messages, try to get the selection value
97
+ interactive_value = self.get_interactive_selection()
98
+ if interactive_value:
99
+ return interactive_value
100
+
101
+ return ""
102
+
103
+ def get_message_type_name(self) -> str:
104
+ """Get the message type as a string."""
105
+ return self.message.message_type.value
106
+
107
+ def get_interactive_selection(self) -> str | None:
108
+ """
109
+ Get the selected option from interactive messages.
110
+
111
+ Returns:
112
+ The selected option ID/value or None if not an interactive message
113
+ """
114
+ # Check if this is an interactive message
115
+ if self.get_message_type_name() != "interactive":
116
+ return None
117
+
118
+ # Try to get the selected option ID from the message
119
+ if hasattr(self.message, "selected_option_id"):
120
+ return self.message.selected_option_id
121
+
122
+ # Fallback: try to get interactive data directly (platform-specific)
123
+ if hasattr(self.message, "interactive"):
124
+ interactive_data = getattr(self.message, "interactive", {})
125
+
126
+ # Handle button replies
127
+ if (
128
+ hasattr(interactive_data, "type")
129
+ and interactive_data.type == "button_reply"
130
+ ):
131
+ button_reply = getattr(interactive_data, "button_reply", None)
132
+ if button_reply and hasattr(button_reply, "id"):
133
+ return button_reply.id
134
+
135
+ # Handle list replies
136
+ elif (
137
+ hasattr(interactive_data, "type")
138
+ and interactive_data.type == "list_reply"
139
+ ):
140
+ list_reply = getattr(interactive_data, "list_reply", None)
141
+ if list_reply and hasattr(list_reply, "id"):
142
+ return list_reply.id
143
+
144
+ return None
145
+
146
+ def has_business_context(self) -> bool:
147
+ """Check if this message has business interaction context."""
148
+ return self.business_context is not None
149
+
150
+ def has_ad_referral(self) -> bool:
151
+ """Check if this message originated from an advertisement."""
152
+ return self.ad_referral is not None
153
+
154
+ def was_forwarded(self) -> bool:
155
+ """Check if this message was forwarded."""
156
+ return self.forward_context is not None and self.forward_context.is_forwarded
157
+
158
+ def get_conversation_id(self) -> str:
159
+ """Get conversation ID from the message."""
160
+ return getattr(self.message, "conversation_id", "")
161
+
162
+ def get_sender_display_name(self) -> str:
163
+ """Get sender's display name."""
164
+ return self.user.get_display_name()
165
+
166
+ def get_raw_webhook_data(self) -> dict | None:
167
+ """
168
+ Get the original raw webhook JSON payload.
169
+
170
+ This is useful for debugging, logging, or accessing platform-specific
171
+ fields that aren't included in the universal interface.
172
+
173
+ Returns:
174
+ Original webhook JSON dict or None if not available
175
+ """
176
+ return self.raw_webhook_data
177
+
178
+ def set_raw_webhook_data(self, raw_data: dict) -> None:
179
+ """
180
+ Set the original raw webhook JSON payload.
181
+
182
+ This should be called by webhook processors when creating
183
+ UniversalWebhook instances.
184
+
185
+ Args:
186
+ raw_data: Original webhook JSON payload
187
+ """
188
+ self.raw_webhook_data = raw_data
189
+
190
+ def get_summary(self) -> dict[str, any]:
191
+ """
192
+ Get a summary of this webhook for logging and monitoring.
193
+
194
+ Returns:
195
+ Dictionary with key information about this webhook
196
+ """
197
+ return {
198
+ "webhook_type": "incoming_message",
199
+ "platform": self.platform.value,
200
+ "message_type": self.get_message_type_name(),
201
+ "sender": self.user.user_id,
202
+ "tenant": self.tenant.get_tenant_key(),
203
+ "has_business_context": self.has_business_context(),
204
+ "has_ad_referral": self.has_ad_referral(),
205
+ "was_forwarded": self.was_forwarded(),
206
+ "timestamp": self.timestamp.isoformat(),
207
+ }
208
+
209
+
210
+ class StatusWebhook(BaseModel):
211
+ """
212
+ Universal interface for message delivery status updates.
213
+
214
+ This interface represents status updates for messages sent by businesses
215
+ to users (sent, delivered, read, failed). It includes conversation and
216
+ billing context when available.
217
+
218
+ All platforms must transform their status webhooks to this format.
219
+ """
220
+
221
+ model_config = ConfigDict(
222
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
223
+ )
224
+
225
+ # Core identification
226
+ tenant: TenantBase = Field(description="Business/tenant identification")
227
+
228
+ # Status information
229
+ message_id: str = Field(description="ID of the message this status refers to")
230
+ status: MessageStatus = Field(description="Current status of the message")
231
+ recipient_id: str = Field(description="ID of the user who received the message")
232
+ timestamp: datetime = Field(description="When this status update occurred")
233
+
234
+ # Optional context
235
+ conversation: ConversationBase | None = Field(
236
+ default=None, description="Conversation and billing context (if available)"
237
+ )
238
+
239
+ # Error context (for failed status)
240
+ errors: list[ErrorDetailBase] | None = Field(
241
+ default=None, description="Error details if status indicates failure"
242
+ )
243
+
244
+ # Optional business metadata
245
+ business_opaque_data: str | None = Field(
246
+ default=None,
247
+ description="Business-provided tracking data from original message",
248
+ )
249
+
250
+ # Security context
251
+ recipient_identity_hash: str | None = Field(
252
+ default=None, description="Recipient identity key hash for security validation"
253
+ )
254
+
255
+ # Universal metadata
256
+ platform: PlatformType = Field(description="Source messaging platform")
257
+ webhook_id: str = Field(description="Unique identifier for this webhook event")
258
+
259
+ # Raw webhook data (for debugging and inspection)
260
+ raw_webhook_data: dict | None = Field(
261
+ default=None,
262
+ description="Original raw webhook JSON payload",
263
+ exclude=True, # Don't include in serialization by default
264
+ )
265
+
266
+ def is_delivered_status(self) -> bool:
267
+ """Check if this status indicates successful delivery."""
268
+ return self.status in [MessageStatus.DELIVERED, MessageStatus.READ]
269
+
270
+ def is_failed_status(self) -> bool:
271
+ """Check if this status indicates failure."""
272
+ return self.status == MessageStatus.FAILED
273
+
274
+ def has_errors(self) -> bool:
275
+ """Check if this status includes error information."""
276
+ return self.errors is not None and len(self.errors) > 0
277
+
278
+ def get_primary_error(self) -> ErrorDetailBase | None:
279
+ """Get the primary error if this status failed."""
280
+ if not self.has_errors():
281
+ return None
282
+ return self.errors[0]
283
+
284
+ def is_billable_message(self) -> bool:
285
+ """Check if this message is billable."""
286
+ if self.conversation is None:
287
+ return False
288
+ return not self.conversation.is_free_conversation()
289
+
290
+ def get_summary(self) -> dict[str, any]:
291
+ """
292
+ Get a summary of this webhook for logging and monitoring.
293
+
294
+ Returns:
295
+ Dictionary with key information about this webhook
296
+ """
297
+ return {
298
+ "webhook_type": "status",
299
+ "platform": self.platform.value,
300
+ "status": self.status.value,
301
+ "message_id": self.message_id,
302
+ "recipient": self.recipient_id,
303
+ "tenant": self.tenant.get_tenant_key(),
304
+ "is_billable": self.is_billable_message(),
305
+ "has_errors": self.has_errors(),
306
+ "timestamp": self.timestamp.isoformat(),
307
+ }
308
+
309
+ def get_raw_webhook_data(self) -> dict | None:
310
+ """
311
+ Get the original raw webhook JSON payload.
312
+
313
+ This is useful for debugging, logging, or accessing platform-specific
314
+ fields that aren't included in the universal interface.
315
+
316
+ Returns:
317
+ Original webhook JSON dict or None if not available
318
+ """
319
+ return self.raw_webhook_data
320
+
321
+ def set_raw_webhook_data(self, raw_data: dict) -> None:
322
+ """
323
+ Set the original raw webhook JSON payload.
324
+
325
+ This should be called by webhook processors when creating
326
+ UniversalWebhook instances.
327
+
328
+ Args:
329
+ raw_data: Original webhook JSON payload
330
+ """
331
+ self.raw_webhook_data = raw_data
332
+
333
+
334
+ class ErrorWebhook(BaseModel):
335
+ """
336
+ Universal interface for system, app, and account-level errors.
337
+
338
+ This interface represents errors that occur at the platform level,
339
+ not related to specific message delivery (those are in StatusWebhook).
340
+
341
+ All platforms must transform their error webhooks to this format.
342
+ """
343
+
344
+ model_config = ConfigDict(
345
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
346
+ )
347
+
348
+ # Core identification
349
+ tenant: TenantBase = Field(description="Business/tenant identification")
350
+
351
+ # Error information
352
+ errors: list[ErrorDetailBase] = Field(description="Detailed error information")
353
+ timestamp: datetime = Field(description="When these errors occurred")
354
+
355
+ # Error context
356
+ error_level: str = Field(
357
+ default="system", description="Level of error (system, app, account, webhook)"
358
+ )
359
+
360
+ # Universal metadata
361
+ platform: PlatformType = Field(description="Source messaging platform")
362
+ webhook_id: str = Field(description="Unique identifier for this webhook event")
363
+
364
+ # Raw webhook data (for debugging and inspection)
365
+ raw_webhook_data: dict | None = Field(
366
+ default=None,
367
+ description="Original raw webhook JSON payload",
368
+ exclude=True, # Don't include in serialization by default
369
+ )
370
+
371
+ def get_primary_error(self) -> ErrorDetailBase:
372
+ """Get the primary (first) error."""
373
+ return self.errors[0]
374
+
375
+ def get_error_count(self) -> int:
376
+ """Get total number of errors in this webhook."""
377
+ return len(self.errors)
378
+
379
+ def has_critical_errors(self) -> bool:
380
+ """Check if any errors are likely critical (5xx codes)."""
381
+ return any(500 <= error.error_code < 600 for error in self.errors)
382
+
383
+ def has_retryable_errors(self) -> bool:
384
+ """Check if any errors are potentially retryable."""
385
+ return any(error.is_temporary_error() for error in self.errors)
386
+
387
+ def get_error_codes(self) -> list[int]:
388
+ """Get list of all error codes in this webhook."""
389
+ return [error.error_code for error in self.errors]
390
+
391
+ def get_summary(self) -> dict[str, any]:
392
+ """
393
+ Get a summary of this webhook for logging and monitoring.
394
+
395
+ Returns:
396
+ Dictionary with key information about this webhook
397
+ """
398
+ return {
399
+ "webhook_type": "error",
400
+ "platform": self.platform.value,
401
+ "error_level": self.error_level,
402
+ "error_count": self.get_error_count(),
403
+ "error_codes": self.get_error_codes(),
404
+ "tenant": self.tenant.get_tenant_key(),
405
+ "has_critical_errors": self.has_critical_errors(),
406
+ "has_retryable_errors": self.has_retryable_errors(),
407
+ "timestamp": self.timestamp.isoformat(),
408
+ }
409
+
410
+ def get_raw_webhook_data(self) -> dict | None:
411
+ """
412
+ Get the original raw webhook JSON payload.
413
+
414
+ This is useful for debugging, logging, or accessing platform-specific
415
+ fields that aren't included in the universal interface.
416
+
417
+ Returns:
418
+ Original webhook JSON dict or None if not available
419
+ """
420
+ return self.raw_webhook_data
421
+
422
+ def set_raw_webhook_data(self, raw_data: dict) -> None:
423
+ """
424
+ Set the original raw webhook JSON payload.
425
+
426
+ This should be called by webhook processors when creating
427
+ UniversalWebhook instances.
428
+
429
+ Args:
430
+ raw_data: Original webhook JSON payload
431
+ """
432
+ self.raw_webhook_data = raw_data
433
+
434
+
435
+ # Type union for all universal webhook interfaces
436
+ # Note: "Outgoing message" webhooks are actually status updates and use StatusWebhook
437
+ UniversalWebhook = Union[
438
+ IncomingMessageWebhook,
439
+ StatusWebhook,
440
+ ErrorWebhook,
441
+ ]