wappa 0.1.8__py3-none-any.whl → 0.1.10__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 +4 -5
- wappa/api/controllers/webhook_controller.py +5 -2
- wappa/api/dependencies/__init__.py +0 -5
- wappa/api/middleware/error_handler.py +4 -4
- wappa/api/middleware/owner.py +11 -5
- wappa/api/routes/webhooks.py +2 -2
- wappa/cli/__init__.py +1 -1
- wappa/cli/examples/init/.env.example +33 -0
- wappa/cli/examples/init/app/__init__.py +0 -0
- wappa/cli/examples/init/app/main.py +9 -0
- wappa/cli/examples/init/app/master_event.py +10 -0
- wappa/cli/examples/json_cache_example/.env.example +33 -0
- wappa/cli/examples/json_cache_example/app/__init__.py +1 -0
- wappa/cli/examples/json_cache_example/app/main.py +247 -0
- wappa/cli/examples/json_cache_example/app/master_event.py +455 -0
- wappa/cli/examples/json_cache_example/app/models/__init__.py +1 -0
- wappa/cli/examples/json_cache_example/app/models/json_demo_models.py +256 -0
- wappa/cli/examples/json_cache_example/app/scores/__init__.py +35 -0
- wappa/cli/examples/json_cache_example/app/scores/score_base.py +192 -0
- wappa/cli/examples/json_cache_example/app/scores/score_cache_statistics.py +256 -0
- wappa/cli/examples/json_cache_example/app/scores/score_message_history.py +187 -0
- wappa/cli/examples/json_cache_example/app/scores/score_state_commands.py +272 -0
- wappa/cli/examples/json_cache_example/app/scores/score_user_management.py +239 -0
- wappa/cli/examples/json_cache_example/app/utils/__init__.py +26 -0
- wappa/cli/examples/json_cache_example/app/utils/cache_utils.py +174 -0
- wappa/cli/examples/json_cache_example/app/utils/message_utils.py +251 -0
- wappa/cli/examples/openai_transcript/.gitignore +63 -4
- wappa/cli/examples/openai_transcript/app/__init__.py +0 -0
- wappa/cli/examples/openai_transcript/app/main.py +9 -0
- wappa/cli/examples/openai_transcript/app/master_event.py +62 -0
- wappa/cli/examples/openai_transcript/app/openai_utils/__init__.py +3 -0
- wappa/cli/examples/openai_transcript/app/openai_utils/audio_processing.py +89 -0
- wappa/cli/examples/redis_cache_example/.env.example +33 -0
- wappa/cli/examples/redis_cache_example/app/__init__.py +6 -0
- wappa/cli/examples/redis_cache_example/app/main.py +246 -0
- wappa/cli/examples/redis_cache_example/app/master_event.py +455 -0
- wappa/cli/examples/redis_cache_example/app/models/redis_demo_models.py +256 -0
- wappa/cli/examples/redis_cache_example/app/scores/__init__.py +35 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_base.py +192 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_cache_statistics.py +256 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_message_history.py +187 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_state_commands.py +272 -0
- wappa/cli/examples/redis_cache_example/app/scores/score_user_management.py +239 -0
- wappa/cli/examples/redis_cache_example/app/utils/__init__.py +26 -0
- wappa/cli/examples/redis_cache_example/app/utils/cache_utils.py +174 -0
- wappa/cli/examples/redis_cache_example/app/utils/message_utils.py +251 -0
- wappa/cli/examples/simple_echo_example/.env.example +33 -0
- wappa/cli/examples/simple_echo_example/app/__init__.py +7 -0
- wappa/cli/examples/simple_echo_example/app/main.py +191 -0
- wappa/cli/examples/simple_echo_example/app/master_event.py +230 -0
- wappa/cli/examples/wappa_full_example/.env.example +33 -0
- wappa/cli/examples/wappa_full_example/.gitignore +63 -4
- wappa/cli/examples/wappa_full_example/app/__init__.py +6 -0
- wappa/cli/examples/wappa_full_example/app/handlers/__init__.py +5 -0
- wappa/cli/examples/wappa_full_example/app/handlers/command_handlers.py +492 -0
- wappa/cli/examples/wappa_full_example/app/handlers/message_handlers.py +559 -0
- wappa/cli/examples/wappa_full_example/app/handlers/state_handlers.py +514 -0
- wappa/cli/examples/wappa_full_example/app/main.py +269 -0
- wappa/cli/examples/wappa_full_example/app/master_event.py +504 -0
- wappa/cli/examples/wappa_full_example/app/media/README.md +54 -0
- wappa/cli/examples/wappa_full_example/app/media/buttons/README.md +62 -0
- wappa/cli/examples/wappa_full_example/app/media/buttons/kitty.png +0 -0
- wappa/cli/examples/wappa_full_example/app/media/buttons/puppy.png +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/README.md +110 -0
- wappa/cli/examples/wappa_full_example/app/media/list/audio.mp3 +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/document.pdf +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/image.png +0 -0
- wappa/cli/examples/wappa_full_example/app/media/list/video.mp4 +0 -0
- wappa/cli/examples/wappa_full_example/app/models/__init__.py +5 -0
- wappa/cli/examples/wappa_full_example/app/models/state_models.py +434 -0
- wappa/cli/examples/wappa_full_example/app/models/user_models.py +303 -0
- wappa/cli/examples/wappa_full_example/app/models/webhook_metadata.py +327 -0
- wappa/cli/examples/wappa_full_example/app/utils/__init__.py +5 -0
- wappa/cli/examples/wappa_full_example/app/utils/cache_utils.py +502 -0
- wappa/cli/examples/wappa_full_example/app/utils/media_handler.py +516 -0
- wappa/cli/examples/wappa_full_example/app/utils/metadata_extractor.py +337 -0
- wappa/cli/main.py +14 -5
- wappa/core/__init__.py +18 -23
- wappa/core/config/settings.py +7 -5
- wappa/core/events/default_handlers.py +1 -1
- wappa/core/factory/wappa_builder.py +38 -25
- wappa/core/plugins/redis_plugin.py +1 -3
- wappa/core/plugins/wappa_core_plugin.py +7 -6
- wappa/core/types.py +12 -12
- wappa/core/wappa_app.py +10 -8
- wappa/database/__init__.py +3 -4
- wappa/domain/enums/messenger_platform.py +1 -2
- wappa/domain/factories/media_factory.py +5 -20
- wappa/domain/factories/message_factory.py +5 -20
- wappa/domain/factories/messenger_factory.py +2 -4
- wappa/domain/interfaces/cache_interface.py +7 -7
- wappa/domain/interfaces/media_interface.py +2 -5
- wappa/domain/models/media_result.py +1 -3
- wappa/domain/models/platforms/platform_config.py +1 -3
- wappa/messaging/__init__.py +9 -12
- wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +20 -22
- wappa/models/__init__.py +27 -35
- wappa/persistence/__init__.py +12 -15
- wappa/persistence/cache_factory.py +0 -1
- wappa/persistence/json/__init__.py +1 -1
- wappa/persistence/json/cache_adapters.py +37 -25
- wappa/persistence/json/handlers/state_handler.py +60 -52
- wappa/persistence/json/handlers/table_handler.py +51 -49
- wappa/persistence/json/handlers/user_handler.py +71 -55
- wappa/persistence/json/handlers/utils/file_manager.py +42 -39
- wappa/persistence/json/handlers/utils/key_factory.py +1 -1
- wappa/persistence/json/handlers/utils/serialization.py +13 -11
- wappa/persistence/json/json_cache_factory.py +4 -8
- wappa/persistence/json/storage_manager.py +66 -79
- wappa/persistence/memory/__init__.py +1 -1
- wappa/persistence/memory/cache_adapters.py +37 -25
- wappa/persistence/memory/handlers/state_handler.py +62 -52
- wappa/persistence/memory/handlers/table_handler.py +59 -53
- wappa/persistence/memory/handlers/user_handler.py +75 -55
- wappa/persistence/memory/handlers/utils/key_factory.py +1 -1
- wappa/persistence/memory/handlers/utils/memory_store.py +75 -71
- wappa/persistence/memory/handlers/utils/ttl_manager.py +59 -67
- wappa/persistence/memory/memory_cache_factory.py +3 -7
- wappa/persistence/memory/storage_manager.py +52 -62
- wappa/persistence/redis/cache_adapters.py +27 -21
- wappa/persistence/redis/ops.py +11 -11
- wappa/persistence/redis/redis_client.py +4 -6
- wappa/persistence/redis/redis_manager.py +12 -4
- wappa/processors/factory.py +5 -5
- wappa/schemas/factory.py +2 -5
- wappa/schemas/whatsapp/message_types/errors.py +3 -12
- wappa/schemas/whatsapp/validators.py +3 -3
- wappa/webhooks/__init__.py +17 -18
- wappa/webhooks/factory.py +3 -5
- wappa/webhooks/whatsapp/__init__.py +10 -13
- wappa/webhooks/whatsapp/message_types/audio.py +0 -4
- wappa/webhooks/whatsapp/message_types/document.py +1 -9
- wappa/webhooks/whatsapp/message_types/errors.py +3 -12
- wappa/webhooks/whatsapp/message_types/location.py +1 -21
- wappa/webhooks/whatsapp/message_types/sticker.py +1 -5
- wappa/webhooks/whatsapp/message_types/text.py +0 -6
- wappa/webhooks/whatsapp/message_types/video.py +1 -20
- wappa/webhooks/whatsapp/status_models.py +2 -2
- wappa/webhooks/whatsapp/validators.py +3 -3
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/METADATA +362 -8
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/RECORD +144 -80
- wappa/cli/examples/init/pyproject.toml +0 -7
- wappa/cli/examples/simple_echo_example/.python-version +0 -1
- wappa/cli/examples/simple_echo_example/pyproject.toml +0 -9
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/WHEEL +0 -0
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/entry_points.txt +0 -0
- {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# List Command Media Files
|
|
2
|
+
|
|
3
|
+
This directory contains media files sent in response to list selections in the `/list` command demo.
|
|
4
|
+
|
|
5
|
+
## Required Files
|
|
6
|
+
|
|
7
|
+
Place these files in this directory:
|
|
8
|
+
|
|
9
|
+
### `image.png`
|
|
10
|
+
- **Purpose**: Sent when user selects "🖼️ Image" from the list
|
|
11
|
+
- **Format**: PNG or JPG image
|
|
12
|
+
- **Size limit**: 5MB maximum
|
|
13
|
+
- **Content**: Sample image to demonstrate image sending
|
|
14
|
+
- **Suggested**: Colorful demo image, infographic, or screenshot
|
|
15
|
+
|
|
16
|
+
### `video.mp4`
|
|
17
|
+
- **Purpose**: Sent when user selects "🎬 Video" from the list
|
|
18
|
+
- **Format**: MP4 video file
|
|
19
|
+
- **Size limit**: 16MB maximum
|
|
20
|
+
- **Duration**: Keep under 60 seconds for demo purposes
|
|
21
|
+
- **Content**: Sample video demonstrating video messaging
|
|
22
|
+
- **Suggested**: Short demo video, animation, or screen recording
|
|
23
|
+
|
|
24
|
+
### `audio.mp3`
|
|
25
|
+
- **Purpose**: Sent when user selects "🎵 Audio" from the list
|
|
26
|
+
- **Format**: MP3, OGG, or AAC audio file
|
|
27
|
+
- **Size limit**: 16MB maximum
|
|
28
|
+
- **Duration**: Keep under 2 minutes for demo purposes
|
|
29
|
+
- **Content**: Sample audio demonstrating audio messaging
|
|
30
|
+
- **Suggested**: Music clip, voice recording, or sound effect
|
|
31
|
+
|
|
32
|
+
### `document.pdf`
|
|
33
|
+
- **Purpose**: Sent when user selects "📄 Document" from the list
|
|
34
|
+
- **Format**: PDF document
|
|
35
|
+
- **Size limit**: 100MB maximum
|
|
36
|
+
- **Content**: Sample document demonstrating document sharing
|
|
37
|
+
- **Suggested**: User guide, specification sheet, or informational PDF
|
|
38
|
+
|
|
39
|
+
## How It Works
|
|
40
|
+
|
|
41
|
+
1. User sends `/list` command
|
|
42
|
+
2. App creates interactive list message with 4 media type options
|
|
43
|
+
3. User selects one option from the list
|
|
44
|
+
4. App sends the corresponding media file from this directory
|
|
45
|
+
5. User receives the media file with a caption
|
|
46
|
+
|
|
47
|
+
## Interactive List Structure
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"title": "📁 Media Files",
|
|
52
|
+
"rows": [
|
|
53
|
+
{"id": "image_file", "title": "🖼️ Image", "description": "Get a sample image file"},
|
|
54
|
+
{"id": "video_file", "title": "🎬 Video", "description": "Get a sample video file"},
|
|
55
|
+
{"id": "audio_file", "title": "🎵 Audio", "description": "Get a sample audio file"},
|
|
56
|
+
{"id": "document_file", "title": "📄 Document", "description": "Get a sample document file"}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## File Recommendations
|
|
62
|
+
|
|
63
|
+
### For Demo/Testing:
|
|
64
|
+
- **Image**: Screenshots of the app, logos, or demo graphics
|
|
65
|
+
- **Video**: App walkthrough, feature demonstration, or intro video
|
|
66
|
+
- **Audio**: Welcome message, jingle, or app sounds
|
|
67
|
+
- **Document**: User manual, API documentation, or feature list
|
|
68
|
+
|
|
69
|
+
### For Production:
|
|
70
|
+
- **Image**: Product catalogs, infographics, charts
|
|
71
|
+
- **Video**: Product demos, tutorials, testimonials
|
|
72
|
+
- **Audio**: Voice messages, audio guides, music
|
|
73
|
+
- **Document**: Contracts, invoices, manuals, reports
|
|
74
|
+
|
|
75
|
+
## WhatsApp Business API Limits
|
|
76
|
+
|
|
77
|
+
- **Images**: JPEG, PNG up to 5MB, 8-bit RGB or RGBA
|
|
78
|
+
- **Videos**: MP4, 3GP up to 16MB, H.264 codec, AAC audio
|
|
79
|
+
- **Audio**: AAC, AMR, MP3, M4A, OGG up to 16MB
|
|
80
|
+
- **Documents**: TXT, PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX up to 100MB
|
|
81
|
+
|
|
82
|
+
## Fallback Behavior
|
|
83
|
+
|
|
84
|
+
If files are missing:
|
|
85
|
+
- App will send a text message instead
|
|
86
|
+
- Message will indicate the selected media type
|
|
87
|
+
- List functionality will still work, but without actual media
|
|
88
|
+
|
|
89
|
+
## Example Implementation
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# In state_handlers.py
|
|
93
|
+
media_mapping = {
|
|
94
|
+
"image_file": ("image.png", "image"),
|
|
95
|
+
"video_file": ("video.mp4", "video"),
|
|
96
|
+
"audio_file": ("audio.mp3", "audio"),
|
|
97
|
+
"document_file": ("document.pdf", "document")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
media_file, media_type = media_mapping.get(selection_id, (None, None))
|
|
101
|
+
|
|
102
|
+
if media_file:
|
|
103
|
+
await send_local_media_file(
|
|
104
|
+
messenger=self.messenger,
|
|
105
|
+
recipient=user_id,
|
|
106
|
+
filename=media_file,
|
|
107
|
+
media_subdir="list",
|
|
108
|
+
caption=f"Here's your {media_type} file! 🎉"
|
|
109
|
+
)
|
|
110
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive state models for the Wappa Full Example application.
|
|
3
|
+
|
|
4
|
+
Contains models for managing button and list interactive states with TTL.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StateType(str, Enum):
|
|
15
|
+
"""Types of interactive states."""
|
|
16
|
+
|
|
17
|
+
BUTTON = "button"
|
|
18
|
+
LIST = "list"
|
|
19
|
+
CTA = "cta"
|
|
20
|
+
LOCATION = "location"
|
|
21
|
+
CUSTOM = "custom"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StateStatus(str, Enum):
|
|
25
|
+
"""Status of interactive states."""
|
|
26
|
+
|
|
27
|
+
ACTIVE = "active"
|
|
28
|
+
EXPIRED = "expired"
|
|
29
|
+
COMPLETED = "completed"
|
|
30
|
+
CANCELLED = "cancelled"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InteractiveState(BaseModel):
|
|
34
|
+
"""Base model for interactive session states (buttons/lists)."""
|
|
35
|
+
|
|
36
|
+
# State identification
|
|
37
|
+
state_id: str = Field(
|
|
38
|
+
default_factory=lambda: f"state_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
|
39
|
+
)
|
|
40
|
+
user_id: str
|
|
41
|
+
state_type: StateType
|
|
42
|
+
|
|
43
|
+
# State timing
|
|
44
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
45
|
+
expires_at: datetime
|
|
46
|
+
last_activity: datetime = Field(default_factory=datetime.now)
|
|
47
|
+
|
|
48
|
+
# State data
|
|
49
|
+
context: dict[str, Any] = Field(default_factory=dict)
|
|
50
|
+
original_message_id: str | None = None
|
|
51
|
+
interactive_message_id: str | None = None
|
|
52
|
+
|
|
53
|
+
# State management
|
|
54
|
+
status: StateStatus = StateStatus.ACTIVE
|
|
55
|
+
attempts: int = 0
|
|
56
|
+
max_attempts: int = 5
|
|
57
|
+
|
|
58
|
+
# Metadata
|
|
59
|
+
creation_metadata: dict[str, Any] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
@field_validator("user_id", mode="before")
|
|
62
|
+
@classmethod
|
|
63
|
+
def validate_user_id(cls, v):
|
|
64
|
+
"""Convert user ID to string if it's an integer."""
|
|
65
|
+
return str(v) if v is not None else v
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def create_with_ttl(
|
|
69
|
+
cls, user_id: str, state_type: StateType, ttl_seconds: int = 600, **kwargs
|
|
70
|
+
) -> "InteractiveState":
|
|
71
|
+
"""Create a new interactive state with TTL."""
|
|
72
|
+
expires_at = datetime.now() + timedelta(seconds=ttl_seconds)
|
|
73
|
+
return cls(
|
|
74
|
+
user_id=user_id, state_type=state_type, expires_at=expires_at, **kwargs
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def is_expired(self) -> bool:
|
|
78
|
+
"""Check if the state has expired."""
|
|
79
|
+
return datetime.now() > self.expires_at or self.status == StateStatus.EXPIRED
|
|
80
|
+
|
|
81
|
+
def is_active(self) -> bool:
|
|
82
|
+
"""Check if the state is currently active."""
|
|
83
|
+
return not self.is_expired() and self.status == StateStatus.ACTIVE
|
|
84
|
+
|
|
85
|
+
def time_remaining_seconds(self) -> int:
|
|
86
|
+
"""Get remaining time in seconds."""
|
|
87
|
+
if self.is_expired():
|
|
88
|
+
return 0
|
|
89
|
+
remaining = self.expires_at - datetime.now()
|
|
90
|
+
return max(0, int(remaining.total_seconds()))
|
|
91
|
+
|
|
92
|
+
def time_remaining_minutes(self) -> int:
|
|
93
|
+
"""Get remaining time in minutes."""
|
|
94
|
+
return max(0, self.time_remaining_seconds() // 60)
|
|
95
|
+
|
|
96
|
+
def increment_attempts(self) -> None:
|
|
97
|
+
"""Increment the attempts counter."""
|
|
98
|
+
self.attempts += 1
|
|
99
|
+
self.last_activity = datetime.now()
|
|
100
|
+
|
|
101
|
+
if self.attempts >= self.max_attempts:
|
|
102
|
+
self.status = StateStatus.CANCELLED
|
|
103
|
+
|
|
104
|
+
def mark_completed(self) -> None:
|
|
105
|
+
"""Mark the state as completed."""
|
|
106
|
+
self.status = StateStatus.COMPLETED
|
|
107
|
+
self.last_activity = datetime.now()
|
|
108
|
+
|
|
109
|
+
def mark_expired(self) -> None:
|
|
110
|
+
"""Mark the state as expired."""
|
|
111
|
+
self.status = StateStatus.EXPIRED
|
|
112
|
+
self.last_activity = datetime.now()
|
|
113
|
+
|
|
114
|
+
def mark_cancelled(self) -> None:
|
|
115
|
+
"""Mark the state as cancelled."""
|
|
116
|
+
self.status = StateStatus.CANCELLED
|
|
117
|
+
self.last_activity = datetime.now()
|
|
118
|
+
|
|
119
|
+
def update_context(self, key: str, value: Any) -> None:
|
|
120
|
+
"""Update a context value."""
|
|
121
|
+
self.context[key] = value
|
|
122
|
+
self.last_activity = datetime.now()
|
|
123
|
+
|
|
124
|
+
def get_context_value(self, key: str, default: Any = None) -> Any:
|
|
125
|
+
"""Get a context value."""
|
|
126
|
+
return self.context.get(key, default)
|
|
127
|
+
|
|
128
|
+
def extend_ttl(self, additional_seconds: int) -> None:
|
|
129
|
+
"""Extend the TTL of this state."""
|
|
130
|
+
if self.is_active():
|
|
131
|
+
self.expires_at = self.expires_at + timedelta(seconds=additional_seconds)
|
|
132
|
+
self.last_activity = datetime.now()
|
|
133
|
+
|
|
134
|
+
def get_summary(self) -> dict[str, Any]:
|
|
135
|
+
"""Get a summary of this state."""
|
|
136
|
+
return {
|
|
137
|
+
"state_id": self.state_id,
|
|
138
|
+
"user_id": self.user_id,
|
|
139
|
+
"state_type": self.state_type.value,
|
|
140
|
+
"status": self.status.value,
|
|
141
|
+
"is_active": self.is_active(),
|
|
142
|
+
"time_remaining_seconds": self.time_remaining_seconds(),
|
|
143
|
+
"attempts": self.attempts,
|
|
144
|
+
"max_attempts": self.max_attempts,
|
|
145
|
+
"created_at": self.created_at.isoformat(),
|
|
146
|
+
"expires_at": self.expires_at.isoformat(),
|
|
147
|
+
"context": self.context,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ButtonState(InteractiveState):
|
|
152
|
+
"""State model specifically for button interactions."""
|
|
153
|
+
|
|
154
|
+
state_type: StateType = StateType.BUTTON
|
|
155
|
+
|
|
156
|
+
# Button-specific data
|
|
157
|
+
button_options: list[dict[str, str]] = Field(default_factory=list)
|
|
158
|
+
selected_button_id: str | None = None
|
|
159
|
+
button_message_text: str | None = None
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def create_button_state(
|
|
163
|
+
cls,
|
|
164
|
+
user_id: str,
|
|
165
|
+
buttons: list[dict[str, str]],
|
|
166
|
+
message_text: str,
|
|
167
|
+
ttl_seconds: int = 600,
|
|
168
|
+
original_message_id: str = None,
|
|
169
|
+
) -> "ButtonState":
|
|
170
|
+
"""Create a new button state."""
|
|
171
|
+
return cls.create_with_ttl(
|
|
172
|
+
user_id=user_id,
|
|
173
|
+
state_type=StateType.BUTTON,
|
|
174
|
+
ttl_seconds=ttl_seconds,
|
|
175
|
+
button_options=buttons,
|
|
176
|
+
button_message_text=message_text,
|
|
177
|
+
original_message_id=original_message_id,
|
|
178
|
+
context={
|
|
179
|
+
"button_count": len(buttons),
|
|
180
|
+
"message_text": message_text,
|
|
181
|
+
"expected_selections": [btn.get("id", "") for btn in buttons],
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def is_valid_selection(self, button_id: str) -> bool:
|
|
186
|
+
"""Check if the button selection is valid."""
|
|
187
|
+
valid_ids = [btn.get("id", "") for btn in self.button_options]
|
|
188
|
+
return button_id in valid_ids
|
|
189
|
+
|
|
190
|
+
def handle_selection(self, button_id: str) -> bool:
|
|
191
|
+
"""Handle button selection."""
|
|
192
|
+
if self.is_valid_selection(button_id) and self.is_active():
|
|
193
|
+
self.selected_button_id = button_id
|
|
194
|
+
self.mark_completed()
|
|
195
|
+
self.update_context("selected_button_id", button_id)
|
|
196
|
+
self.update_context("selected_at", datetime.now().isoformat())
|
|
197
|
+
return True
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
def get_selected_button(self) -> dict[str, str] | None:
|
|
201
|
+
"""Get the selected button information."""
|
|
202
|
+
if not self.selected_button_id:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
for button in self.button_options:
|
|
206
|
+
if button.get("id") == self.selected_button_id:
|
|
207
|
+
return button
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ListState(InteractiveState):
|
|
212
|
+
"""State model specifically for list interactions."""
|
|
213
|
+
|
|
214
|
+
state_type: StateType = StateType.LIST
|
|
215
|
+
|
|
216
|
+
# List-specific data
|
|
217
|
+
list_sections: list[dict[str, Any]] = Field(default_factory=list)
|
|
218
|
+
selected_item_id: str | None = None
|
|
219
|
+
list_button_text: str = "Choose an option"
|
|
220
|
+
list_message_text: str | None = None
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def create_list_state(
|
|
224
|
+
cls,
|
|
225
|
+
user_id: str,
|
|
226
|
+
sections: list[dict[str, Any]],
|
|
227
|
+
message_text: str,
|
|
228
|
+
button_text: str = "Choose an option",
|
|
229
|
+
ttl_seconds: int = 600,
|
|
230
|
+
original_message_id: str = None,
|
|
231
|
+
) -> "ListState":
|
|
232
|
+
"""Create a new list state."""
|
|
233
|
+
# Extract all possible item IDs for validation
|
|
234
|
+
valid_ids = []
|
|
235
|
+
for section in sections:
|
|
236
|
+
rows = section.get("rows", [])
|
|
237
|
+
for row in rows:
|
|
238
|
+
if "id" in row:
|
|
239
|
+
valid_ids.append(row["id"])
|
|
240
|
+
|
|
241
|
+
return cls.create_with_ttl(
|
|
242
|
+
user_id=user_id,
|
|
243
|
+
state_type=StateType.LIST,
|
|
244
|
+
ttl_seconds=ttl_seconds,
|
|
245
|
+
list_sections=sections,
|
|
246
|
+
list_message_text=message_text,
|
|
247
|
+
list_button_text=button_text,
|
|
248
|
+
original_message_id=original_message_id,
|
|
249
|
+
context={
|
|
250
|
+
"sections_count": len(sections),
|
|
251
|
+
"total_items": len(valid_ids),
|
|
252
|
+
"message_text": message_text,
|
|
253
|
+
"button_text": button_text,
|
|
254
|
+
"expected_selections": valid_ids,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def is_valid_selection(self, item_id: str) -> bool:
|
|
259
|
+
"""Check if the list item selection is valid."""
|
|
260
|
+
valid_ids = self.get_context_value("expected_selections", [])
|
|
261
|
+
return item_id in valid_ids
|
|
262
|
+
|
|
263
|
+
def handle_selection(self, item_id: str) -> bool:
|
|
264
|
+
"""Handle list item selection."""
|
|
265
|
+
if self.is_valid_selection(item_id) and self.is_active():
|
|
266
|
+
self.selected_item_id = item_id
|
|
267
|
+
self.mark_completed()
|
|
268
|
+
self.update_context("selected_item_id", item_id)
|
|
269
|
+
self.update_context("selected_at", datetime.now().isoformat())
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
def get_selected_item(self) -> dict[str, Any] | None:
|
|
274
|
+
"""Get the selected list item information."""
|
|
275
|
+
if not self.selected_item_id:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
for section in self.list_sections:
|
|
279
|
+
rows = section.get("rows", [])
|
|
280
|
+
for row in rows:
|
|
281
|
+
if row.get("id") == self.selected_item_id:
|
|
282
|
+
return row
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class CommandState(InteractiveState):
|
|
287
|
+
"""State model for command-based interactions."""
|
|
288
|
+
|
|
289
|
+
state_type: StateType = StateType.CUSTOM
|
|
290
|
+
|
|
291
|
+
# Command-specific data
|
|
292
|
+
command_name: str
|
|
293
|
+
expected_responses: list[str] = Field(default_factory=list)
|
|
294
|
+
current_step: int = 0
|
|
295
|
+
total_steps: int = 1
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def create_command_state(
|
|
299
|
+
cls,
|
|
300
|
+
user_id: str,
|
|
301
|
+
command_name: str,
|
|
302
|
+
expected_responses: list[str] = None,
|
|
303
|
+
ttl_seconds: int = 600,
|
|
304
|
+
total_steps: int = 1,
|
|
305
|
+
original_message_id: str = None,
|
|
306
|
+
) -> "CommandState":
|
|
307
|
+
"""Create a new command state."""
|
|
308
|
+
return cls.create_with_ttl(
|
|
309
|
+
user_id=user_id,
|
|
310
|
+
state_type=StateType.CUSTOM,
|
|
311
|
+
ttl_seconds=ttl_seconds,
|
|
312
|
+
command_name=command_name,
|
|
313
|
+
expected_responses=expected_responses or [],
|
|
314
|
+
total_steps=total_steps,
|
|
315
|
+
original_message_id=original_message_id,
|
|
316
|
+
context={
|
|
317
|
+
"command_name": command_name,
|
|
318
|
+
"expected_responses": expected_responses or [],
|
|
319
|
+
"total_steps": total_steps,
|
|
320
|
+
"current_step": 0,
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def advance_step(self) -> bool:
|
|
325
|
+
"""Advance to the next step."""
|
|
326
|
+
if self.current_step < self.total_steps - 1:
|
|
327
|
+
self.current_step += 1
|
|
328
|
+
self.update_context("current_step", self.current_step)
|
|
329
|
+
return True
|
|
330
|
+
else:
|
|
331
|
+
self.mark_completed()
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
def is_final_step(self) -> bool:
|
|
335
|
+
"""Check if this is the final step."""
|
|
336
|
+
return self.current_step >= self.total_steps - 1
|
|
337
|
+
|
|
338
|
+
def get_progress_percentage(self) -> float:
|
|
339
|
+
"""Get the progress percentage."""
|
|
340
|
+
if self.total_steps <= 1:
|
|
341
|
+
return 100.0 if self.status == StateStatus.COMPLETED else 0.0
|
|
342
|
+
return (self.current_step / self.total_steps) * 100.0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class StateManager(BaseModel):
|
|
346
|
+
"""Manager for handling multiple interactive states."""
|
|
347
|
+
|
|
348
|
+
active_states: dict[str, InteractiveState] = Field(default_factory=dict)
|
|
349
|
+
completed_states: dict[str, InteractiveState] = Field(default_factory=dict)
|
|
350
|
+
|
|
351
|
+
def add_state(self, state: InteractiveState) -> None:
|
|
352
|
+
"""Add a new state to the manager."""
|
|
353
|
+
state_key = f"{state.user_id}_{state.state_type.value}"
|
|
354
|
+
|
|
355
|
+
# Remove any existing state of the same type for this user
|
|
356
|
+
if state_key in self.active_states:
|
|
357
|
+
old_state = self.active_states[state_key]
|
|
358
|
+
old_state.mark_cancelled()
|
|
359
|
+
self.completed_states[f"{state_key}_{old_state.state_id}"] = old_state
|
|
360
|
+
|
|
361
|
+
self.active_states[state_key] = state
|
|
362
|
+
|
|
363
|
+
def get_user_state(
|
|
364
|
+
self, user_id: str, state_type: StateType
|
|
365
|
+
) -> InteractiveState | None:
|
|
366
|
+
"""Get the active state for a user and state type."""
|
|
367
|
+
state_key = f"{user_id}_{state_type.value}"
|
|
368
|
+
state = self.active_states.get(state_key)
|
|
369
|
+
|
|
370
|
+
if state and state.is_expired():
|
|
371
|
+
state.mark_expired()
|
|
372
|
+
self.completed_states[f"{state_key}_{state.state_id}"] = state
|
|
373
|
+
del self.active_states[state_key]
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
return state if state and state.is_active() else None
|
|
377
|
+
|
|
378
|
+
def remove_state(
|
|
379
|
+
self, user_id: str, state_type: StateType
|
|
380
|
+
) -> InteractiveState | None:
|
|
381
|
+
"""Remove a state from active states."""
|
|
382
|
+
state_key = f"{user_id}_{state_type.value}"
|
|
383
|
+
state = self.active_states.pop(state_key, None)
|
|
384
|
+
|
|
385
|
+
if state:
|
|
386
|
+
self.completed_states[f"{state_key}_{state.state_id}"] = state
|
|
387
|
+
|
|
388
|
+
return state
|
|
389
|
+
|
|
390
|
+
def cleanup_expired_states(self) -> int:
|
|
391
|
+
"""Clean up expired states and return count of cleaned states."""
|
|
392
|
+
expired_count = 0
|
|
393
|
+
expired_keys = []
|
|
394
|
+
|
|
395
|
+
for state_key, state in self.active_states.items():
|
|
396
|
+
if state.is_expired():
|
|
397
|
+
state.mark_expired()
|
|
398
|
+
self.completed_states[f"{state_key}_{state.state_id}"] = state
|
|
399
|
+
expired_keys.append(state_key)
|
|
400
|
+
expired_count += 1
|
|
401
|
+
|
|
402
|
+
for key in expired_keys:
|
|
403
|
+
del self.active_states[key]
|
|
404
|
+
|
|
405
|
+
return expired_count
|
|
406
|
+
|
|
407
|
+
def get_user_states(self, user_id: str) -> list[InteractiveState]:
|
|
408
|
+
"""Get all active states for a user."""
|
|
409
|
+
user_states = []
|
|
410
|
+
for state in self.active_states.values():
|
|
411
|
+
if state.user_id == user_id and state.is_active():
|
|
412
|
+
user_states.append(state)
|
|
413
|
+
return user_states
|
|
414
|
+
|
|
415
|
+
def get_statistics(self) -> dict[str, Any]:
|
|
416
|
+
"""Get manager statistics."""
|
|
417
|
+
active_count = len(self.active_states)
|
|
418
|
+
completed_count = len(self.completed_states)
|
|
419
|
+
|
|
420
|
+
# Count by state type
|
|
421
|
+
type_counts = {}
|
|
422
|
+
for state in self.active_states.values():
|
|
423
|
+
state_type = state.state_type.value
|
|
424
|
+
type_counts[state_type] = type_counts.get(state_type, 0) + 1
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
"active_states": active_count,
|
|
428
|
+
"completed_states": completed_count,
|
|
429
|
+
"total_states": active_count + completed_count,
|
|
430
|
+
"state_type_distribution": type_counts,
|
|
431
|
+
"cleanup_needed": sum(
|
|
432
|
+
1 for state in self.active_states.values() if state.is_expired()
|
|
433
|
+
),
|
|
434
|
+
}
|