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,411 @@
1
+ """
2
+ WhatsApp text message schema.
3
+
4
+ This module contains Pydantic models for processing WhatsApp text messages,
5
+ including regular text, forwarded messages, message business button replies,
6
+ and Click-to-WhatsApp ad messages.
7
+ """
8
+
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
+
13
+ from wappa.schemas.core.base_message import BaseMessageContext, BaseTextMessage
14
+ from wappa.schemas.core.types import (
15
+ ConversationType,
16
+ PlatformType,
17
+ UniversalMessageData,
18
+ )
19
+ from wappa.schemas.whatsapp.base_models import AdReferral, MessageContext
20
+
21
+
22
+ class TextContent(BaseModel):
23
+ """Text message content."""
24
+
25
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
26
+
27
+ body: str = Field(
28
+ ...,
29
+ description="The text content of the message",
30
+ min_length=1,
31
+ max_length=4096, # WhatsApp text message limit
32
+ )
33
+
34
+ @field_validator("body")
35
+ @classmethod
36
+ def validate_body_not_empty(cls, v: str) -> str:
37
+ """Validate message body is not empty or whitespace only."""
38
+ if not v.strip():
39
+ raise ValueError("Text message body cannot be empty")
40
+ return v.strip()
41
+
42
+
43
+ class WhatsAppMessageContext(BaseMessageContext):
44
+ """
45
+ WhatsApp-specific message context adapter for universal interface.
46
+
47
+ Adapts WhatsApp MessageContext to the universal context interface.
48
+ """
49
+
50
+ def __init__(self, whatsapp_context: MessageContext | None):
51
+ super().__init__()
52
+ self._context = whatsapp_context
53
+
54
+ @property
55
+ def original_message_id(self) -> str | None:
56
+ """Get the ID of the original message being replied to or forwarded."""
57
+ return self._context.id if self._context else None
58
+
59
+ @property
60
+ def original_sender_id(self) -> str | None:
61
+ """Get the sender ID of the original message."""
62
+ return self._context.from_ if self._context else None
63
+
64
+ @property
65
+ def is_reply(self) -> bool:
66
+ """Check if this represents a reply context."""
67
+ if not self._context:
68
+ return False
69
+ return (
70
+ self._context.id is not None
71
+ and not self._context.forwarded
72
+ and not self._context.frequently_forwarded
73
+ and self._context.referred_product is None
74
+ )
75
+
76
+ @property
77
+ def is_forward(self) -> bool:
78
+ """Check if this represents a forward context."""
79
+ if not self._context:
80
+ return False
81
+ return self._context.forwarded or self._context.frequently_forwarded
82
+
83
+ def to_universal_dict(self) -> dict[str, Any]:
84
+ """Convert to platform-agnostic dictionary representation."""
85
+ if not self._context:
86
+ return {"platform": PlatformType.WHATSAPP.value, "has_context": False}
87
+
88
+ return {
89
+ "platform": PlatformType.WHATSAPP.value,
90
+ "has_context": True,
91
+ "original_message_id": self.original_message_id,
92
+ "original_sender_id": self.original_sender_id,
93
+ "is_reply": self.is_reply,
94
+ "is_forward": self.is_forward,
95
+ "whatsapp_data": {
96
+ "forwarded": self._context.forwarded,
97
+ "frequently_forwarded": self._context.frequently_forwarded,
98
+ "referred_product": self._context.referred_product.model_dump()
99
+ if self._context.referred_product
100
+ else None,
101
+ },
102
+ }
103
+
104
+
105
+ class WhatsAppTextMessage(BaseTextMessage):
106
+ """
107
+ WhatsApp text message model.
108
+
109
+ Supports various text message scenarios:
110
+ - Regular text messages
111
+ - Forwarded text messages
112
+ - Message business button replies (with product context)
113
+ - Click-to-WhatsApp ad messages
114
+ """
115
+
116
+ model_config = ConfigDict(
117
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
118
+ )
119
+
120
+ # Standard message fields
121
+ from_: str = Field(
122
+ ..., alias="from", description="WhatsApp user phone number who sent the message"
123
+ )
124
+ id: str = Field(..., description="Unique WhatsApp message ID")
125
+ timestamp_str: str = Field(
126
+ ..., alias="timestamp", description="Unix timestamp when the message was sent"
127
+ )
128
+ type: Literal["text"] = Field(
129
+ ..., description="Message type, always 'text' for text messages"
130
+ )
131
+
132
+ # Text content
133
+ text: TextContent = Field(..., description="Text message content")
134
+
135
+ # Optional context fields
136
+ context: MessageContext | None = Field(
137
+ None, description="Context for replies, forwards, or message business buttons"
138
+ )
139
+ referral: AdReferral | None = Field(
140
+ None, description="Click-to-WhatsApp ad referral information"
141
+ )
142
+
143
+ @field_validator("from_")
144
+ @classmethod
145
+ def validate_from_phone(cls, v: str) -> str:
146
+ """Validate sender phone number format."""
147
+ if not v or len(v) < 8:
148
+ raise ValueError("Sender phone number must be at least 8 characters")
149
+ # Remove common prefixes and validate numeric
150
+ phone = v.replace("+", "").replace("-", "").replace(" ", "")
151
+ if not phone.isdigit():
152
+ raise ValueError("Phone number must contain only digits (and +)")
153
+ return v
154
+
155
+ @field_validator("id")
156
+ @classmethod
157
+ def validate_message_id(cls, v: str) -> str:
158
+ """Validate WhatsApp message ID format."""
159
+ if not v or len(v) < 10:
160
+ raise ValueError("WhatsApp message ID must be at least 10 characters")
161
+ # WhatsApp message IDs typically start with 'wamid.'
162
+ if not v.startswith("wamid."):
163
+ raise ValueError("WhatsApp message ID should start with 'wamid.'")
164
+ return v
165
+
166
+ @field_validator("timestamp_str")
167
+ @classmethod
168
+ def validate_timestamp(cls, v: str) -> str:
169
+ """Validate Unix timestamp format."""
170
+ if not v.isdigit():
171
+ raise ValueError("Timestamp must be numeric")
172
+ # Validate reasonable timestamp range (after 2020, before 2100)
173
+ timestamp_int = int(v)
174
+ if timestamp_int < 1577836800 or timestamp_int > 4102444800:
175
+ raise ValueError("Timestamp must be a valid Unix timestamp")
176
+ return v
177
+
178
+ @model_validator(mode="after")
179
+ def validate_message_consistency(self):
180
+ """Validate message field consistency."""
181
+ # If we have a referral, this should be from an ad
182
+ if self.referral and self.context:
183
+ raise ValueError(
184
+ "Message cannot have both referral (ad) and context (reply/forward)"
185
+ )
186
+
187
+ # If context has forwarded flags, it should not have product info
188
+ if (
189
+ self.context
190
+ and (self.context.forwarded or self.context.frequently_forwarded)
191
+ and self.context.referred_product
192
+ ):
193
+ raise ValueError("Forwarded messages cannot have product referral context")
194
+
195
+ return self
196
+
197
+ @property
198
+ def is_forwarded(self) -> bool:
199
+ """Check if this message was forwarded."""
200
+ return self.context is not None and (
201
+ self.context.forwarded or self.context.frequently_forwarded
202
+ )
203
+
204
+ @property
205
+ def is_frequently_forwarded(self) -> bool:
206
+ """Check if this message was forwarded more than 5 times."""
207
+ return self.context is not None and self.context.frequently_forwarded is True
208
+
209
+ @property
210
+ def is_reply(self) -> bool:
211
+ """Check if this message is a reply to another message."""
212
+ return (
213
+ self.context is not None
214
+ and self.context.id is not None
215
+ and not self.is_forwarded
216
+ and self.context.referred_product is None
217
+ )
218
+
219
+ @property
220
+ def is_business_button_reply(self) -> bool:
221
+ """Check if this message came from a message business button."""
222
+ return self.context is not None and self.context.referred_product is not None
223
+
224
+ @property
225
+ def is_ad_message(self) -> bool:
226
+ """Check if this message came from a Click-to-WhatsApp ad."""
227
+ return self.referral is not None
228
+
229
+ @property
230
+ def sender_phone(self) -> str:
231
+ """Get the sender's phone number (clean accessor)."""
232
+ return self.from_
233
+
234
+ @property
235
+ def message_body(self) -> str:
236
+ """Get the text message body (clean accessor)."""
237
+ return self.text.body
238
+
239
+ @property
240
+ def unix_timestamp(self) -> int:
241
+ """Get the timestamp as an integer."""
242
+ return self.timestamp
243
+
244
+ def get_reply_context(self) -> tuple[str | None, str | None]:
245
+ """
246
+ Get reply context information.
247
+
248
+ Returns:
249
+ Tuple of (original_sender, original_message_id) if this is a reply,
250
+ (None, None) otherwise.
251
+ """
252
+ if self.is_reply and self.context:
253
+ return (self.context.from_, self.context.id)
254
+ return (None, None)
255
+
256
+ def get_product_context(self) -> tuple[str | None, str | None]:
257
+ """
258
+ Get product context information for message business button replies.
259
+
260
+ Returns:
261
+ Tuple of (catalog_id, product_id) if this came from a product button,
262
+ (None, None) otherwise.
263
+ """
264
+ if (
265
+ self.is_business_button_reply
266
+ and self.context
267
+ and self.context.referred_product
268
+ ):
269
+ product = self.context.referred_product
270
+ return (product.catalog_id, product.product_retailer_id)
271
+ return (None, None)
272
+
273
+ def get_ad_context(self) -> tuple[str | None, str | None]:
274
+ """
275
+ Get ad context information for Click-to-WhatsApp messages.
276
+
277
+ Returns:
278
+ Tuple of (ad_id, ad_click_id) if this came from an ad,
279
+ (None, None) otherwise.
280
+ """
281
+ if self.is_ad_message and self.referral:
282
+ return (self.referral.source_id, self.referral.ctwa_clid)
283
+ return (None, None)
284
+
285
+ def to_summary_dict(self) -> dict[str, str | bool | int]:
286
+ """
287
+ Create a summary dictionary for logging and analysis.
288
+
289
+ Returns:
290
+ Dictionary with key message information for structured logging.
291
+ """
292
+ return {
293
+ "message_id": self.id,
294
+ "sender": self.sender_phone,
295
+ "timestamp": self.unix_timestamp,
296
+ "type": self.type,
297
+ "body_length": len(self.message_body),
298
+ "is_reply": self.is_reply,
299
+ "is_forwarded": self.is_forwarded,
300
+ "is_frequently_forwarded": self.is_frequently_forwarded,
301
+ "is_business_button": self.is_business_button_reply,
302
+ "is_ad_message": self.is_ad_message,
303
+ }
304
+
305
+ # Implement abstract methods from BaseMessage
306
+
307
+ @property
308
+ def platform(self) -> PlatformType:
309
+ """Get the platform this message came from."""
310
+ return PlatformType.WHATSAPP
311
+
312
+ @property
313
+ def message_id(self) -> str:
314
+ """Get the unique message identifier."""
315
+ return self.id
316
+
317
+ @property
318
+ def sender_id(self) -> str:
319
+ """Get the sender's universal identifier."""
320
+ return self.from_
321
+
322
+ @property
323
+ def timestamp(self) -> int:
324
+ """Get the message timestamp as Unix timestamp."""
325
+ return int(self.timestamp_str)
326
+
327
+ @property
328
+ def conversation_id(self) -> str:
329
+ """Get the conversation/chat identifier."""
330
+ # For WhatsApp, use sender ID as conversation ID for 1-on-1 chats
331
+ return self.from_
332
+
333
+ @property
334
+ def conversation_type(self) -> ConversationType:
335
+ """Get the type of conversation."""
336
+ return ConversationType.PRIVATE # WhatsApp messages are typically private
337
+
338
+ def has_context(self) -> bool:
339
+ """Check if this message has context (reply, forward, etc.)."""
340
+ return self.context is not None
341
+
342
+ def get_context(self) -> BaseMessageContext | None:
343
+ """Get message context if available."""
344
+ return WhatsAppMessageContext(self.context) if self.context else None
345
+
346
+ def to_universal_dict(self) -> UniversalMessageData:
347
+ """Convert to platform-agnostic dictionary representation."""
348
+ return {
349
+ "platform": self.platform.value,
350
+ "message_type": self.message_type.value,
351
+ "message_id": self.message_id,
352
+ "sender_id": self.sender_id,
353
+ "conversation_id": self.conversation_id,
354
+ "conversation_type": self.conversation_type.value,
355
+ "timestamp": self.timestamp,
356
+ "processed_at": self.processed_at.isoformat(),
357
+ "has_context": self.has_context(),
358
+ "content": self.text_content,
359
+ "text_length": len(self.text_content),
360
+ "is_reply": self.is_reply,
361
+ "is_forwarded": self.is_forwarded,
362
+ "is_frequently_forwarded": self.is_frequently_forwarded,
363
+ "context": self.get_context().to_universal_dict()
364
+ if self.has_context()
365
+ else None,
366
+ "whatsapp_data": {
367
+ "whatsapp_id": self.id,
368
+ "from": self.from_,
369
+ "timestamp_str": self.timestamp_str,
370
+ "type": self.type,
371
+ "is_business_button_reply": self.is_business_button_reply,
372
+ "is_ad_message": self.is_ad_message,
373
+ "referral": self.referral.model_dump() if self.referral else None,
374
+ },
375
+ }
376
+
377
+ def get_platform_data(self) -> dict[str, Any]:
378
+ """Get platform-specific data for advanced processing."""
379
+ return {
380
+ "whatsapp_message_id": self.id,
381
+ "from_phone": self.from_,
382
+ "timestamp_str": self.timestamp_str,
383
+ "message_type": self.type,
384
+ "text_content": self.text.model_dump(),
385
+ "context": self.context.model_dump() if self.context else None,
386
+ "referral": self.referral.model_dump() if self.referral else None,
387
+ "is_business_button_reply": self.is_business_button_reply,
388
+ "is_ad_message": self.is_ad_message,
389
+ "product_context": self.get_product_context(),
390
+ "ad_context": self.get_ad_context(),
391
+ }
392
+
393
+ # Implement abstract methods from BaseTextMessage
394
+
395
+ @property
396
+ def text_content(self) -> str:
397
+ """Get the text content of the message."""
398
+ return self.text.body
399
+
400
+ def get_reply_context(self) -> tuple[str | None, str | None]:
401
+ """Get reply context information."""
402
+ if self.is_reply and self.context:
403
+ return (self.context.from_, self.context.id)
404
+ return (None, None)
405
+
406
+ @classmethod
407
+ def from_platform_data(
408
+ cls, data: dict[str, Any], **kwargs
409
+ ) -> "WhatsAppTextMessage":
410
+ """Create message instance from WhatsApp-specific data."""
411
+ return cls.model_validate(data)
@@ -0,0 +1,273 @@
1
+ """
2
+ WhatsApp unsupported message schema.
3
+
4
+ This module contains Pydantic models for processing WhatsApp unsupported messages,
5
+ which are sent when users send message types not supported by the Cloud API.
6
+ """
7
+
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import ConfigDict, Field, field_validator
11
+
12
+ from wappa.schemas.core.base_message import BaseMessage, BaseMessageContext
13
+ from wappa.schemas.core.types import (
14
+ ConversationType,
15
+ MessageType,
16
+ PlatformType,
17
+ UniversalMessageData,
18
+ )
19
+ from wappa.schemas.whatsapp.base_models import MessageContext, MessageError
20
+
21
+
22
+ class WhatsAppUnsupportedMessage(BaseMessage):
23
+ """
24
+ WhatsApp unsupported message model.
25
+
26
+ Represents messages that are not supported by the WhatsApp Cloud API, such as:
27
+ - New message types not yet supported
28
+ - Messages sent to numbers already in use with the API
29
+ - Other unsupported content types
30
+
31
+ These messages include error information explaining why they're unsupported.
32
+ """
33
+
34
+ model_config = ConfigDict(
35
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
36
+ )
37
+
38
+ # Standard message fields
39
+ from_: str = Field(
40
+ ..., alias="from", description="WhatsApp user phone number who sent the message"
41
+ )
42
+ id: str = Field(..., description="Unique WhatsApp message ID")
43
+ timestamp_str: str = Field(
44
+ ..., alias="timestamp", description="Unix timestamp when the message was sent"
45
+ )
46
+ type: Literal["unsupported"] = Field(
47
+ ..., description="Message type, always 'unsupported' for unsupported messages"
48
+ )
49
+
50
+ # Error information
51
+ errors: list[MessageError] = Field(
52
+ ..., description="List of errors explaining why the message is unsupported"
53
+ )
54
+
55
+ # Context field
56
+ context: MessageContext | None = Field(
57
+ None, description="Context for unsupported messages (rare)"
58
+ )
59
+
60
+ @field_validator("from_")
61
+ @classmethod
62
+ def validate_from_phone(cls, v: str) -> str:
63
+ """Validate sender phone number format."""
64
+ if not v or len(v) < 8:
65
+ raise ValueError("Sender phone number must be at least 8 characters")
66
+ # Remove common prefixes and validate numeric
67
+ phone = v.replace("+", "").replace("-", "").replace(" ", "")
68
+ if not phone.isdigit():
69
+ raise ValueError("Phone number must contain only digits (and +)")
70
+ return v
71
+
72
+ @field_validator("id")
73
+ @classmethod
74
+ def validate_message_id(cls, v: str) -> str:
75
+ """Validate WhatsApp message ID format."""
76
+ if not v or len(v) < 10:
77
+ raise ValueError("WhatsApp message ID must be at least 10 characters")
78
+ # WhatsApp message IDs typically start with 'wamid.'
79
+ if not v.startswith("wamid."):
80
+ raise ValueError("WhatsApp message ID should start with 'wamid.'")
81
+ return v
82
+
83
+ @field_validator("timestamp_str")
84
+ @classmethod
85
+ def validate_timestamp(cls, v: str) -> str:
86
+ """Validate Unix timestamp format."""
87
+ if not v.isdigit():
88
+ raise ValueError("Timestamp must be numeric")
89
+ # Validate reasonable timestamp range (after 2020, before 2100)
90
+ timestamp_int = int(v)
91
+ if timestamp_int < 1577836800 or timestamp_int > 4102444800:
92
+ raise ValueError("Timestamp must be a valid Unix timestamp")
93
+ return v
94
+
95
+ @field_validator("errors")
96
+ @classmethod
97
+ def validate_errors(cls, v: list[MessageError]) -> list[MessageError]:
98
+ """Validate errors list is not empty."""
99
+ if not v or len(v) == 0:
100
+ raise ValueError("Unsupported messages must include error information")
101
+ return v
102
+
103
+ @property
104
+ def sender_phone(self) -> str:
105
+ """Get the sender's phone number (clean accessor)."""
106
+ return self.from_
107
+
108
+ @property
109
+ def error_count(self) -> int:
110
+ """Get the number of errors."""
111
+ return len(self.errors)
112
+
113
+ @property
114
+ def primary_error(self) -> MessageError:
115
+ """Get the first (primary) error."""
116
+ return self.errors[0]
117
+
118
+ @property
119
+ def error_codes(self) -> list[int]:
120
+ """Get list of all error codes."""
121
+ return [error.code for error in self.errors]
122
+
123
+ @property
124
+ def error_messages(self) -> list[str]:
125
+ """Get list of all error messages."""
126
+ return [error.message for error in self.errors]
127
+
128
+ @property
129
+ def unix_timestamp(self) -> int:
130
+ """Get the timestamp as an integer."""
131
+ return self.timestamp
132
+
133
+ def has_error_code(self, code: int) -> bool:
134
+ """Check if a specific error code is present."""
135
+ return code in self.error_codes
136
+
137
+ def get_error_by_code(self, code: int) -> MessageError | None:
138
+ """Get the first error with the specified code."""
139
+ for error in self.errors:
140
+ if error.code == code:
141
+ return error
142
+ return None
143
+
144
+ def is_unknown_message_type(self) -> bool:
145
+ """Check if this is an unknown message type error (code 131051)."""
146
+ return self.has_error_code(131051)
147
+
148
+ def is_duplicate_phone_usage(self) -> bool:
149
+ """
150
+ Check if this error is due to sending to a number already in use.
151
+
152
+ Note: This is based on the trigger description and may need adjustment
153
+ based on actual error codes for this scenario.
154
+ """
155
+ # This would need to be updated with the actual error code
156
+ # for duplicate phone number usage once documented
157
+ return False
158
+
159
+ def get_unsupported_reason(self) -> str:
160
+ """
161
+ Get a human-readable reason why the message is unsupported.
162
+
163
+ Returns:
164
+ Primary error message explaining the unsupported reason.
165
+ """
166
+ return self.primary_error.message
167
+
168
+ def to_summary_dict(self) -> dict[str, str | bool | int | list]:
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
+ "error_count": self.error_count,
181
+ "error_codes": self.error_codes,
182
+ "error_messages": self.error_messages,
183
+ "primary_error_code": self.primary_error.code,
184
+ "primary_error_message": self.primary_error.message,
185
+ "is_unknown_message_type": self.is_unknown_message_type(),
186
+ "unsupported_reason": self.get_unsupported_reason(),
187
+ }
188
+
189
+ # Implement abstract methods from BaseMessage
190
+
191
+ @property
192
+ def platform(self) -> PlatformType:
193
+ return PlatformType.WHATSAPP
194
+
195
+ @property
196
+ def message_type(self) -> MessageType:
197
+ return MessageType.UNSUPPORTED
198
+
199
+ @property
200
+ def message_id(self) -> str:
201
+ return self.id
202
+
203
+ @property
204
+ def sender_id(self) -> str:
205
+ return self.from_
206
+
207
+ @property
208
+ def timestamp(self) -> int:
209
+ return int(self.timestamp_str)
210
+
211
+ @property
212
+ def conversation_id(self) -> str:
213
+ return self.from_
214
+
215
+ @property
216
+ def conversation_type(self) -> ConversationType:
217
+ return ConversationType.PRIVATE
218
+
219
+ def has_context(self) -> bool:
220
+ return self.context is not None
221
+
222
+ def get_context(self) -> BaseMessageContext | None:
223
+ from .text import WhatsAppMessageContext
224
+
225
+ return WhatsAppMessageContext(self.context) if self.context else None
226
+
227
+ def to_universal_dict(self) -> UniversalMessageData:
228
+ return {
229
+ "platform": self.platform.value,
230
+ "message_type": self.message_type.value,
231
+ "message_id": self.message_id,
232
+ "sender_id": self.sender_id,
233
+ "conversation_id": self.conversation_id,
234
+ "conversation_type": self.conversation_type.value,
235
+ "timestamp": self.timestamp,
236
+ "processed_at": self.processed_at.isoformat(),
237
+ "has_context": self.has_context(),
238
+ "error_count": self.error_count,
239
+ "error_codes": self.error_codes,
240
+ "primary_error_code": self.primary_error.code,
241
+ "primary_error_message": self.primary_error.message,
242
+ "unsupported_reason": self.get_unsupported_reason(),
243
+ "whatsapp_data": {
244
+ "whatsapp_id": self.id,
245
+ "from": self.from_,
246
+ "timestamp_str": self.timestamp_str,
247
+ "type": self.type,
248
+ "errors": [error.model_dump() for error in self.errors],
249
+ "context": self.context.model_dump() if self.context else None,
250
+ },
251
+ }
252
+
253
+ def get_platform_data(self) -> dict[str, Any]:
254
+ return {
255
+ "whatsapp_message_id": self.id,
256
+ "from_phone": self.from_,
257
+ "timestamp_str": self.timestamp_str,
258
+ "message_type": self.type,
259
+ "errors": [error.model_dump() for error in self.errors],
260
+ "context": self.context.model_dump() if self.context else None,
261
+ "error_analysis": {
262
+ "error_count": self.error_count,
263
+ "is_unknown_message_type": self.is_unknown_message_type(),
264
+ "is_duplicate_phone_usage": self.is_duplicate_phone_usage(),
265
+ "primary_error": self.primary_error.model_dump(),
266
+ },
267
+ }
268
+
269
+ @classmethod
270
+ def from_platform_data(
271
+ cls, data: dict[str, Any], **kwargs
272
+ ) -> "WhatsAppUnsupportedMessage":
273
+ return cls.model_validate(data)