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.
- wappa/__init__.py +85 -0
- wappa/api/__init__.py +1 -0
- wappa/api/controllers/__init__.py +10 -0
- wappa/api/controllers/webhook_controller.py +441 -0
- wappa/api/dependencies/__init__.py +15 -0
- wappa/api/dependencies/whatsapp_dependencies.py +220 -0
- wappa/api/dependencies/whatsapp_media_dependencies.py +26 -0
- wappa/api/middleware/__init__.py +7 -0
- wappa/api/middleware/error_handler.py +158 -0
- wappa/api/middleware/owner.py +99 -0
- wappa/api/middleware/request_logging.py +184 -0
- wappa/api/routes/__init__.py +6 -0
- wappa/api/routes/health.py +102 -0
- wappa/api/routes/webhooks.py +211 -0
- wappa/api/routes/whatsapp/__init__.py +15 -0
- wappa/api/routes/whatsapp/whatsapp_interactive.py +429 -0
- wappa/api/routes/whatsapp/whatsapp_media.py +440 -0
- wappa/api/routes/whatsapp/whatsapp_messages.py +195 -0
- wappa/api/routes/whatsapp/whatsapp_specialized.py +516 -0
- wappa/api/routes/whatsapp/whatsapp_templates.py +431 -0
- wappa/api/routes/whatsapp_combined.py +35 -0
- wappa/cli/__init__.py +9 -0
- wappa/cli/main.py +199 -0
- wappa/core/__init__.py +6 -0
- wappa/core/config/__init__.py +5 -0
- wappa/core/config/settings.py +161 -0
- wappa/core/events/__init__.py +41 -0
- wappa/core/events/default_handlers.py +642 -0
- wappa/core/events/event_dispatcher.py +244 -0
- wappa/core/events/event_handler.py +247 -0
- wappa/core/events/webhook_factory.py +219 -0
- wappa/core/factory/__init__.py +15 -0
- wappa/core/factory/plugin.py +68 -0
- wappa/core/factory/wappa_builder.py +326 -0
- wappa/core/logging/__init__.py +5 -0
- wappa/core/logging/context.py +100 -0
- wappa/core/logging/logger.py +343 -0
- wappa/core/plugins/__init__.py +34 -0
- wappa/core/plugins/auth_plugin.py +169 -0
- wappa/core/plugins/cors_plugin.py +128 -0
- wappa/core/plugins/custom_middleware_plugin.py +182 -0
- wappa/core/plugins/database_plugin.py +235 -0
- wappa/core/plugins/rate_limit_plugin.py +183 -0
- wappa/core/plugins/redis_plugin.py +224 -0
- wappa/core/plugins/wappa_core_plugin.py +261 -0
- wappa/core/plugins/webhook_plugin.py +253 -0
- wappa/core/types.py +108 -0
- wappa/core/wappa_app.py +546 -0
- wappa/database/__init__.py +18 -0
- wappa/database/adapter.py +107 -0
- wappa/database/adapters/__init__.py +17 -0
- wappa/database/adapters/mysql_adapter.py +187 -0
- wappa/database/adapters/postgresql_adapter.py +169 -0
- wappa/database/adapters/sqlite_adapter.py +174 -0
- wappa/domain/__init__.py +28 -0
- wappa/domain/builders/__init__.py +5 -0
- wappa/domain/builders/message_builder.py +189 -0
- wappa/domain/entities/__init__.py +5 -0
- wappa/domain/enums/messenger_platform.py +123 -0
- wappa/domain/factories/__init__.py +6 -0
- wappa/domain/factories/media_factory.py +450 -0
- wappa/domain/factories/message_factory.py +497 -0
- wappa/domain/factories/messenger_factory.py +244 -0
- wappa/domain/interfaces/__init__.py +32 -0
- wappa/domain/interfaces/base_repository.py +94 -0
- wappa/domain/interfaces/cache_factory.py +85 -0
- wappa/domain/interfaces/cache_interface.py +199 -0
- wappa/domain/interfaces/expiry_repository.py +68 -0
- wappa/domain/interfaces/media_interface.py +311 -0
- wappa/domain/interfaces/messaging_interface.py +523 -0
- wappa/domain/interfaces/pubsub_repository.py +151 -0
- wappa/domain/interfaces/repository_factory.py +108 -0
- wappa/domain/interfaces/shared_state_repository.py +122 -0
- wappa/domain/interfaces/state_repository.py +123 -0
- wappa/domain/interfaces/tables_repository.py +215 -0
- wappa/domain/interfaces/user_repository.py +114 -0
- wappa/domain/interfaces/webhooks/__init__.py +1 -0
- wappa/domain/models/media_result.py +110 -0
- wappa/domain/models/platforms/__init__.py +15 -0
- wappa/domain/models/platforms/platform_config.py +104 -0
- wappa/domain/services/__init__.py +11 -0
- wappa/domain/services/tenant_credentials_service.py +56 -0
- wappa/messaging/__init__.py +7 -0
- wappa/messaging/whatsapp/__init__.py +1 -0
- wappa/messaging/whatsapp/client/__init__.py +5 -0
- wappa/messaging/whatsapp/client/whatsapp_client.py +417 -0
- wappa/messaging/whatsapp/handlers/__init__.py +13 -0
- wappa/messaging/whatsapp/handlers/whatsapp_interactive_handler.py +653 -0
- wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +579 -0
- wappa/messaging/whatsapp/handlers/whatsapp_specialized_handler.py +434 -0
- wappa/messaging/whatsapp/handlers/whatsapp_template_handler.py +416 -0
- wappa/messaging/whatsapp/messenger/__init__.py +5 -0
- wappa/messaging/whatsapp/messenger/whatsapp_messenger.py +904 -0
- wappa/messaging/whatsapp/models/__init__.py +61 -0
- wappa/messaging/whatsapp/models/basic_models.py +65 -0
- wappa/messaging/whatsapp/models/interactive_models.py +287 -0
- wappa/messaging/whatsapp/models/media_models.py +215 -0
- wappa/messaging/whatsapp/models/specialized_models.py +304 -0
- wappa/messaging/whatsapp/models/template_models.py +261 -0
- wappa/persistence/cache_factory.py +93 -0
- wappa/persistence/json/__init__.py +14 -0
- wappa/persistence/json/cache_adapters.py +271 -0
- wappa/persistence/json/handlers/__init__.py +1 -0
- wappa/persistence/json/handlers/state_handler.py +250 -0
- wappa/persistence/json/handlers/table_handler.py +263 -0
- wappa/persistence/json/handlers/user_handler.py +213 -0
- wappa/persistence/json/handlers/utils/__init__.py +1 -0
- wappa/persistence/json/handlers/utils/file_manager.py +153 -0
- wappa/persistence/json/handlers/utils/key_factory.py +11 -0
- wappa/persistence/json/handlers/utils/serialization.py +121 -0
- wappa/persistence/json/json_cache_factory.py +76 -0
- wappa/persistence/json/storage_manager.py +285 -0
- wappa/persistence/memory/__init__.py +14 -0
- wappa/persistence/memory/cache_adapters.py +271 -0
- wappa/persistence/memory/handlers/__init__.py +1 -0
- wappa/persistence/memory/handlers/state_handler.py +250 -0
- wappa/persistence/memory/handlers/table_handler.py +280 -0
- wappa/persistence/memory/handlers/user_handler.py +213 -0
- wappa/persistence/memory/handlers/utils/__init__.py +1 -0
- wappa/persistence/memory/handlers/utils/key_factory.py +11 -0
- wappa/persistence/memory/handlers/utils/memory_store.py +317 -0
- wappa/persistence/memory/handlers/utils/ttl_manager.py +235 -0
- wappa/persistence/memory/memory_cache_factory.py +76 -0
- wappa/persistence/memory/storage_manager.py +235 -0
- wappa/persistence/redis/README.md +699 -0
- wappa/persistence/redis/__init__.py +11 -0
- wappa/persistence/redis/cache_adapters.py +285 -0
- wappa/persistence/redis/ops.py +880 -0
- wappa/persistence/redis/redis_cache_factory.py +71 -0
- wappa/persistence/redis/redis_client.py +231 -0
- wappa/persistence/redis/redis_handler/__init__.py +26 -0
- wappa/persistence/redis/redis_handler/state_handler.py +176 -0
- wappa/persistence/redis/redis_handler/table.py +158 -0
- wappa/persistence/redis/redis_handler/user.py +138 -0
- wappa/persistence/redis/redis_handler/utils/__init__.py +12 -0
- wappa/persistence/redis/redis_handler/utils/key_factory.py +32 -0
- wappa/persistence/redis/redis_handler/utils/serde.py +146 -0
- wappa/persistence/redis/redis_handler/utils/tenant_cache.py +268 -0
- wappa/persistence/redis/redis_manager.py +189 -0
- wappa/processors/__init__.py +6 -0
- wappa/processors/base_processor.py +262 -0
- wappa/processors/factory.py +550 -0
- wappa/processors/whatsapp_processor.py +810 -0
- wappa/schemas/__init__.py +6 -0
- wappa/schemas/core/__init__.py +71 -0
- wappa/schemas/core/base_message.py +499 -0
- wappa/schemas/core/base_status.py +322 -0
- wappa/schemas/core/base_webhook.py +312 -0
- wappa/schemas/core/types.py +253 -0
- wappa/schemas/core/webhook_interfaces/__init__.py +48 -0
- wappa/schemas/core/webhook_interfaces/base_components.py +293 -0
- wappa/schemas/core/webhook_interfaces/universal_webhooks.py +348 -0
- wappa/schemas/factory.py +754 -0
- wappa/schemas/webhooks/__init__.py +3 -0
- wappa/schemas/whatsapp/__init__.py +6 -0
- wappa/schemas/whatsapp/base_models.py +285 -0
- wappa/schemas/whatsapp/message_types/__init__.py +93 -0
- wappa/schemas/whatsapp/message_types/audio.py +350 -0
- wappa/schemas/whatsapp/message_types/button.py +267 -0
- wappa/schemas/whatsapp/message_types/contact.py +464 -0
- wappa/schemas/whatsapp/message_types/document.py +421 -0
- wappa/schemas/whatsapp/message_types/errors.py +195 -0
- wappa/schemas/whatsapp/message_types/image.py +424 -0
- wappa/schemas/whatsapp/message_types/interactive.py +430 -0
- wappa/schemas/whatsapp/message_types/location.py +416 -0
- wappa/schemas/whatsapp/message_types/order.py +372 -0
- wappa/schemas/whatsapp/message_types/reaction.py +271 -0
- wappa/schemas/whatsapp/message_types/sticker.py +328 -0
- wappa/schemas/whatsapp/message_types/system.py +317 -0
- wappa/schemas/whatsapp/message_types/text.py +411 -0
- wappa/schemas/whatsapp/message_types/unsupported.py +273 -0
- wappa/schemas/whatsapp/message_types/video.py +344 -0
- wappa/schemas/whatsapp/status_models.py +479 -0
- wappa/schemas/whatsapp/validators.py +454 -0
- wappa/schemas/whatsapp/webhook_container.py +438 -0
- wappa/webhooks/__init__.py +17 -0
- wappa/webhooks/core/__init__.py +71 -0
- wappa/webhooks/core/base_message.py +499 -0
- wappa/webhooks/core/base_status.py +322 -0
- wappa/webhooks/core/base_webhook.py +312 -0
- wappa/webhooks/core/types.py +253 -0
- wappa/webhooks/core/webhook_interfaces/__init__.py +48 -0
- wappa/webhooks/core/webhook_interfaces/base_components.py +293 -0
- wappa/webhooks/core/webhook_interfaces/universal_webhooks.py +441 -0
- wappa/webhooks/factory.py +754 -0
- wappa/webhooks/whatsapp/__init__.py +6 -0
- wappa/webhooks/whatsapp/base_models.py +285 -0
- wappa/webhooks/whatsapp/message_types/__init__.py +93 -0
- wappa/webhooks/whatsapp/message_types/audio.py +350 -0
- wappa/webhooks/whatsapp/message_types/button.py +267 -0
- wappa/webhooks/whatsapp/message_types/contact.py +464 -0
- wappa/webhooks/whatsapp/message_types/document.py +421 -0
- wappa/webhooks/whatsapp/message_types/errors.py +195 -0
- wappa/webhooks/whatsapp/message_types/image.py +424 -0
- wappa/webhooks/whatsapp/message_types/interactive.py +430 -0
- wappa/webhooks/whatsapp/message_types/location.py +416 -0
- wappa/webhooks/whatsapp/message_types/order.py +372 -0
- wappa/webhooks/whatsapp/message_types/reaction.py +271 -0
- wappa/webhooks/whatsapp/message_types/sticker.py +328 -0
- wappa/webhooks/whatsapp/message_types/system.py +317 -0
- wappa/webhooks/whatsapp/message_types/text.py +411 -0
- wappa/webhooks/whatsapp/message_types/unsupported.py +273 -0
- wappa/webhooks/whatsapp/message_types/video.py +344 -0
- wappa/webhooks/whatsapp/status_models.py +479 -0
- wappa/webhooks/whatsapp/validators.py +454 -0
- wappa/webhooks/whatsapp/webhook_container.py +438 -0
- wappa-0.1.0.dist-info/METADATA +269 -0
- wappa-0.1.0.dist-info/RECORD +211 -0
- wappa-0.1.0.dist-info/WHEEL +4 -0
- wappa-0.1.0.dist-info/entry_points.txt +2 -0
- wappa-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WhatsApp order message schema.
|
|
3
|
+
|
|
4
|
+
This module contains Pydantic models for processing WhatsApp order messages,
|
|
5
|
+
which are sent when users order products via catalog, single-, or multi-product messages.
|
|
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 OrderProductItem(BaseModel):
|
|
23
|
+
"""Individual product item in an order."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
26
|
+
|
|
27
|
+
product_retailer_id: str = Field(..., description="Product ID from the catalog")
|
|
28
|
+
quantity: int = Field(..., description="Quantity of this product ordered", ge=1)
|
|
29
|
+
item_price: float = Field(..., description="Individual product price", ge=0)
|
|
30
|
+
currency: str = Field(..., description="Currency code (e.g., 'USD', 'EUR')")
|
|
31
|
+
|
|
32
|
+
@field_validator("product_retailer_id")
|
|
33
|
+
@classmethod
|
|
34
|
+
def validate_product_id(cls, v: str) -> str:
|
|
35
|
+
"""Validate product ID is not empty."""
|
|
36
|
+
if not v.strip():
|
|
37
|
+
raise ValueError("Product retailer ID cannot be empty")
|
|
38
|
+
return v.strip()
|
|
39
|
+
|
|
40
|
+
@field_validator("currency")
|
|
41
|
+
@classmethod
|
|
42
|
+
def validate_currency(cls, v: str) -> str:
|
|
43
|
+
"""Validate currency code format."""
|
|
44
|
+
currency = v.strip().upper()
|
|
45
|
+
if len(currency) != 3:
|
|
46
|
+
raise ValueError("Currency code must be 3 characters (e.g., USD, EUR)")
|
|
47
|
+
return currency
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def total_price(self) -> float:
|
|
51
|
+
"""Calculate total price for this item (quantity * price)."""
|
|
52
|
+
return self.quantity * self.item_price
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OrderContent(BaseModel):
|
|
56
|
+
"""Order message content."""
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
59
|
+
|
|
60
|
+
catalog_id: str = Field(..., description="Product catalog ID")
|
|
61
|
+
text: str | None = Field(None, description="Text accompanying the order (optional)")
|
|
62
|
+
product_items: list[OrderProductItem] = Field(
|
|
63
|
+
..., description="List of products in the order"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@field_validator("catalog_id")
|
|
67
|
+
@classmethod
|
|
68
|
+
def validate_catalog_id(cls, v: str) -> str:
|
|
69
|
+
"""Validate catalog ID is not empty."""
|
|
70
|
+
if not v.strip():
|
|
71
|
+
raise ValueError("Catalog ID cannot be empty")
|
|
72
|
+
return v.strip()
|
|
73
|
+
|
|
74
|
+
@field_validator("text")
|
|
75
|
+
@classmethod
|
|
76
|
+
def validate_text(cls, v: str | None) -> str | None:
|
|
77
|
+
"""Validate order text if present."""
|
|
78
|
+
if v is not None:
|
|
79
|
+
v = v.strip()
|
|
80
|
+
if not v:
|
|
81
|
+
return None
|
|
82
|
+
if len(v) > 1000: # Reasonable order text limit
|
|
83
|
+
raise ValueError("Order text cannot exceed 1000 characters")
|
|
84
|
+
return v
|
|
85
|
+
|
|
86
|
+
@field_validator("product_items")
|
|
87
|
+
@classmethod
|
|
88
|
+
def validate_product_items(
|
|
89
|
+
cls, v: list[OrderProductItem]
|
|
90
|
+
) -> list[OrderProductItem]:
|
|
91
|
+
"""Validate product items list."""
|
|
92
|
+
if not v or len(v) == 0:
|
|
93
|
+
raise ValueError("Order must contain at least one product item")
|
|
94
|
+
if len(v) > 50: # Reasonable limit for order size
|
|
95
|
+
raise ValueError("Order cannot contain more than 50 product items")
|
|
96
|
+
|
|
97
|
+
# Check for duplicate products
|
|
98
|
+
product_ids = [item.product_retailer_id for item in v]
|
|
99
|
+
if len(product_ids) != len(set(product_ids)):
|
|
100
|
+
raise ValueError("Order cannot contain duplicate products")
|
|
101
|
+
|
|
102
|
+
return v
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def item_count(self) -> int:
|
|
106
|
+
"""Get total number of items in the order."""
|
|
107
|
+
return sum(item.quantity for item in self.product_items)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def unique_products(self) -> int:
|
|
111
|
+
"""Get number of unique products in the order."""
|
|
112
|
+
return len(self.product_items)
|
|
113
|
+
|
|
114
|
+
def get_total_amount(self) -> float:
|
|
115
|
+
"""Calculate total order amount."""
|
|
116
|
+
return sum(item.total_price for item in self.product_items)
|
|
117
|
+
|
|
118
|
+
def get_currencies(self) -> set[str]:
|
|
119
|
+
"""Get all currencies used in the order."""
|
|
120
|
+
return {item.currency for item in self.product_items}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class WhatsAppOrderMessage(BaseMessage):
|
|
124
|
+
"""
|
|
125
|
+
WhatsApp order message model.
|
|
126
|
+
|
|
127
|
+
Represents customer orders placed via catalog, single-product, or multi-product messages.
|
|
128
|
+
Contains product details, quantities, prices, and optional order text.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
model_config = ConfigDict(
|
|
132
|
+
extra="forbid", str_strip_whitespace=True, validate_assignment=True
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Standard message fields
|
|
136
|
+
from_: str = Field(
|
|
137
|
+
..., alias="from", description="WhatsApp user phone number who sent the message"
|
|
138
|
+
)
|
|
139
|
+
id: str = Field(..., description="Unique WhatsApp message ID")
|
|
140
|
+
timestamp_str: str = Field(
|
|
141
|
+
..., alias="timestamp", description="Unix timestamp when the message was sent"
|
|
142
|
+
)
|
|
143
|
+
type: Literal["order"] = Field(
|
|
144
|
+
..., description="Message type, always 'order' for order messages"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Order content
|
|
148
|
+
order: OrderContent = Field(
|
|
149
|
+
..., description="Order details including products and pricing"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Context field
|
|
153
|
+
context: MessageContext | None = Field(
|
|
154
|
+
None, description="Context for order messages"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@field_validator("from_")
|
|
158
|
+
@classmethod
|
|
159
|
+
def validate_from_phone(cls, v: str) -> str:
|
|
160
|
+
"""Validate sender phone number format."""
|
|
161
|
+
if not v or len(v) < 8:
|
|
162
|
+
raise ValueError("Sender phone number must be at least 8 characters")
|
|
163
|
+
# Remove common prefixes and validate numeric
|
|
164
|
+
phone = v.replace("+", "").replace("-", "").replace(" ", "")
|
|
165
|
+
if not phone.isdigit():
|
|
166
|
+
raise ValueError("Phone number must contain only digits (and +)")
|
|
167
|
+
return v
|
|
168
|
+
|
|
169
|
+
@field_validator("id")
|
|
170
|
+
@classmethod
|
|
171
|
+
def validate_message_id(cls, v: str) -> str:
|
|
172
|
+
"""Validate WhatsApp message ID format."""
|
|
173
|
+
if not v or len(v) < 10:
|
|
174
|
+
raise ValueError("WhatsApp message ID must be at least 10 characters")
|
|
175
|
+
# WhatsApp message IDs typically start with 'wamid.'
|
|
176
|
+
if not v.startswith("wamid."):
|
|
177
|
+
raise ValueError("WhatsApp message ID should start with 'wamid.'")
|
|
178
|
+
return v
|
|
179
|
+
|
|
180
|
+
@field_validator("timestamp_str")
|
|
181
|
+
@classmethod
|
|
182
|
+
def validate_timestamp(cls, v: str) -> str:
|
|
183
|
+
"""Validate Unix timestamp format."""
|
|
184
|
+
if not v.isdigit():
|
|
185
|
+
raise ValueError("Timestamp must be numeric")
|
|
186
|
+
# Validate reasonable timestamp range (after 2020, before 2100)
|
|
187
|
+
timestamp_int = int(v)
|
|
188
|
+
if timestamp_int < 1577836800 or timestamp_int > 4102444800:
|
|
189
|
+
raise ValueError("Timestamp must be a valid Unix timestamp")
|
|
190
|
+
return v
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def sender_phone(self) -> str:
|
|
194
|
+
"""Get the sender's phone number (clean accessor)."""
|
|
195
|
+
return self.from_
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def catalog_id(self) -> str:
|
|
199
|
+
"""Get the product catalog ID."""
|
|
200
|
+
return self.order.catalog_id
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def order_text(self) -> str | None:
|
|
204
|
+
"""Get the order text."""
|
|
205
|
+
return self.order.text
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def has_order_text(self) -> bool:
|
|
209
|
+
"""Check if the order has accompanying text."""
|
|
210
|
+
return self.order.text is not None and len(self.order.text.strip()) > 0
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def total_amount(self) -> float:
|
|
214
|
+
"""Get the total order amount."""
|
|
215
|
+
return self.order.get_total_amount()
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def item_count(self) -> int:
|
|
219
|
+
"""Get total number of items in the order."""
|
|
220
|
+
return self.order.item_count
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def unique_products(self) -> int:
|
|
224
|
+
"""Get number of unique products in the order."""
|
|
225
|
+
return self.order.unique_products
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def currencies(self) -> set[str]:
|
|
229
|
+
"""Get all currencies used in the order."""
|
|
230
|
+
return self.order.get_currencies()
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_multi_currency(self) -> bool:
|
|
234
|
+
"""Check if the order uses multiple currencies."""
|
|
235
|
+
return len(self.currencies) > 1
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def unix_timestamp(self) -> int:
|
|
239
|
+
"""Get the timestamp as an integer."""
|
|
240
|
+
return self.timestamp
|
|
241
|
+
|
|
242
|
+
def get_products(self) -> list[OrderProductItem]:
|
|
243
|
+
"""Get the list of products in the order."""
|
|
244
|
+
return self.order.product_items
|
|
245
|
+
|
|
246
|
+
def get_product_by_id(self, product_id: str) -> OrderProductItem | None:
|
|
247
|
+
"""Get a specific product by its ID."""
|
|
248
|
+
for item in self.order.product_items:
|
|
249
|
+
if item.product_retailer_id == product_id:
|
|
250
|
+
return item
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def get_total_by_currency(self) -> dict[str, float]:
|
|
254
|
+
"""Get total amounts grouped by currency."""
|
|
255
|
+
totals = {}
|
|
256
|
+
for item in self.order.product_items:
|
|
257
|
+
if item.currency not in totals:
|
|
258
|
+
totals[item.currency] = 0
|
|
259
|
+
totals[item.currency] += item.total_price
|
|
260
|
+
return totals
|
|
261
|
+
|
|
262
|
+
def to_summary_dict(self) -> dict[str, str | bool | int | float | list]:
|
|
263
|
+
"""
|
|
264
|
+
Create a summary dictionary for logging and analysis.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Dictionary with key message information for structured logging.
|
|
268
|
+
"""
|
|
269
|
+
return {
|
|
270
|
+
"message_id": self.id,
|
|
271
|
+
"sender": self.sender_phone,
|
|
272
|
+
"timestamp": self.unix_timestamp,
|
|
273
|
+
"type": self.type,
|
|
274
|
+
"catalog_id": self.catalog_id,
|
|
275
|
+
"total_amount": self.total_amount,
|
|
276
|
+
"item_count": self.item_count,
|
|
277
|
+
"unique_products": self.unique_products,
|
|
278
|
+
"currencies": list(self.currencies),
|
|
279
|
+
"is_multi_currency": self.is_multi_currency,
|
|
280
|
+
"has_order_text": self.has_order_text,
|
|
281
|
+
"product_ids": [
|
|
282
|
+
item.product_retailer_id for item in self.order.product_items
|
|
283
|
+
],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# Implement abstract methods from BaseMessage
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def platform(self) -> PlatformType:
|
|
290
|
+
return PlatformType.WHATSAPP
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def message_type(self) -> MessageType:
|
|
294
|
+
return MessageType.ORDER
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def message_id(self) -> str:
|
|
298
|
+
return self.id
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def sender_id(self) -> str:
|
|
302
|
+
return self.from_
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def timestamp(self) -> int:
|
|
306
|
+
return int(self.timestamp_str)
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def conversation_id(self) -> str:
|
|
310
|
+
return self.from_
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def conversation_type(self) -> ConversationType:
|
|
314
|
+
return ConversationType.PRIVATE
|
|
315
|
+
|
|
316
|
+
def has_context(self) -> bool:
|
|
317
|
+
return self.context is not None
|
|
318
|
+
|
|
319
|
+
def get_context(self) -> BaseMessageContext | None:
|
|
320
|
+
from .text import WhatsAppMessageContext
|
|
321
|
+
|
|
322
|
+
return WhatsAppMessageContext(self.context) if self.context else None
|
|
323
|
+
|
|
324
|
+
def to_universal_dict(self) -> UniversalMessageData:
|
|
325
|
+
return {
|
|
326
|
+
"platform": self.platform.value,
|
|
327
|
+
"message_type": self.message_type.value,
|
|
328
|
+
"message_id": self.message_id,
|
|
329
|
+
"sender_id": self.sender_id,
|
|
330
|
+
"conversation_id": self.conversation_id,
|
|
331
|
+
"conversation_type": self.conversation_type.value,
|
|
332
|
+
"timestamp": self.timestamp,
|
|
333
|
+
"processed_at": self.processed_at.isoformat(),
|
|
334
|
+
"has_context": self.has_context(),
|
|
335
|
+
"catalog_id": self.catalog_id,
|
|
336
|
+
"total_amount": self.total_amount,
|
|
337
|
+
"item_count": self.item_count,
|
|
338
|
+
"unique_products": self.unique_products,
|
|
339
|
+
"currencies": list(self.currencies),
|
|
340
|
+
"whatsapp_data": {
|
|
341
|
+
"whatsapp_id": self.id,
|
|
342
|
+
"from": self.from_,
|
|
343
|
+
"timestamp_str": self.timestamp_str,
|
|
344
|
+
"type": self.type,
|
|
345
|
+
"order_content": self.order.model_dump(),
|
|
346
|
+
"context": self.context.model_dump() if self.context else None,
|
|
347
|
+
},
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
def get_platform_data(self) -> dict[str, Any]:
|
|
351
|
+
return {
|
|
352
|
+
"whatsapp_message_id": self.id,
|
|
353
|
+
"from_phone": self.from_,
|
|
354
|
+
"timestamp_str": self.timestamp_str,
|
|
355
|
+
"message_type": self.type,
|
|
356
|
+
"order_content": self.order.model_dump(),
|
|
357
|
+
"context": self.context.model_dump() if self.context else None,
|
|
358
|
+
"order_summary": {
|
|
359
|
+
"catalog_id": self.catalog_id,
|
|
360
|
+
"total_amount": self.total_amount,
|
|
361
|
+
"item_count": self.item_count,
|
|
362
|
+
"currencies": list(self.currencies),
|
|
363
|
+
"is_multi_currency": self.is_multi_currency,
|
|
364
|
+
"total_by_currency": self.get_total_by_currency(),
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
@classmethod
|
|
369
|
+
def from_platform_data(
|
|
370
|
+
cls, data: dict[str, Any], **kwargs
|
|
371
|
+
) -> "WhatsAppOrderMessage":
|
|
372
|
+
return cls.model_validate(data)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WhatsApp reaction message schema.
|
|
3
|
+
|
|
4
|
+
This module contains Pydantic models for processing WhatsApp reaction messages,
|
|
5
|
+
which are sent when users react to or remove reactions from business messages.
|
|
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 ReactionContent(BaseModel):
|
|
23
|
+
"""Reaction message content."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
26
|
+
|
|
27
|
+
message_id: str = Field(..., description="ID of the message being reacted to")
|
|
28
|
+
emoji: str | None = Field(
|
|
29
|
+
None, description="Emoji Unicode (None if reaction is being removed)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@field_validator("message_id")
|
|
33
|
+
@classmethod
|
|
34
|
+
def validate_message_id(cls, v: str) -> str:
|
|
35
|
+
"""Validate message ID format."""
|
|
36
|
+
if not v.strip():
|
|
37
|
+
raise ValueError("Message ID cannot be empty")
|
|
38
|
+
# WhatsApp message IDs typically start with 'wamid.'
|
|
39
|
+
if not v.startswith("wamid."):
|
|
40
|
+
raise ValueError("Message ID should start with 'wamid.'")
|
|
41
|
+
return v.strip()
|
|
42
|
+
|
|
43
|
+
@field_validator("emoji")
|
|
44
|
+
@classmethod
|
|
45
|
+
def validate_emoji(cls, v: str | None) -> str | None:
|
|
46
|
+
"""Validate emoji format if present."""
|
|
47
|
+
if v is not None:
|
|
48
|
+
v = v.strip()
|
|
49
|
+
if not v:
|
|
50
|
+
return None
|
|
51
|
+
# Basic validation - emoji should be reasonably short
|
|
52
|
+
if len(v) > 20: # Unicode representations can be long
|
|
53
|
+
raise ValueError("Emoji representation too long")
|
|
54
|
+
return v
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WhatsAppReactionMessage(BaseMessage):
|
|
58
|
+
"""
|
|
59
|
+
WhatsApp reaction message model.
|
|
60
|
+
|
|
61
|
+
Represents user reactions to business messages including:
|
|
62
|
+
- Adding emoji reactions to messages
|
|
63
|
+
- Removing emoji reactions from messages
|
|
64
|
+
- Reactions to messages sent within the last 30 days
|
|
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
|
+
..., alias="from", description="WhatsApp user phone number who sent the message"
|
|
74
|
+
)
|
|
75
|
+
id: str = Field(..., description="Unique WhatsApp message ID")
|
|
76
|
+
timestamp_str: str = Field(
|
|
77
|
+
..., alias="timestamp", description="Unix timestamp when the reaction was sent"
|
|
78
|
+
)
|
|
79
|
+
type: Literal["reaction"] = Field(
|
|
80
|
+
..., description="Message type, always 'reaction' for reaction messages"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Reaction content
|
|
84
|
+
reaction: ReactionContent = Field(
|
|
85
|
+
..., description="Reaction details including target message and emoji"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Context field
|
|
89
|
+
context: MessageContext | None = Field(
|
|
90
|
+
None, description="Context for reactions (rare)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@field_validator("from_")
|
|
94
|
+
@classmethod
|
|
95
|
+
def validate_from_phone(cls, v: str) -> str:
|
|
96
|
+
"""Validate sender phone number format."""
|
|
97
|
+
if not v or len(v) < 8:
|
|
98
|
+
raise ValueError("Sender phone number must be at least 8 characters")
|
|
99
|
+
# Remove common prefixes and validate numeric
|
|
100
|
+
phone = v.replace("+", "").replace("-", "").replace(" ", "")
|
|
101
|
+
if not phone.isdigit():
|
|
102
|
+
raise ValueError("Phone number must contain only digits (and +)")
|
|
103
|
+
return v
|
|
104
|
+
|
|
105
|
+
@field_validator("id")
|
|
106
|
+
@classmethod
|
|
107
|
+
def validate_message_id(cls, v: str) -> str:
|
|
108
|
+
"""Validate WhatsApp message ID format."""
|
|
109
|
+
if not v or len(v) < 10:
|
|
110
|
+
raise ValueError("WhatsApp message ID must be at least 10 characters")
|
|
111
|
+
# WhatsApp message IDs typically start with 'wamid.'
|
|
112
|
+
if not v.startswith("wamid."):
|
|
113
|
+
raise ValueError("WhatsApp message ID should start with 'wamid.'")
|
|
114
|
+
return v
|
|
115
|
+
|
|
116
|
+
@field_validator("timestamp_str")
|
|
117
|
+
@classmethod
|
|
118
|
+
def validate_timestamp(cls, v: str) -> str:
|
|
119
|
+
"""Validate Unix timestamp format."""
|
|
120
|
+
if not v.isdigit():
|
|
121
|
+
raise ValueError("Timestamp must be numeric")
|
|
122
|
+
# Validate reasonable timestamp range (after 2020, before 2100)
|
|
123
|
+
timestamp_int = int(v)
|
|
124
|
+
if timestamp_int < 1577836800 or timestamp_int > 4102444800:
|
|
125
|
+
raise ValueError("Timestamp must be a valid Unix timestamp")
|
|
126
|
+
return v
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def sender_phone(self) -> str:
|
|
130
|
+
"""Get the sender's phone number (clean accessor)."""
|
|
131
|
+
return self.from_
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def target_message_id(self) -> str:
|
|
135
|
+
"""Get the ID of the message being reacted to."""
|
|
136
|
+
return self.reaction.message_id
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def emoji(self) -> str | None:
|
|
140
|
+
"""Get the reaction emoji."""
|
|
141
|
+
return self.reaction.emoji
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def is_adding_reaction(self) -> bool:
|
|
145
|
+
"""Check if this is adding a reaction (emoji present)."""
|
|
146
|
+
return self.reaction.emoji is not None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def is_removing_reaction(self) -> bool:
|
|
150
|
+
"""Check if this is removing a reaction (no emoji)."""
|
|
151
|
+
return self.reaction.emoji is None
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def unix_timestamp(self) -> int:
|
|
155
|
+
"""Get the timestamp as an integer."""
|
|
156
|
+
return self.timestamp
|
|
157
|
+
|
|
158
|
+
def get_emoji_display(self) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Get a display-friendly emoji representation.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The emoji if present, or "[removed]" if reaction was removed.
|
|
164
|
+
"""
|
|
165
|
+
if self.is_adding_reaction:
|
|
166
|
+
return self.emoji
|
|
167
|
+
return "[removed]"
|
|
168
|
+
|
|
169
|
+
def to_summary_dict(self) -> dict[str, str | bool | int]:
|
|
170
|
+
"""
|
|
171
|
+
Create a summary dictionary for logging and analysis.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Dictionary with key message information for structured logging.
|
|
175
|
+
"""
|
|
176
|
+
return {
|
|
177
|
+
"message_id": self.id,
|
|
178
|
+
"sender": self.sender_phone,
|
|
179
|
+
"timestamp": self.unix_timestamp,
|
|
180
|
+
"type": self.type,
|
|
181
|
+
"target_message_id": self.target_message_id,
|
|
182
|
+
"emoji": self.emoji,
|
|
183
|
+
"emoji_display": self.get_emoji_display(),
|
|
184
|
+
"is_adding_reaction": self.is_adding_reaction,
|
|
185
|
+
"is_removing_reaction": self.is_removing_reaction,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Implement abstract methods from BaseMessage
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def platform(self) -> PlatformType:
|
|
192
|
+
return PlatformType.WHATSAPP
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def message_type(self) -> MessageType:
|
|
196
|
+
return MessageType.REACTION
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def message_id(self) -> str:
|
|
200
|
+
return self.id
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def sender_id(self) -> str:
|
|
204
|
+
return self.from_
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def timestamp(self) -> int:
|
|
208
|
+
return int(self.timestamp_str)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def conversation_id(self) -> str:
|
|
212
|
+
return self.from_
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def conversation_type(self) -> ConversationType:
|
|
216
|
+
return ConversationType.PRIVATE
|
|
217
|
+
|
|
218
|
+
def has_context(self) -> bool:
|
|
219
|
+
return self.context is not None
|
|
220
|
+
|
|
221
|
+
def get_context(self) -> BaseMessageContext | None:
|
|
222
|
+
from .text import WhatsAppMessageContext
|
|
223
|
+
|
|
224
|
+
return WhatsAppMessageContext(self.context) if self.context else None
|
|
225
|
+
|
|
226
|
+
def to_universal_dict(self) -> UniversalMessageData:
|
|
227
|
+
return {
|
|
228
|
+
"platform": self.platform.value,
|
|
229
|
+
"message_type": self.message_type.value,
|
|
230
|
+
"message_id": self.message_id,
|
|
231
|
+
"sender_id": self.sender_id,
|
|
232
|
+
"conversation_id": self.conversation_id,
|
|
233
|
+
"conversation_type": self.conversation_type.value,
|
|
234
|
+
"timestamp": self.timestamp,
|
|
235
|
+
"processed_at": self.processed_at.isoformat(),
|
|
236
|
+
"has_context": self.has_context(),
|
|
237
|
+
"target_message_id": self.target_message_id,
|
|
238
|
+
"emoji": self.emoji,
|
|
239
|
+
"is_adding_reaction": self.is_adding_reaction,
|
|
240
|
+
"is_removing_reaction": self.is_removing_reaction,
|
|
241
|
+
"whatsapp_data": {
|
|
242
|
+
"whatsapp_id": self.id,
|
|
243
|
+
"from": self.from_,
|
|
244
|
+
"timestamp_str": self.timestamp_str,
|
|
245
|
+
"type": self.type,
|
|
246
|
+
"reaction_content": self.reaction.model_dump(),
|
|
247
|
+
"context": self.context.model_dump() if self.context else None,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def get_platform_data(self) -> dict[str, Any]:
|
|
252
|
+
return {
|
|
253
|
+
"whatsapp_message_id": self.id,
|
|
254
|
+
"from_phone": self.from_,
|
|
255
|
+
"timestamp_str": self.timestamp_str,
|
|
256
|
+
"message_type": self.type,
|
|
257
|
+
"reaction_content": self.reaction.model_dump(),
|
|
258
|
+
"context": self.context.model_dump() if self.context else None,
|
|
259
|
+
"reaction_details": {
|
|
260
|
+
"target_message_id": self.target_message_id,
|
|
261
|
+
"emoji_display": self.get_emoji_display(),
|
|
262
|
+
"is_adding": self.is_adding_reaction,
|
|
263
|
+
"is_removing": self.is_removing_reaction,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@classmethod
|
|
268
|
+
def from_platform_data(
|
|
269
|
+
cls, data: dict[str, Any], **kwargs
|
|
270
|
+
) -> "WhatsAppReactionMessage":
|
|
271
|
+
return cls.model_validate(data)
|