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,328 @@
1
+ """
2
+ WhatsApp sticker message schema.
3
+
4
+ This module contains Pydantic models for processing WhatsApp sticker messages,
5
+ including animated and static stickers sent via Click-to-WhatsApp ads.
6
+ """
7
+
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+ from wappa.schemas.core.base_message import BaseMediaMessage, BaseMessageContext
13
+ from wappa.schemas.core.types import (
14
+ ConversationType,
15
+ MediaType,
16
+ MessageType,
17
+ PlatformType,
18
+ UniversalMessageData,
19
+ )
20
+ from wappa.schemas.whatsapp.base_models import AdReferral, MessageContext
21
+
22
+
23
+ class StickerContent(BaseModel):
24
+ """Sticker message content."""
25
+
26
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
27
+
28
+ mime_type: str = Field(
29
+ ..., description="MIME type of the sticker file (e.g., 'image/webp')"
30
+ )
31
+ sha256: str = Field(..., description="SHA-256 hash of the sticker file")
32
+ id: str = Field(..., description="Media asset ID for retrieving the sticker file")
33
+ animated: bool = Field(
34
+ ..., description="True if sticker is animated, False if static"
35
+ )
36
+
37
+ @field_validator("mime_type")
38
+ @classmethod
39
+ def validate_mime_type(cls, v: str) -> str:
40
+ """Validate sticker MIME type format."""
41
+ # Stickers are typically WebP format, but can be other image formats
42
+ valid_types = ["image/webp", "image/png", "image/jpeg", "image/gif"]
43
+ mime_lower = v.lower()
44
+
45
+ if mime_lower not in valid_types:
46
+ raise ValueError(
47
+ f"Sticker MIME type must be one of: {', '.join(valid_types)}"
48
+ )
49
+ return mime_lower
50
+
51
+ @field_validator("id")
52
+ @classmethod
53
+ def validate_media_id(cls, v: str) -> str:
54
+ """Validate media asset ID."""
55
+ if not v or len(v) < 10:
56
+ raise ValueError("Media asset ID must be at least 10 characters")
57
+ return v
58
+
59
+
60
+ class WhatsAppStickerMessage(BaseMediaMessage):
61
+ """
62
+ WhatsApp sticker message model.
63
+
64
+ Supports various sticker message scenarios:
65
+ - Static stickers
66
+ - Animated stickers
67
+ - Click-to-WhatsApp ad sticker messages
68
+ """
69
+
70
+ model_config = ConfigDict(
71
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
72
+ )
73
+
74
+ # Standard message fields
75
+ from_: str = Field(
76
+ ..., alias="from", description="WhatsApp user phone number who sent the message"
77
+ )
78
+ id: str = Field(..., description="Unique WhatsApp message ID")
79
+ timestamp_str: str = Field(
80
+ ..., alias="timestamp", description="Unix timestamp when the message was sent"
81
+ )
82
+ type: Literal["sticker"] = Field(
83
+ ..., description="Message type, always 'sticker' for sticker messages"
84
+ )
85
+
86
+ # Sticker content
87
+ sticker: StickerContent = Field(
88
+ ..., description="Sticker message content and metadata"
89
+ )
90
+
91
+ # Optional context fields
92
+ context: MessageContext | None = Field(
93
+ None,
94
+ description="Context for forwards (stickers don't support replies typically)",
95
+ )
96
+ referral: AdReferral | None = Field(
97
+ None, description="Click-to-WhatsApp ad referral information"
98
+ )
99
+
100
+ @field_validator("from_")
101
+ @classmethod
102
+ def validate_from_phone(cls, v: str) -> str:
103
+ """Validate sender phone number format."""
104
+ if not v or len(v) < 8:
105
+ raise ValueError("Sender phone number must be at least 8 characters")
106
+ # Remove common prefixes and validate numeric
107
+ phone = v.replace("+", "").replace("-", "").replace(" ", "")
108
+ if not phone.isdigit():
109
+ raise ValueError("Phone number must contain only digits (and +)")
110
+ return v
111
+
112
+ @field_validator("id")
113
+ @classmethod
114
+ def validate_message_id(cls, v: str) -> str:
115
+ """Validate WhatsApp message ID format."""
116
+ if not v or len(v) < 10:
117
+ raise ValueError("WhatsApp message ID must be at least 10 characters")
118
+ # WhatsApp message IDs typically start with 'wamid.'
119
+ if not v.startswith("wamid."):
120
+ raise ValueError("WhatsApp message ID should start with 'wamid.'")
121
+ return v
122
+
123
+ @field_validator("timestamp_str")
124
+ @classmethod
125
+ def validate_timestamp(cls, v: str) -> str:
126
+ """Validate Unix timestamp format."""
127
+ if not v.isdigit():
128
+ raise ValueError("Timestamp must be numeric")
129
+ # Validate reasonable timestamp range (after 2020, before 2100)
130
+ timestamp_int = int(v)
131
+ if timestamp_int < 1577836800 or timestamp_int > 4102444800:
132
+ raise ValueError("Timestamp must be a valid Unix timestamp")
133
+ return v
134
+
135
+ @property
136
+ def is_animated(self) -> bool:
137
+ """Check if this is an animated sticker."""
138
+ return self.sticker.animated
139
+
140
+ @property
141
+ def is_static(self) -> bool:
142
+ """Check if this is a static (non-animated) sticker."""
143
+ return not self.sticker.animated
144
+
145
+ @property
146
+ def is_ad_message(self) -> bool:
147
+ """Check if this sticker message came from a Click-to-WhatsApp ad."""
148
+ return self.referral is not None
149
+
150
+ @property
151
+ def is_webp(self) -> bool:
152
+ """Check if this sticker is in WebP format."""
153
+ return self.sticker.mime_type == "image/webp"
154
+
155
+ @property
156
+ def sender_phone(self) -> str:
157
+ """Get the sender's phone number (clean accessor)."""
158
+ return self.from_
159
+
160
+ @property
161
+ def media_id(self) -> str:
162
+ """Get the media asset ID for downloading the sticker file."""
163
+ return self.sticker.id
164
+
165
+ @property
166
+ def mime_type(self) -> str:
167
+ """Get the sticker MIME type."""
168
+ return self.sticker.mime_type
169
+
170
+ @property
171
+ def file_hash(self) -> str:
172
+ """Get the SHA-256 hash of the sticker file."""
173
+ return self.sticker.sha256
174
+
175
+ @property
176
+ def unix_timestamp(self) -> int:
177
+ """Get the timestamp as an integer."""
178
+ return self.timestamp
179
+
180
+ def get_ad_context(self) -> tuple[str | None, str | None]:
181
+ """
182
+ Get ad context information for Click-to-WhatsApp sticker messages.
183
+
184
+ Returns:
185
+ Tuple of (ad_id, ad_click_id) if this came from an ad,
186
+ (None, None) otherwise.
187
+ """
188
+ if self.is_ad_message and self.referral:
189
+ return (self.referral.source_id, self.referral.ctwa_clid)
190
+ return (None, None)
191
+
192
+ def to_summary_dict(self) -> dict[str, str | bool | int]:
193
+ """
194
+ Create a summary dictionary for logging and analysis.
195
+
196
+ Returns:
197
+ Dictionary with key message information for structured logging.
198
+ """
199
+ return {
200
+ "message_id": self.id,
201
+ "sender": self.sender_phone,
202
+ "timestamp": self.unix_timestamp,
203
+ "type": self.type,
204
+ "media_id": self.media_id,
205
+ "mime_type": self.mime_type,
206
+ "is_animated": self.is_animated,
207
+ "is_webp": self.is_webp,
208
+ "is_ad_message": self.is_ad_message,
209
+ }
210
+
211
+ # Implement abstract methods from BaseMessage
212
+
213
+ @property
214
+ def platform(self) -> PlatformType:
215
+ return PlatformType.WHATSAPP
216
+
217
+ @property
218
+ def message_type(self) -> MessageType:
219
+ return MessageType.STICKER
220
+
221
+ @property
222
+ def message_id(self) -> str:
223
+ return self.id
224
+
225
+ @property
226
+ def sender_id(self) -> str:
227
+ return self.from_
228
+
229
+ @property
230
+ def timestamp(self) -> int:
231
+ return int(self.timestamp_str)
232
+
233
+ @property
234
+ def conversation_id(self) -> str:
235
+ return self.from_
236
+
237
+ @property
238
+ def conversation_type(self) -> ConversationType:
239
+ return ConversationType.PRIVATE
240
+
241
+ def has_context(self) -> bool:
242
+ return self.context is not None
243
+
244
+ def get_context(self) -> BaseMessageContext | None:
245
+ from .text import WhatsAppMessageContext
246
+
247
+ return WhatsAppMessageContext(self.context) if self.context else None
248
+
249
+ def to_universal_dict(self) -> UniversalMessageData:
250
+ return {
251
+ "platform": self.platform.value,
252
+ "message_type": self.message_type.value,
253
+ "message_id": self.message_id,
254
+ "sender_id": self.sender_id,
255
+ "conversation_id": self.conversation_id,
256
+ "conversation_type": self.conversation_type.value,
257
+ "timestamp": self.timestamp,
258
+ "processed_at": self.processed_at.isoformat(),
259
+ "has_context": self.has_context(),
260
+ "media_id": self.media_id,
261
+ "media_type": self.media_type.value,
262
+ "file_size": self.file_size,
263
+ "caption": self.caption,
264
+ "is_animated": self.is_animated,
265
+ "whatsapp_data": {
266
+ "whatsapp_id": self.id,
267
+ "from": self.from_,
268
+ "timestamp_str": self.timestamp_str,
269
+ "type": self.type,
270
+ "sticker_content": self.sticker.model_dump(),
271
+ "context": self.context.model_dump() if self.context else None,
272
+ "referral": self.referral.model_dump() if self.referral else None,
273
+ },
274
+ }
275
+
276
+ def get_platform_data(self) -> dict[str, Any]:
277
+ return {
278
+ "whatsapp_message_id": self.id,
279
+ "from_phone": self.from_,
280
+ "timestamp_str": self.timestamp_str,
281
+ "message_type": self.type,
282
+ "sticker_content": self.sticker.model_dump(),
283
+ "context": self.context.model_dump() if self.context else None,
284
+ "referral": self.referral.model_dump() if self.referral else None,
285
+ "sticker_properties": {
286
+ "is_animated": self.is_animated,
287
+ "is_webp": self.is_webp,
288
+ },
289
+ }
290
+
291
+ # Implement abstract methods from BaseMediaMessage
292
+
293
+ @property
294
+ def media_id(self) -> str:
295
+ return self.sticker.id
296
+
297
+ @property
298
+ def media_type(self) -> MediaType:
299
+ mime_str = self.sticker.mime_type
300
+ try:
301
+ return MediaType(mime_str)
302
+ except ValueError:
303
+ return MediaType.IMAGE_WEBP
304
+
305
+ @property
306
+ def file_size(self) -> int | None:
307
+ return None # WhatsApp doesn't provide file size in webhooks
308
+
309
+ @property
310
+ def caption(self) -> str | None:
311
+ return None # Stickers don't have captions
312
+
313
+ def get_download_info(self) -> dict[str, Any]:
314
+ return {
315
+ "media_id": self.media_id,
316
+ "mime_type": self.media_type.value,
317
+ "sha256": self.sticker.sha256,
318
+ "platform": "whatsapp",
319
+ "requires_auth": True,
320
+ "download_method": "whatsapp_media_api",
321
+ "is_animated": self.is_animated,
322
+ }
323
+
324
+ @classmethod
325
+ def from_platform_data(
326
+ cls, data: dict[str, Any], **kwargs
327
+ ) -> "WhatsAppStickerMessage":
328
+ return cls.model_validate(data)
@@ -0,0 +1,317 @@
1
+ """
2
+ WhatsApp system message schema.
3
+
4
+ This module contains Pydantic models for processing WhatsApp system messages,
5
+ which are generated when system events occur (e.g., user changes phone number).
6
+ """
7
+
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, 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
20
+
21
+
22
+ class SystemContent(BaseModel):
23
+ """System message content."""
24
+
25
+ model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
26
+
27
+ body: str = Field(..., description="System message text describing the event")
28
+ wa_id: str | None = Field(
29
+ None, description="New WhatsApp ID (for user_changed_number events)"
30
+ )
31
+ type: Literal["user_changed_number"] = Field(
32
+ ..., description="Type of system event"
33
+ )
34
+
35
+ @field_validator("body")
36
+ @classmethod
37
+ def validate_body(cls, v: str) -> str:
38
+ """Validate system message body."""
39
+ if not v.strip():
40
+ raise ValueError("System message body cannot be empty")
41
+ return v.strip()
42
+
43
+ @field_validator("wa_id")
44
+ @classmethod
45
+ def validate_wa_id(cls, v: str | None) -> str | None:
46
+ """Validate WhatsApp ID if present."""
47
+ if v is not None:
48
+ v = v.strip()
49
+ if not v:
50
+ return None
51
+ if len(v) < 8:
52
+ raise ValueError("WhatsApp ID must be at least 8 characters")
53
+ return v
54
+
55
+
56
+ class WhatsAppSystemMessage(BaseMessage):
57
+ """
58
+ WhatsApp system message model.
59
+
60
+ Represents system-generated messages for events like:
61
+ - User changing their phone number
62
+ - Other system notifications
63
+
64
+ Note: System messages don't include contact information unlike regular messages.
65
+ """
66
+
67
+ model_config = ConfigDict(
68
+ extra="forbid", str_strip_whitespace=True, validate_assignment=True
69
+ )
70
+
71
+ # Standard message fields
72
+ from_: str = Field(
73
+ ...,
74
+ alias="from",
75
+ description="WhatsApp user phone number (old number for number changes)",
76
+ )
77
+ id: str = Field(..., description="Unique WhatsApp message ID")
78
+ timestamp_str: str = Field(
79
+ ...,
80
+ alias="timestamp",
81
+ description="Unix timestamp when the system event occurred",
82
+ )
83
+ type: Literal["system"] = Field(
84
+ ..., description="Message type, always 'system' for system messages"
85
+ )
86
+
87
+ # System content
88
+ system: SystemContent = Field(..., description="System event details")
89
+
90
+ # Context field (though system messages typically don't have context)
91
+ context: MessageContext | None = Field(
92
+ None, description="Context for system messages (rare)"
93
+ )
94
+
95
+ @field_validator("from_")
96
+ @classmethod
97
+ def validate_from_phone(cls, v: str) -> str:
98
+ """Validate sender phone number format."""
99
+ if not v or len(v) < 8:
100
+ raise ValueError("Sender phone number must be at least 8 characters")
101
+ # Remove common prefixes and validate numeric
102
+ phone = v.replace("+", "").replace("-", "").replace(" ", "")
103
+ if not phone.isdigit():
104
+ raise ValueError("Phone number must contain only digits (and +)")
105
+ return v
106
+
107
+ @field_validator("id")
108
+ @classmethod
109
+ def validate_message_id(cls, v: str) -> str:
110
+ """Validate WhatsApp message ID format."""
111
+ if not v or len(v) < 10:
112
+ raise ValueError("WhatsApp message ID must be at least 10 characters")
113
+ # WhatsApp message IDs typically start with 'wamid.'
114
+ if not v.startswith("wamid."):
115
+ raise ValueError("WhatsApp message ID should start with 'wamid.'")
116
+ return v
117
+
118
+ @field_validator("timestamp_str")
119
+ @classmethod
120
+ def validate_timestamp(cls, v: str) -> str:
121
+ """Validate Unix timestamp format."""
122
+ if not v.isdigit():
123
+ raise ValueError("Timestamp must be numeric")
124
+ # Validate reasonable timestamp range (after 2020, before 2100)
125
+ timestamp_int = int(v)
126
+ if timestamp_int < 1577836800 or timestamp_int > 4102444800:
127
+ raise ValueError("Timestamp must be a valid Unix timestamp")
128
+ return v
129
+
130
+ @property
131
+ def sender_phone(self) -> str:
132
+ """Get the sender's phone number (old number for number changes)."""
133
+ return self.from_
134
+
135
+ @property
136
+ def system_event_type(self) -> str:
137
+ """Get the type of system event."""
138
+ return self.system.type
139
+
140
+ @property
141
+ def system_message(self) -> str:
142
+ """Get the system message text."""
143
+ return self.system.body
144
+
145
+ @property
146
+ def new_wa_id(self) -> str | None:
147
+ """Get the new WhatsApp ID (for number change events)."""
148
+ return self.system.wa_id
149
+
150
+ @property
151
+ def is_number_change(self) -> bool:
152
+ """Check if this is a phone number change event."""
153
+ return self.system.type == "user_changed_number"
154
+
155
+ @property
156
+ def unix_timestamp(self) -> int:
157
+ """Get the timestamp as an integer."""
158
+ return self.timestamp
159
+
160
+ def extract_phone_numbers(self) -> tuple[str | None, str | None]:
161
+ """
162
+ Extract old and new phone numbers from number change message.
163
+
164
+ Returns:
165
+ Tuple of (old_number, new_number) for number change events,
166
+ (None, None) for other system events.
167
+ """
168
+ if not self.is_number_change:
169
+ return (None, None)
170
+
171
+ # The old number is in the 'from' field
172
+ old_number = self.sender_phone
173
+
174
+ # Try to extract new number from the message body
175
+ # Format: "User <name> changed from <old> to <new>"
176
+ try:
177
+ message = self.system_message
178
+ if " changed from " in message and " to " in message:
179
+ parts = message.split(" to ")
180
+ if len(parts) >= 2:
181
+ # Extract the new number (last part, cleaned)
182
+ new_number = parts[-1].strip()
183
+ return (old_number, new_number)
184
+ except Exception:
185
+ pass
186
+
187
+ return (old_number, None)
188
+
189
+ def extract_user_name(self) -> str | None:
190
+ """
191
+ Extract user name from system message.
192
+
193
+ Returns:
194
+ User name if found in message, None otherwise.
195
+ """
196
+ try:
197
+ message = self.system_message
198
+ if message.startswith("User ") and " changed from " in message:
199
+ # Format: "User <name> changed from <old> to <new>"
200
+ parts = message.split(" changed from ")
201
+ if len(parts) >= 1:
202
+ user_part = parts[0].replace("User ", "", 1).strip()
203
+ return user_part
204
+ except Exception:
205
+ pass
206
+
207
+ return None
208
+
209
+ def to_summary_dict(self) -> dict[str, str | bool | int]:
210
+ """
211
+ Create a summary dictionary for logging and analysis.
212
+
213
+ Returns:
214
+ Dictionary with key message information for structured logging.
215
+ """
216
+ old_number, new_number = self.extract_phone_numbers()
217
+
218
+ return {
219
+ "message_id": self.id,
220
+ "sender": self.sender_phone,
221
+ "timestamp": self.unix_timestamp,
222
+ "type": self.type,
223
+ "system_event_type": self.system_event_type,
224
+ "system_message": self.system_message,
225
+ "is_number_change": self.is_number_change,
226
+ "old_phone_number": old_number,
227
+ "new_phone_number": new_number,
228
+ "new_wa_id": self.new_wa_id,
229
+ "user_name": self.extract_user_name(),
230
+ }
231
+
232
+ # Implement abstract methods from BaseMessage
233
+
234
+ @property
235
+ def platform(self) -> PlatformType:
236
+ return PlatformType.WHATSAPP
237
+
238
+ @property
239
+ def message_type(self) -> MessageType:
240
+ return MessageType.SYSTEM
241
+
242
+ @property
243
+ def message_id(self) -> str:
244
+ return self.id
245
+
246
+ @property
247
+ def sender_id(self) -> str:
248
+ return self.from_
249
+
250
+ @property
251
+ def timestamp(self) -> int:
252
+ return int(self.timestamp_str)
253
+
254
+ @property
255
+ def conversation_id(self) -> str:
256
+ return self.from_
257
+
258
+ @property
259
+ def conversation_type(self) -> ConversationType:
260
+ return ConversationType.PRIVATE
261
+
262
+ def has_context(self) -> bool:
263
+ return self.context is not None
264
+
265
+ def get_context(self) -> BaseMessageContext | None:
266
+ from .text import WhatsAppMessageContext
267
+
268
+ return WhatsAppMessageContext(self.context) if self.context else None
269
+
270
+ def to_universal_dict(self) -> UniversalMessageData:
271
+ old_number, new_number = self.extract_phone_numbers()
272
+ return {
273
+ "platform": self.platform.value,
274
+ "message_type": self.message_type.value,
275
+ "message_id": self.message_id,
276
+ "sender_id": self.sender_id,
277
+ "conversation_id": self.conversation_id,
278
+ "conversation_type": self.conversation_type.value,
279
+ "timestamp": self.timestamp,
280
+ "processed_at": self.processed_at.isoformat(),
281
+ "has_context": self.has_context(),
282
+ "system_event_type": self.system_event_type,
283
+ "system_message": self.system_message,
284
+ "is_number_change": self.is_number_change,
285
+ "old_phone_number": old_number,
286
+ "new_phone_number": new_number,
287
+ "whatsapp_data": {
288
+ "whatsapp_id": self.id,
289
+ "from": self.from_,
290
+ "timestamp_str": self.timestamp_str,
291
+ "type": self.type,
292
+ "system_content": self.system.model_dump(),
293
+ "context": self.context.model_dump() if self.context else None,
294
+ },
295
+ }
296
+
297
+ def get_platform_data(self) -> dict[str, Any]:
298
+ return {
299
+ "whatsapp_message_id": self.id,
300
+ "from_phone": self.from_,
301
+ "timestamp_str": self.timestamp_str,
302
+ "message_type": self.type,
303
+ "system_content": self.system.model_dump(),
304
+ "context": self.context.model_dump() if self.context else None,
305
+ "system_analysis": {
306
+ "event_type": self.system_event_type,
307
+ "is_number_change": self.is_number_change,
308
+ "extracted_user_name": self.extract_user_name(),
309
+ "phone_numbers": self.extract_phone_numbers(),
310
+ },
311
+ }
312
+
313
+ @classmethod
314
+ def from_platform_data(
315
+ cls, data: dict[str, Any], **kwargs
316
+ ) -> "WhatsAppSystemMessage":
317
+ return cls.model_validate(data)