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,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.webhooks.core.base_message import BaseMessageContext, BaseTextMessage
|
|
14
|
+
from wappa.webhooks.core.types import (
|
|
15
|
+
ConversationType,
|
|
16
|
+
PlatformType,
|
|
17
|
+
UniversalMessageData,
|
|
18
|
+
)
|
|
19
|
+
from wappa.webhooks.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.webhooks.core.base_message import BaseMessage, BaseMessageContext
|
|
13
|
+
from wappa.webhooks.core.types import (
|
|
14
|
+
ConversationType,
|
|
15
|
+
MessageType,
|
|
16
|
+
PlatformType,
|
|
17
|
+
UniversalMessageData,
|
|
18
|
+
)
|
|
19
|
+
from wappa.webhooks.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)
|