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,514 @@
1
+ """
2
+ State handlers for interactive features in the Wappa Full Example application.
3
+
4
+ This module provides handlers for managing interactive states like button and list
5
+ selections, including state validation, response processing, and cleanup.
6
+ """
7
+
8
+ import time
9
+
10
+ from wappa.webhooks import IncomingMessageWebhook
11
+
12
+ from ..models.state_models import ButtonState, ListState, StateType
13
+ from ..models.user_models import UserProfile
14
+ from ..utils.cache_utils import CacheHelper
15
+ from ..utils.media_handler import send_local_media_file
16
+ from ..utils.metadata_extractor import MetadataExtractor
17
+
18
+
19
+ class StateHandlers:
20
+ """Collection of handlers for interactive state management."""
21
+
22
+ def __init__(self, messenger, cache_factory, logger):
23
+ """
24
+ Initialize state handlers.
25
+
26
+ Args:
27
+ messenger: IMessenger instance for sending messages
28
+ cache_factory: Cache factory for data persistence
29
+ logger: Logger instance
30
+ """
31
+ self.messenger = messenger
32
+ self.cache_helper = CacheHelper(cache_factory)
33
+ self.logger = logger
34
+
35
+ async def handle_button_state_response(
36
+ self,
37
+ webhook: IncomingMessageWebhook,
38
+ user_profile: UserProfile,
39
+ button_state: ButtonState,
40
+ ) -> dict[str, any]:
41
+ """
42
+ Handle response when user is in button state.
43
+
44
+ Args:
45
+ webhook: IncomingMessageWebhook with user input
46
+ user_profile: User profile for tracking
47
+ button_state: Active button state
48
+
49
+ Returns:
50
+ Result dictionary with operation status
51
+ """
52
+ try:
53
+ start_time = time.time()
54
+
55
+ user_id = webhook.user.user_id
56
+ message_id = webhook.message.message_id
57
+ message_type = webhook.get_message_type_name()
58
+
59
+ self.logger.info(f"🔘 Processing button state response from {user_id}")
60
+
61
+ # Check if this is an interactive button selection
62
+ if message_type == "interactive":
63
+ selection_id = webhook.get_interactive_selection()
64
+
65
+ if button_state.is_valid_selection(selection_id):
66
+ # Valid button selection - process it
67
+ return await self._process_button_selection(
68
+ webhook, user_profile, button_state, selection_id, start_time
69
+ )
70
+ else:
71
+ # Invalid selection
72
+ await self._send_invalid_button_selection_message(
73
+ user_id, message_id, selection_id
74
+ )
75
+ button_state.increment_attempts()
76
+ await self.cache_helper.save_user_state(button_state)
77
+
78
+ return {
79
+ "success": False,
80
+ "error": "Invalid button selection",
81
+ "selection_id": selection_id,
82
+ "attempts": button_state.attempts,
83
+ }
84
+ else:
85
+ # Non-interactive message while in button state - send reminder
86
+ await self._send_button_state_reminder(
87
+ user_id, message_id, button_state
88
+ )
89
+ button_state.increment_attempts()
90
+ await self.cache_helper.save_user_state(button_state)
91
+
92
+ return {
93
+ "success": False,
94
+ "error": "Expected button selection",
95
+ "message_type": message_type,
96
+ "reminder_sent": True,
97
+ "attempts": button_state.attempts,
98
+ }
99
+
100
+ except Exception as e:
101
+ self.logger.error(
102
+ f"❌ Error handling button state response: {e}", exc_info=True
103
+ )
104
+ return {"success": False, "error": str(e)}
105
+
106
+ async def handle_list_state_response(
107
+ self,
108
+ webhook: IncomingMessageWebhook,
109
+ user_profile: UserProfile,
110
+ list_state: ListState,
111
+ ) -> dict[str, any]:
112
+ """
113
+ Handle response when user is in list state.
114
+
115
+ Args:
116
+ webhook: IncomingMessageWebhook with user input
117
+ user_profile: User profile for tracking
118
+ list_state: Active list state
119
+
120
+ Returns:
121
+ Result dictionary with operation status
122
+ """
123
+ try:
124
+ start_time = time.time()
125
+
126
+ user_id = webhook.user.user_id
127
+ message_id = webhook.message.message_id
128
+ message_type = webhook.get_message_type_name()
129
+
130
+ self.logger.info(f"📋 Processing list state response from {user_id}")
131
+
132
+ # Check if this is an interactive list selection
133
+ if message_type == "interactive":
134
+ selection_id = webhook.get_interactive_selection()
135
+
136
+ if list_state.is_valid_selection(selection_id):
137
+ # Valid list selection - process it
138
+ return await self._process_list_selection(
139
+ webhook, user_profile, list_state, selection_id, start_time
140
+ )
141
+ else:
142
+ # Invalid selection
143
+ await self._send_invalid_list_selection_message(
144
+ user_id, message_id, selection_id
145
+ )
146
+ list_state.increment_attempts()
147
+ await self.cache_helper.save_user_state(list_state)
148
+
149
+ return {
150
+ "success": False,
151
+ "error": "Invalid list selection",
152
+ "selection_id": selection_id,
153
+ "attempts": list_state.attempts,
154
+ }
155
+ else:
156
+ # Non-interactive message while in list state - send reminder
157
+ await self._send_list_state_reminder(user_id, message_id, list_state)
158
+ list_state.increment_attempts()
159
+ await self.cache_helper.save_user_state(list_state)
160
+
161
+ return {
162
+ "success": False,
163
+ "error": "Expected list selection",
164
+ "message_type": message_type,
165
+ "reminder_sent": True,
166
+ "attempts": list_state.attempts,
167
+ }
168
+
169
+ except Exception as e:
170
+ self.logger.error(
171
+ f"❌ Error handling list state response: {e}", exc_info=True
172
+ )
173
+ return {"success": False, "error": str(e)}
174
+
175
+ async def _process_button_selection(
176
+ self,
177
+ webhook: IncomingMessageWebhook,
178
+ user_profile: UserProfile,
179
+ button_state: ButtonState,
180
+ selection_id: str,
181
+ start_time: float,
182
+ ) -> dict[str, any]:
183
+ """Process valid button selection."""
184
+ user_id = webhook.user.user_id
185
+ message_id = webhook.message.message_id
186
+
187
+ # Handle the selection in the state
188
+ button_state.handle_selection(selection_id)
189
+ selected_button = button_state.get_selected_button()
190
+
191
+ # Remove state from cache
192
+ await self.cache_helper.remove_user_state(user_id, StateType.BUTTON)
193
+
194
+ # Extract and format metadata
195
+ metadata = MetadataExtractor.extract_metadata(webhook, start_time)
196
+ metadata_text = MetadataExtractor.format_metadata_for_user(metadata)
197
+
198
+ # Send metadata response
199
+ await self.messenger.send_text(
200
+ recipient=user_id, text=metadata_text, reply_to_message_id=message_id
201
+ )
202
+
203
+ # Send corresponding media file based on selection
204
+ media_sent = False
205
+ media_file = None
206
+
207
+ if selection_id == "kitty":
208
+ media_file = "kitty.png"
209
+ elif selection_id == "puppy":
210
+ media_file = "puppy.png"
211
+
212
+ if media_file:
213
+ media_result = await send_local_media_file(
214
+ messenger=self.messenger,
215
+ recipient=user_id,
216
+ filename=media_file,
217
+ media_subdir="buttons",
218
+ caption=f"Here's your {selection_id}! 🎉",
219
+ )
220
+ media_sent = media_result["success"]
221
+
222
+ if not media_sent:
223
+ # Send fallback text if media fails
224
+ fallback_text = f"🎉 You selected: *{selected_button['title']}*\n\n(Media file not found: {media_file})"
225
+ await self.messenger.send_text(recipient=user_id, text=fallback_text)
226
+
227
+ # Send completion message
228
+ completion_text = (
229
+ f"✅ *Button Selection Complete!*\n\n"
230
+ f"🎯 *Your choice*: {selected_button['title']}\n"
231
+ f"🆔 *Selection ID*: `{selection_id}`\n"
232
+ f"⏱️ *Response time*: {int((time.time() - start_time) * 1000)}ms\n"
233
+ f"🗑️ *State cleaned up*: Button state removed\n\n"
234
+ f"💡 *What happened*: You successfully clicked a button, received metadata, "
235
+ f"and got your chosen animal {'image' if media_sent else '(image failed to load)'}!"
236
+ )
237
+
238
+ await self.messenger.send_text(recipient=user_id, text=completion_text)
239
+
240
+ # Update user activity
241
+ await self.cache_helper.update_user_activity(
242
+ user_id, "interactive", interaction_type="button"
243
+ )
244
+
245
+ processing_time = int((time.time() - start_time) * 1000)
246
+ self.logger.info(f"✅ Button selection processed in {processing_time}ms")
247
+
248
+ return {
249
+ "success": True,
250
+ "selection_type": "button",
251
+ "selection_id": selection_id,
252
+ "selected_button": selected_button,
253
+ "metadata_sent": True,
254
+ "media_sent": media_sent,
255
+ "media_file": media_file,
256
+ "state_cleaned": True,
257
+ "processing_time_ms": processing_time,
258
+ }
259
+
260
+ async def _process_list_selection(
261
+ self,
262
+ webhook: IncomingMessageWebhook,
263
+ user_profile: UserProfile,
264
+ list_state: ListState,
265
+ selection_id: str,
266
+ start_time: float,
267
+ ) -> dict[str, any]:
268
+ """Process valid list selection."""
269
+ user_id = webhook.user.user_id
270
+ message_id = webhook.message.message_id
271
+
272
+ # Handle the selection in the state
273
+ list_state.handle_selection(selection_id)
274
+ selected_item = list_state.get_selected_item()
275
+
276
+ # Remove state from cache
277
+ await self.cache_helper.remove_user_state(user_id, StateType.LIST)
278
+
279
+ # Extract and format metadata
280
+ metadata = MetadataExtractor.extract_metadata(webhook, start_time)
281
+ metadata_text = MetadataExtractor.format_metadata_for_user(metadata)
282
+
283
+ # Send metadata response
284
+ await self.messenger.send_text(
285
+ recipient=user_id, text=metadata_text, reply_to_message_id=message_id
286
+ )
287
+
288
+ # Send corresponding media file based on selection
289
+ media_sent = False
290
+ media_file = None
291
+ media_type = None
292
+
293
+ # Map selection to media file
294
+ if selection_id == "image_file":
295
+ media_file = "image.png"
296
+ media_type = "image"
297
+ elif selection_id == "video_file":
298
+ media_file = "video.mp4"
299
+ media_type = "video"
300
+ elif selection_id == "audio_file":
301
+ media_file = "audio.mp3"
302
+ media_type = "audio"
303
+ elif selection_id == "document_file":
304
+ media_file = "document.pdf"
305
+ media_type = "document"
306
+
307
+ if media_file and media_type:
308
+ media_result = await send_local_media_file(
309
+ messenger=self.messenger,
310
+ recipient=user_id,
311
+ filename=media_file,
312
+ media_subdir="list",
313
+ caption=f"Here's your {media_type} file! 🎉",
314
+ )
315
+ media_sent = media_result["success"]
316
+
317
+ if not media_sent:
318
+ # Send fallback text if media fails
319
+ fallback_text = f"🎉 You selected: *{selected_item['title']}*\n\n(Media file not found: {media_file})"
320
+ await self.messenger.send_text(recipient=user_id, text=fallback_text)
321
+
322
+ # Send completion message
323
+ completion_text = (
324
+ f"✅ *List Selection Complete!*\n\n"
325
+ f"🎯 *Your choice*: {selected_item['title']}\n"
326
+ f"📝 *Description*: {selected_item.get('description', 'N/A')}\n"
327
+ f"🆔 *Selection ID*: `{selection_id}`\n"
328
+ f"📁 *Media type*: {media_type}\n"
329
+ f"⏱️ *Response time*: {int((time.time() - start_time) * 1000)}ms\n"
330
+ f"🗑️ *State cleaned up*: List state removed\n\n"
331
+ f"💡 *What happened*: You successfully selected from a list, received metadata, "
332
+ f"and got your chosen {media_type} {'file' if media_sent else '(file failed to load)'}!"
333
+ )
334
+
335
+ await self.messenger.send_text(recipient=user_id, text=completion_text)
336
+
337
+ # Update user activity
338
+ await self.cache_helper.update_user_activity(
339
+ user_id, "interactive", interaction_type="list"
340
+ )
341
+
342
+ processing_time = int((time.time() - start_time) * 1000)
343
+ self.logger.info(f"✅ List selection processed in {processing_time}ms")
344
+
345
+ return {
346
+ "success": True,
347
+ "selection_type": "list",
348
+ "selection_id": selection_id,
349
+ "selected_item": selected_item,
350
+ "metadata_sent": True,
351
+ "media_sent": media_sent,
352
+ "media_file": media_file,
353
+ "media_type": media_type,
354
+ "state_cleaned": True,
355
+ "processing_time_ms": processing_time,
356
+ }
357
+
358
+ async def _send_button_state_reminder(
359
+ self, user_id: str, message_id: str, button_state: ButtonState
360
+ ) -> None:
361
+ """Send reminder message when user is in button state."""
362
+ time_remaining = button_state.time_remaining_minutes()
363
+
364
+ reminder_text = (
365
+ f"🔘 *Hey Wappa! We love your enthusiasm, but please press a button!*\n\n"
366
+ f"⚠️ *You're currently in Button Demo mode*\n"
367
+ f"📋 *Expected action*: Click one of the buttons above\n"
368
+ f"⏰ *Time remaining*: {time_remaining} minutes\n"
369
+ f"🔢 *Attempt*: {button_state.attempts + 1}/{button_state.max_attempts}\n\n"
370
+ f"💡 *Tip*: Look for the message with 🐱 Kitty and 🐶 Puppy buttons above!"
371
+ )
372
+
373
+ await self.messenger.send_text(
374
+ recipient=user_id, text=reminder_text, reply_to_message_id=message_id
375
+ )
376
+
377
+ async def _send_list_state_reminder(
378
+ self, user_id: str, message_id: str, list_state: ListState
379
+ ) -> None:
380
+ """Send reminder message when user is in list state."""
381
+ time_remaining = list_state.time_remaining_minutes()
382
+
383
+ reminder_text = (
384
+ f"📋 *Hey Wappa! We love your enthusiasm, but please make a list selection!*\n\n"
385
+ f"⚠️ *You're currently in List Demo mode*\n"
386
+ f"📋 *Expected action*: Tap 'Choose Media' and select an option\n"
387
+ f"⏰ *Time remaining*: {time_remaining} minutes\n"
388
+ f"🔢 *Attempt*: {list_state.attempts + 1}/{list_state.max_attempts}\n\n"
389
+ f"💡 *Tip*: Look for the message with the 'Choose Media' button above!"
390
+ )
391
+
392
+ await self.messenger.send_text(
393
+ recipient=user_id, text=reminder_text, reply_to_message_id=message_id
394
+ )
395
+
396
+ async def _send_invalid_button_selection_message(
397
+ self, user_id: str, message_id: str, selection_id: str
398
+ ) -> None:
399
+ """Send message for invalid button selection."""
400
+ error_text = (
401
+ f"❌ *Invalid Button Selection*\n\n"
402
+ f"🆔 *You selected*: `{selection_id}`\n"
403
+ f"✅ *Valid options*: `kitty`, `puppy`\n\n"
404
+ f"💡 *Please try again*: Click one of the valid buttons above!"
405
+ )
406
+
407
+ await self.messenger.send_text(
408
+ recipient=user_id, text=error_text, reply_to_message_id=message_id
409
+ )
410
+
411
+ async def _send_invalid_list_selection_message(
412
+ self, user_id: str, message_id: str, selection_id: str
413
+ ) -> None:
414
+ """Send message for invalid list selection."""
415
+ error_text = (
416
+ f"❌ *Invalid List Selection*\n\n"
417
+ f"🆔 *You selected*: `{selection_id}`\n"
418
+ f"✅ *Valid options*: `image_file`, `video_file`, `audio_file`, `document_file`\n\n"
419
+ f"💡 *Please try again*: Use the list above to make a valid selection!"
420
+ )
421
+
422
+ await self.messenger.send_text(
423
+ recipient=user_id, text=error_text, reply_to_message_id=message_id
424
+ )
425
+
426
+
427
+ # Convenience functions for direct use
428
+ async def handle_user_in_state(
429
+ webhook: IncomingMessageWebhook,
430
+ user_profile: UserProfile,
431
+ messenger,
432
+ cache_factory,
433
+ logger,
434
+ ) -> dict[str, any]:
435
+ """
436
+ Handle user response when they are in an active state (convenience function).
437
+
438
+ Args:
439
+ webhook: IncomingMessageWebhook with user response
440
+ user_profile: User profile for tracking
441
+ messenger: IMessenger instance
442
+ cache_factory: Cache factory
443
+ logger: Logger instance
444
+
445
+ Returns:
446
+ Result dictionary or None if no active state
447
+ """
448
+ handlers = StateHandlers(messenger, cache_factory, logger)
449
+ cache_helper = CacheHelper(cache_factory)
450
+ user_id = webhook.user.user_id
451
+
452
+ # Check for active button state
453
+ button_state = await cache_helper.get_user_state(user_id, StateType.BUTTON)
454
+ if button_state and button_state.is_active():
455
+ # Ensure we have the correct type
456
+ if isinstance(button_state, ButtonState):
457
+ return await handlers.handle_button_state_response(
458
+ webhook, user_profile, button_state
459
+ )
460
+ else:
461
+ logger.warning(f"Button state returned wrong type: {type(button_state)}")
462
+
463
+ # Check for active list state
464
+ list_state = await cache_helper.get_user_state(user_id, StateType.LIST)
465
+ if list_state and list_state.is_active():
466
+ # Ensure we have the correct type
467
+ if isinstance(list_state, ListState):
468
+ return await handlers.handle_list_state_response(
469
+ webhook, user_profile, list_state
470
+ )
471
+ else:
472
+ logger.warning(f"List state returned wrong type: {type(list_state)}")
473
+
474
+ # No active state found
475
+ return None
476
+
477
+
478
+ async def cleanup_expired_user_states(
479
+ cache_factory, logger, user_id: str = None
480
+ ) -> int:
481
+ """
482
+ Cleanup expired states for a specific user or all users (convenience function).
483
+
484
+ Args:
485
+ cache_factory: Cache factory
486
+ logger: Logger instance
487
+ user_id: Optional specific user ID to clean up
488
+
489
+ Returns:
490
+ Number of states cleaned up
491
+ """
492
+ cache_helper = CacheHelper(cache_factory)
493
+
494
+ try:
495
+ # This is a simplified cleanup - in a real implementation you would
496
+ # scan Redis keys and clean up expired states
497
+ cleanup_count = 0
498
+
499
+ if user_id:
500
+ # Clean up specific user's states
501
+ for state_type in [StateType.BUTTON, StateType.LIST]:
502
+ state = await cache_helper.get_user_state(user_id, state_type)
503
+ if state and state.is_expired():
504
+ await cache_helper.remove_user_state(user_id, state_type)
505
+ cleanup_count += 1
506
+ logger.info(
507
+ f"🧹 Cleaned up expired {state_type.value} state for user {user_id}"
508
+ )
509
+
510
+ return cleanup_count
511
+
512
+ except Exception as e:
513
+ logger.error(f"❌ Error during state cleanup: {e}")
514
+ return 0