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.

Files changed (147) hide show
  1. wappa/__init__.py +4 -5
  2. wappa/api/controllers/webhook_controller.py +5 -2
  3. wappa/api/dependencies/__init__.py +0 -5
  4. wappa/api/middleware/error_handler.py +4 -4
  5. wappa/api/middleware/owner.py +11 -5
  6. wappa/api/routes/webhooks.py +2 -2
  7. wappa/cli/__init__.py +1 -1
  8. wappa/cli/examples/init/.env.example +33 -0
  9. wappa/cli/examples/init/app/__init__.py +0 -0
  10. wappa/cli/examples/init/app/main.py +9 -0
  11. wappa/cli/examples/init/app/master_event.py +10 -0
  12. wappa/cli/examples/json_cache_example/.env.example +33 -0
  13. wappa/cli/examples/json_cache_example/app/__init__.py +1 -0
  14. wappa/cli/examples/json_cache_example/app/main.py +247 -0
  15. wappa/cli/examples/json_cache_example/app/master_event.py +455 -0
  16. wappa/cli/examples/json_cache_example/app/models/__init__.py +1 -0
  17. wappa/cli/examples/json_cache_example/app/models/json_demo_models.py +256 -0
  18. wappa/cli/examples/json_cache_example/app/scores/__init__.py +35 -0
  19. wappa/cli/examples/json_cache_example/app/scores/score_base.py +192 -0
  20. wappa/cli/examples/json_cache_example/app/scores/score_cache_statistics.py +256 -0
  21. wappa/cli/examples/json_cache_example/app/scores/score_message_history.py +187 -0
  22. wappa/cli/examples/json_cache_example/app/scores/score_state_commands.py +272 -0
  23. wappa/cli/examples/json_cache_example/app/scores/score_user_management.py +239 -0
  24. wappa/cli/examples/json_cache_example/app/utils/__init__.py +26 -0
  25. wappa/cli/examples/json_cache_example/app/utils/cache_utils.py +174 -0
  26. wappa/cli/examples/json_cache_example/app/utils/message_utils.py +251 -0
  27. wappa/cli/examples/openai_transcript/.gitignore +63 -4
  28. wappa/cli/examples/openai_transcript/app/__init__.py +0 -0
  29. wappa/cli/examples/openai_transcript/app/main.py +9 -0
  30. wappa/cli/examples/openai_transcript/app/master_event.py +62 -0
  31. wappa/cli/examples/openai_transcript/app/openai_utils/__init__.py +3 -0
  32. wappa/cli/examples/openai_transcript/app/openai_utils/audio_processing.py +89 -0
  33. wappa/cli/examples/redis_cache_example/.env.example +33 -0
  34. wappa/cli/examples/redis_cache_example/app/__init__.py +6 -0
  35. wappa/cli/examples/redis_cache_example/app/main.py +246 -0
  36. wappa/cli/examples/redis_cache_example/app/master_event.py +455 -0
  37. wappa/cli/examples/redis_cache_example/app/models/redis_demo_models.py +256 -0
  38. wappa/cli/examples/redis_cache_example/app/scores/__init__.py +35 -0
  39. wappa/cli/examples/redis_cache_example/app/scores/score_base.py +192 -0
  40. wappa/cli/examples/redis_cache_example/app/scores/score_cache_statistics.py +256 -0
  41. wappa/cli/examples/redis_cache_example/app/scores/score_message_history.py +187 -0
  42. wappa/cli/examples/redis_cache_example/app/scores/score_state_commands.py +272 -0
  43. wappa/cli/examples/redis_cache_example/app/scores/score_user_management.py +239 -0
  44. wappa/cli/examples/redis_cache_example/app/utils/__init__.py +26 -0
  45. wappa/cli/examples/redis_cache_example/app/utils/cache_utils.py +174 -0
  46. wappa/cli/examples/redis_cache_example/app/utils/message_utils.py +251 -0
  47. wappa/cli/examples/simple_echo_example/.env.example +33 -0
  48. wappa/cli/examples/simple_echo_example/app/__init__.py +7 -0
  49. wappa/cli/examples/simple_echo_example/app/main.py +191 -0
  50. wappa/cli/examples/simple_echo_example/app/master_event.py +230 -0
  51. wappa/cli/examples/wappa_full_example/.env.example +33 -0
  52. wappa/cli/examples/wappa_full_example/.gitignore +63 -4
  53. wappa/cli/examples/wappa_full_example/app/__init__.py +6 -0
  54. wappa/cli/examples/wappa_full_example/app/handlers/__init__.py +5 -0
  55. wappa/cli/examples/wappa_full_example/app/handlers/command_handlers.py +492 -0
  56. wappa/cli/examples/wappa_full_example/app/handlers/message_handlers.py +559 -0
  57. wappa/cli/examples/wappa_full_example/app/handlers/state_handlers.py +514 -0
  58. wappa/cli/examples/wappa_full_example/app/main.py +269 -0
  59. wappa/cli/examples/wappa_full_example/app/master_event.py +504 -0
  60. wappa/cli/examples/wappa_full_example/app/media/README.md +54 -0
  61. wappa/cli/examples/wappa_full_example/app/media/buttons/README.md +62 -0
  62. wappa/cli/examples/wappa_full_example/app/media/buttons/kitty.png +0 -0
  63. wappa/cli/examples/wappa_full_example/app/media/buttons/puppy.png +0 -0
  64. wappa/cli/examples/wappa_full_example/app/media/list/README.md +110 -0
  65. wappa/cli/examples/wappa_full_example/app/media/list/audio.mp3 +0 -0
  66. wappa/cli/examples/wappa_full_example/app/media/list/document.pdf +0 -0
  67. wappa/cli/examples/wappa_full_example/app/media/list/image.png +0 -0
  68. wappa/cli/examples/wappa_full_example/app/media/list/video.mp4 +0 -0
  69. wappa/cli/examples/wappa_full_example/app/models/__init__.py +5 -0
  70. wappa/cli/examples/wappa_full_example/app/models/state_models.py +434 -0
  71. wappa/cli/examples/wappa_full_example/app/models/user_models.py +303 -0
  72. wappa/cli/examples/wappa_full_example/app/models/webhook_metadata.py +327 -0
  73. wappa/cli/examples/wappa_full_example/app/utils/__init__.py +5 -0
  74. wappa/cli/examples/wappa_full_example/app/utils/cache_utils.py +502 -0
  75. wappa/cli/examples/wappa_full_example/app/utils/media_handler.py +516 -0
  76. wappa/cli/examples/wappa_full_example/app/utils/metadata_extractor.py +337 -0
  77. wappa/cli/main.py +14 -5
  78. wappa/core/__init__.py +18 -23
  79. wappa/core/config/settings.py +7 -5
  80. wappa/core/events/default_handlers.py +1 -1
  81. wappa/core/factory/wappa_builder.py +38 -25
  82. wappa/core/plugins/redis_plugin.py +1 -3
  83. wappa/core/plugins/wappa_core_plugin.py +7 -6
  84. wappa/core/types.py +12 -12
  85. wappa/core/wappa_app.py +10 -8
  86. wappa/database/__init__.py +3 -4
  87. wappa/domain/enums/messenger_platform.py +1 -2
  88. wappa/domain/factories/media_factory.py +5 -20
  89. wappa/domain/factories/message_factory.py +5 -20
  90. wappa/domain/factories/messenger_factory.py +2 -4
  91. wappa/domain/interfaces/cache_interface.py +7 -7
  92. wappa/domain/interfaces/media_interface.py +2 -5
  93. wappa/domain/models/media_result.py +1 -3
  94. wappa/domain/models/platforms/platform_config.py +1 -3
  95. wappa/messaging/__init__.py +9 -12
  96. wappa/messaging/whatsapp/handlers/whatsapp_media_handler.py +20 -22
  97. wappa/models/__init__.py +27 -35
  98. wappa/persistence/__init__.py +12 -15
  99. wappa/persistence/cache_factory.py +0 -1
  100. wappa/persistence/json/__init__.py +1 -1
  101. wappa/persistence/json/cache_adapters.py +37 -25
  102. wappa/persistence/json/handlers/state_handler.py +60 -52
  103. wappa/persistence/json/handlers/table_handler.py +51 -49
  104. wappa/persistence/json/handlers/user_handler.py +71 -55
  105. wappa/persistence/json/handlers/utils/file_manager.py +42 -39
  106. wappa/persistence/json/handlers/utils/key_factory.py +1 -1
  107. wappa/persistence/json/handlers/utils/serialization.py +13 -11
  108. wappa/persistence/json/json_cache_factory.py +4 -8
  109. wappa/persistence/json/storage_manager.py +66 -79
  110. wappa/persistence/memory/__init__.py +1 -1
  111. wappa/persistence/memory/cache_adapters.py +37 -25
  112. wappa/persistence/memory/handlers/state_handler.py +62 -52
  113. wappa/persistence/memory/handlers/table_handler.py +59 -53
  114. wappa/persistence/memory/handlers/user_handler.py +75 -55
  115. wappa/persistence/memory/handlers/utils/key_factory.py +1 -1
  116. wappa/persistence/memory/handlers/utils/memory_store.py +75 -71
  117. wappa/persistence/memory/handlers/utils/ttl_manager.py +59 -67
  118. wappa/persistence/memory/memory_cache_factory.py +3 -7
  119. wappa/persistence/memory/storage_manager.py +52 -62
  120. wappa/persistence/redis/cache_adapters.py +27 -21
  121. wappa/persistence/redis/ops.py +11 -11
  122. wappa/persistence/redis/redis_client.py +4 -6
  123. wappa/persistence/redis/redis_manager.py +12 -4
  124. wappa/processors/factory.py +5 -5
  125. wappa/schemas/factory.py +2 -5
  126. wappa/schemas/whatsapp/message_types/errors.py +3 -12
  127. wappa/schemas/whatsapp/validators.py +3 -3
  128. wappa/webhooks/__init__.py +17 -18
  129. wappa/webhooks/factory.py +3 -5
  130. wappa/webhooks/whatsapp/__init__.py +10 -13
  131. wappa/webhooks/whatsapp/message_types/audio.py +0 -4
  132. wappa/webhooks/whatsapp/message_types/document.py +1 -9
  133. wappa/webhooks/whatsapp/message_types/errors.py +3 -12
  134. wappa/webhooks/whatsapp/message_types/location.py +1 -21
  135. wappa/webhooks/whatsapp/message_types/sticker.py +1 -5
  136. wappa/webhooks/whatsapp/message_types/text.py +0 -6
  137. wappa/webhooks/whatsapp/message_types/video.py +1 -20
  138. wappa/webhooks/whatsapp/status_models.py +2 -2
  139. wappa/webhooks/whatsapp/validators.py +3 -3
  140. {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/METADATA +362 -8
  141. {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/RECORD +144 -80
  142. wappa/cli/examples/init/pyproject.toml +0 -7
  143. wappa/cli/examples/simple_echo_example/.python-version +0 -1
  144. wappa/cli/examples/simple_echo_example/pyproject.toml +0 -9
  145. {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/WHEEL +0 -0
  146. {wappa-0.1.8.dist-info → wappa-0.1.10.dist-info}/entry_points.txt +0 -0
  147. {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
+ ```
@@ -0,0 +1,5 @@
1
+ """
2
+ Pydantic models for the Wappa Full Example application.
3
+
4
+ Contains models for webhook metadata, user tracking, and interactive states.
5
+ """
@@ -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
+ }