smart-bot-factory 1.1.1__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.
Files changed (73) hide show
  1. smart_bot_factory/__init__.py +3 -0
  2. smart_bot_factory/admin/__init__.py +18 -0
  3. smart_bot_factory/admin/admin_events.py +1223 -0
  4. smart_bot_factory/admin/admin_logic.py +553 -0
  5. smart_bot_factory/admin/admin_manager.py +156 -0
  6. smart_bot_factory/admin/admin_tester.py +157 -0
  7. smart_bot_factory/admin/timeout_checker.py +547 -0
  8. smart_bot_factory/aiogram_calendar/__init__.py +14 -0
  9. smart_bot_factory/aiogram_calendar/common.py +64 -0
  10. smart_bot_factory/aiogram_calendar/dialog_calendar.py +259 -0
  11. smart_bot_factory/aiogram_calendar/schemas.py +99 -0
  12. smart_bot_factory/aiogram_calendar/simple_calendar.py +224 -0
  13. smart_bot_factory/analytics/analytics_manager.py +414 -0
  14. smart_bot_factory/cli.py +806 -0
  15. smart_bot_factory/config.py +258 -0
  16. smart_bot_factory/configs/growthmed-october-24/prompts/1sales_context.txt +16 -0
  17. smart_bot_factory/configs/growthmed-october-24/prompts/2product_info.txt +582 -0
  18. smart_bot_factory/configs/growthmed-october-24/prompts/3objection_handling.txt +66 -0
  19. smart_bot_factory/configs/growthmed-october-24/prompts/final_instructions.txt +212 -0
  20. smart_bot_factory/configs/growthmed-october-24/prompts/help_message.txt +28 -0
  21. smart_bot_factory/configs/growthmed-october-24/prompts/welcome_message.txt +8 -0
  22. smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064229.txt +818 -0
  23. smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064335.txt +32 -0
  24. smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064638.txt +35 -0
  25. smart_bot_factory/configs/growthmed-october-24/tests/quick_scenarios.yaml +133 -0
  26. smart_bot_factory/configs/growthmed-october-24/tests/realistic_scenarios.yaml +108 -0
  27. smart_bot_factory/configs/growthmed-october-24/tests/scenario_examples.yaml +46 -0
  28. smart_bot_factory/configs/growthmed-october-24/welcome_file/welcome_file_msg.txt +16 -0
  29. smart_bot_factory/configs/growthmed-october-24/welcome_file//342/225/250/320/267/342/225/250/342/225/241/342/225/250/342/225/221 /342/225/250/342/225/227/342/225/250/342/225/225/342/225/244/320/221/342/225/244/320/222 /342/225/250/342/224/220/342/225/250/342/225/233 152/342/225/250/320/264/342/225/250/320/247 /342/225/250/342/225/225 323/342/225/250/320/264/342/225/250/320/247 /342/225/250/342/224/244/342/225/250/342/225/227/342/225/244/320/237 /342/225/250/342/225/235/342/225/250/342/225/241/342/225/250/342/224/244/342/225/250/342/225/225/342/225/244/320/226/342/225/250/342/225/225/342/225/250/342/225/234/342/225/244/320/233.pdf +0 -0
  30. smart_bot_factory/core/bot_utils.py +1108 -0
  31. smart_bot_factory/core/conversation_manager.py +653 -0
  32. smart_bot_factory/core/decorators.py +2464 -0
  33. smart_bot_factory/core/message_sender.py +729 -0
  34. smart_bot_factory/core/router.py +347 -0
  35. smart_bot_factory/core/router_manager.py +218 -0
  36. smart_bot_factory/core/states.py +27 -0
  37. smart_bot_factory/creation/__init__.py +7 -0
  38. smart_bot_factory/creation/bot_builder.py +1093 -0
  39. smart_bot_factory/creation/bot_testing.py +1122 -0
  40. smart_bot_factory/dashboard/__init__.py +3 -0
  41. smart_bot_factory/event/__init__.py +7 -0
  42. smart_bot_factory/handlers/handlers.py +2013 -0
  43. smart_bot_factory/integrations/langchain_openai.py +542 -0
  44. smart_bot_factory/integrations/openai_client.py +513 -0
  45. smart_bot_factory/integrations/supabase_client.py +1678 -0
  46. smart_bot_factory/memory/__init__.py +8 -0
  47. smart_bot_factory/memory/memory_manager.py +299 -0
  48. smart_bot_factory/memory/static_memory.py +214 -0
  49. smart_bot_factory/message/__init__.py +56 -0
  50. smart_bot_factory/rag/__init__.py +5 -0
  51. smart_bot_factory/rag/decorators.py +29 -0
  52. smart_bot_factory/rag/router.py +54 -0
  53. smart_bot_factory/rag/templates/__init__.py +3 -0
  54. smart_bot_factory/rag/templates/create_table.sql +7 -0
  55. smart_bot_factory/rag/templates/create_table_and_function_template.py +94 -0
  56. smart_bot_factory/rag/templates/match_function.sql +61 -0
  57. smart_bot_factory/rag/templates/match_services_template.py +82 -0
  58. smart_bot_factory/rag/vectorstore.py +449 -0
  59. smart_bot_factory/router/__init__.py +10 -0
  60. smart_bot_factory/setup_checker.py +512 -0
  61. smart_bot_factory/supabase/__init__.py +7 -0
  62. smart_bot_factory/supabase/client.py +631 -0
  63. smart_bot_factory/utils/__init__.py +11 -0
  64. smart_bot_factory/utils/debug_routing.py +114 -0
  65. smart_bot_factory/utils/prompt_loader.py +529 -0
  66. smart_bot_factory/utils/tool_router.py +68 -0
  67. smart_bot_factory/utils/user_prompt_loader.py +55 -0
  68. smart_bot_factory/utm_link_generator.py +123 -0
  69. smart_bot_factory-1.1.1.dist-info/METADATA +1135 -0
  70. smart_bot_factory-1.1.1.dist-info/RECORD +73 -0
  71. smart_bot_factory-1.1.1.dist-info/WHEEL +4 -0
  72. smart_bot_factory-1.1.1.dist-info/entry_points.txt +2 -0
  73. smart_bot_factory-1.1.1.dist-info/licenses/LICENSE +24 -0
@@ -0,0 +1,2464 @@
1
+ """
2
+ Декораторы для обработчиков событий и временных задач
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import re
8
+ from datetime import datetime, timedelta, timezone
9
+ from functools import wraps
10
+ from typing import Any, Callable, Dict, Optional, Union
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def format_seconds_to_human(seconds: int) -> str:
16
+ """
17
+ Форматирует секунды в человекочитаемый формат
18
+
19
+ Args:
20
+ seconds: Количество секунд
21
+
22
+ Returns:
23
+ str: Человекочитаемое время
24
+
25
+ Examples:
26
+ format_seconds_to_human(3600) -> "1ч 0м"
27
+ format_seconds_to_human(5445) -> "1ч 30м"
28
+ format_seconds_to_human(102461) -> "1д 4ч 28м"
29
+ """
30
+ if seconds < 60:
31
+ return f"{seconds}с"
32
+
33
+ days = seconds // 86400
34
+ hours = (seconds % 86400) // 3600
35
+ minutes = (seconds % 3600) // 60
36
+
37
+ parts = []
38
+ if days > 0:
39
+ parts.append(f"{days}д")
40
+ if hours > 0:
41
+ parts.append(f"{hours}ч")
42
+ if minutes > 0:
43
+ parts.append(f"{minutes}м")
44
+
45
+ return " ".join(parts) if parts else "0м"
46
+
47
+
48
+ def parse_time_string(time_str: Union[str, int]) -> int:
49
+ """
50
+ Парсит время в удобном формате и возвращает секунды
51
+
52
+ Args:
53
+ time_str: Время в формате "1h 30m 45s" или число (секунды)
54
+
55
+ Returns:
56
+ int: Количество секунд
57
+
58
+ Examples:
59
+ parse_time_string("1h 30m 45s") -> 5445
60
+ parse_time_string("2h") -> 7200
61
+ parse_time_string("45m") -> 2700
62
+ parse_time_string("30s") -> 30
63
+ parse_time_string(3600) -> 3600
64
+ """
65
+ if isinstance(time_str, int):
66
+ return time_str
67
+
68
+ # Убираем лишние пробелы и приводим к нижнему регистру
69
+ time_str = time_str.strip().lower()
70
+
71
+ # Если это просто число - возвращаем как секунды
72
+ if time_str.isdigit():
73
+ return int(time_str)
74
+
75
+ total_seconds = 0
76
+
77
+ # Регулярное выражение для поиска времени: число + единица (h, m, s)
78
+ pattern = r"(\d+)\s*(h|m|s)"
79
+ matches = re.findall(pattern, time_str)
80
+
81
+ if not matches:
82
+ raise ValueError(
83
+ f"Неверный формат времени: '{time_str}'. Используйте формат '1h 30m 45s'"
84
+ )
85
+
86
+ for value, unit in matches:
87
+ value = int(value)
88
+
89
+ if unit == "h": # часы
90
+ total_seconds += value * 3600
91
+ elif unit == "m": # минуты
92
+ total_seconds += value * 60
93
+ elif unit == "s": # секунды
94
+ total_seconds += value
95
+
96
+ if total_seconds <= 0:
97
+ raise ValueError(f"Время должно быть больше 0: '{time_str}'")
98
+
99
+ return total_seconds
100
+
101
+
102
+ def parse_supabase_datetime(datetime_str: str) -> datetime:
103
+ """
104
+ Парсит дату и время из формата Supabase в объект datetime
105
+
106
+ Args:
107
+ datetime_str: Строка даты и времени из Supabase (ISO 8601 формат)
108
+
109
+ Returns:
110
+ datetime: Объект datetime с timezone
111
+
112
+ Examples:
113
+ parse_supabase_datetime("2024-01-15T10:30:45.123456Z") -> datetime(2024, 1, 15, 10, 30, 45, 123456, tzinfo=timezone.utc)
114
+ parse_supabase_datetime("2024-01-15T10:30:45+00:00") -> datetime(2024, 1, 15, 10, 30, 45, tzinfo=timezone.utc)
115
+ parse_supabase_datetime("2024-01-15T10:30:45") -> datetime(2024, 1, 15, 10, 30, 45, tzinfo=timezone.utc)
116
+ """
117
+ if not datetime_str:
118
+ raise ValueError("Пустая строка даты и времени")
119
+
120
+ # Убираем лишние пробелы
121
+ datetime_str = datetime_str.strip()
122
+
123
+ try:
124
+ # Пробуем парсить ISO 8601 формат с Z в конце
125
+ if datetime_str.endswith("Z"):
126
+ # Заменяем Z на +00:00 для корректного парсинга
127
+ datetime_str = datetime_str[:-1] + "+00:00"
128
+ return datetime.fromisoformat(datetime_str)
129
+
130
+ # Пробуем парсить ISO 8601 формат с timezone
131
+ if "+" in datetime_str or datetime_str.count("-") > 2:
132
+ return datetime.fromisoformat(datetime_str)
133
+
134
+ # Если нет timezone, добавляем UTC
135
+ if "T" in datetime_str:
136
+ return datetime.fromisoformat(datetime_str + "+00:00")
137
+
138
+ # Если это только дата, добавляем время 00:00:00 и UTC
139
+ return datetime.fromisoformat(datetime_str + "T00:00:00+00:00")
140
+
141
+ except ValueError as e:
142
+ raise ValueError(
143
+ f"Неверный формат даты и времени: '{datetime_str}'. Ошибка: {e}"
144
+ )
145
+
146
+
147
+ def format_datetime_for_supabase(dt: datetime) -> str:
148
+ """
149
+ Форматирует объект datetime в формат для Supabase
150
+
151
+ Args:
152
+ dt: Объект datetime
153
+
154
+ Returns:
155
+ str: Строка в формате ISO 8601 для Supabase
156
+
157
+ Examples:
158
+ format_datetime_for_supabase(datetime.now(timezone.utc)) -> "2024-01-15T10:30:45.123456+00:00"
159
+ """
160
+ if not isinstance(dt, datetime):
161
+ raise ValueError("Ожидается объект datetime")
162
+
163
+ # Если нет timezone, добавляем UTC
164
+ if dt.tzinfo is None:
165
+ dt = dt.replace(tzinfo=timezone.utc)
166
+
167
+ return dt.isoformat()
168
+
169
+
170
+ def get_time_difference_seconds(dt1: datetime, dt2: datetime) -> int:
171
+ """
172
+ Вычисляет разность между двумя датами в секундах
173
+
174
+ Args:
175
+ dt1: Первая дата
176
+ dt2: Вторая дата
177
+
178
+ Returns:
179
+ int: Разность в секундах (dt2 - dt1)
180
+
181
+ Examples:
182
+ get_time_difference_seconds(datetime1, datetime2) -> 3600 # 1 час
183
+ """
184
+
185
+ # Если у дат нет timezone, добавляем UTC
186
+ if dt1.tzinfo is None:
187
+ dt1 = dt1.replace(tzinfo=timezone.utc)
188
+ if dt2.tzinfo is None:
189
+ dt2 = dt2.replace(tzinfo=timezone.utc)
190
+
191
+ return int((dt2 - dt1).total_seconds())
192
+
193
+
194
+ def is_datetime_recent(dt: datetime, max_age_seconds: int = 3600) -> bool:
195
+ """
196
+ Проверяет, является ли дата недавней (не старше указанного времени)
197
+
198
+ Args:
199
+ dt: Дата для проверки
200
+ max_age_seconds: Максимальный возраст в секундах (по умолчанию 1 час)
201
+
202
+ Returns:
203
+ bool: True если дата недавняя, False если старая
204
+
205
+ Examples:
206
+ is_datetime_recent(datetime.now(), 1800) -> True # если дата сейчас
207
+ is_datetime_recent(datetime.now() - timedelta(hours=2), 3600) -> False # если дата 2 часа назад
208
+ """
209
+ if not isinstance(dt, datetime):
210
+ raise ValueError("Ожидается объект datetime")
211
+
212
+ now = datetime.now(timezone.utc)
213
+
214
+ # Если у даты нет timezone, добавляем UTC
215
+ if dt.tzinfo is None:
216
+ dt = dt.replace(tzinfo=timezone.utc)
217
+
218
+ age_seconds = (now - dt).total_seconds()
219
+ return age_seconds <= max_age_seconds
220
+
221
+
222
+ def parse_appointment_data(data_str: str) -> Dict[str, Any]:
223
+ """
224
+ Парсит данные записи на прием из строки формата "ключ: значение, ключ: значение"
225
+
226
+ Args:
227
+ data_str: Строка с данными записи
228
+
229
+ Returns:
230
+ Dict[str, Any]: Словарь с распарсенными данными
231
+
232
+ Examples:
233
+ parse_appointment_data("имя: Михаил, телефон: +79965214968, процедура: Ламинирование + окрашивание, мастер: Софья, дата: 2025-10-01, время: 19:00")
234
+ -> {
235
+ 'имя': 'Михаил',
236
+ 'телефон': '+79965214968',
237
+ 'процедура': 'Ламинирование + окрашивание',
238
+ 'мастер': 'Софья',
239
+ 'дата': '2025-10-01',
240
+ 'время': '19:00'
241
+ }
242
+ """
243
+ if not data_str or not isinstance(data_str, str):
244
+ return {}
245
+
246
+ result = {}
247
+
248
+ try:
249
+ # Разделяем по запятым, но учитываем что внутри значений могут быть запятые
250
+ # Используем более умный подход - ищем паттерн "ключ: значение"
251
+ pattern = r"([^:]+):\s*([^,]+?)(?=,\s*[^:]+:|$)"
252
+ matches = re.findall(pattern, data_str.strip())
253
+
254
+ for key, value in matches:
255
+ # Очищаем ключ и значение от лишних пробелов
256
+ clean_key = key.strip()
257
+ clean_value = value.strip()
258
+
259
+ # Убираем запятые в конце значения если есть
260
+ if clean_value.endswith(","):
261
+ clean_value = clean_value[:-1].strip()
262
+
263
+ result[clean_key] = clean_value
264
+
265
+ # Дополнительная обработка для даты и времени
266
+ if "дата" in result and "время" in result:
267
+ try:
268
+ # Создаем полную дату и время
269
+ date_str = result["дата"]
270
+ time_str = result["время"]
271
+
272
+ # Парсим дату и время
273
+ appointment_datetime = datetime.strptime(
274
+ f"{date_str} {time_str}", "%Y-%m-%d %H:%M"
275
+ )
276
+
277
+ # Добавляем в результат
278
+ result["datetime"] = appointment_datetime
279
+ result["datetime_str"] = appointment_datetime.strftime("%Y-%m-%d %H:%M")
280
+
281
+ # Проверяем, не в прошлом ли запись
282
+ now = datetime.now()
283
+ if appointment_datetime < now:
284
+ result["is_past"] = True
285
+ else:
286
+ result["is_past"] = False
287
+
288
+ except ValueError as e:
289
+ logger.warning(f"Ошибка парсинга даты/времени: {e}")
290
+ result["datetime_error"] = str(e)
291
+
292
+ logger.info(f"Распарсены данные записи: {list(result.keys())}")
293
+ return result
294
+
295
+ except Exception as e:
296
+ logger.error(f"Ошибка парсинга данных записи: {e}")
297
+ return {"error": str(e), "raw_data": data_str}
298
+
299
+
300
+ def format_appointment_data(appointment_data: Dict[str, Any]) -> str:
301
+ """
302
+ Форматирует данные записи обратно в строку
303
+
304
+ Args:
305
+ appointment_data: Словарь с данными записи
306
+
307
+ Returns:
308
+ str: Отформатированная строка
309
+
310
+ Examples:
311
+ format_appointment_data({
312
+ 'имя': 'Михаил',
313
+ 'телефон': '+79965214968',
314
+ 'процедура': 'Ламинирование + окрашивание',
315
+ 'мастер': 'Софья',
316
+ 'дата': '2025-10-01',
317
+ 'время': '19:00'
318
+ })
319
+ -> "имя: Михаил, телефон: +79965214968, процедура: Ламинирование + окрашивание, мастер: Софья, дата: 2025-10-01, время: 19:00"
320
+ """
321
+ if not appointment_data or not isinstance(appointment_data, dict):
322
+ return ""
323
+
324
+ # Исключаем служебные поля
325
+ exclude_fields = {
326
+ "datetime",
327
+ "datetime_str",
328
+ "is_past",
329
+ "datetime_error",
330
+ "error",
331
+ "raw_data",
332
+ }
333
+
334
+ parts = []
335
+ for key, value in appointment_data.items():
336
+ if key not in exclude_fields and value is not None:
337
+ parts.append(f"{key}: {value}")
338
+
339
+ return ", ".join(parts)
340
+
341
+
342
+ def validate_appointment_data(appointment_data: Dict[str, Any]) -> Dict[str, Any]:
343
+ """
344
+ Валидирует данные записи на прием
345
+
346
+ Args:
347
+ appointment_data: Словарь с данными записи
348
+
349
+ Returns:
350
+ Dict[str, Any]: Результат валидации с полями 'valid', 'errors', 'warnings'
351
+ """
352
+ result = {"valid": True, "errors": [], "warnings": []}
353
+
354
+ # Проверяем обязательные поля
355
+ required_fields = ["имя", "телефон", "процедура", "мастер", "дата", "время"]
356
+
357
+ for field in required_fields:
358
+ if field not in appointment_data or not appointment_data[field]:
359
+ result["errors"].append(f"Отсутствует обязательное поле: {field}")
360
+ result["valid"] = False
361
+
362
+ # Проверяем формат телефона
363
+ if "телефон" in appointment_data:
364
+ phone = appointment_data["телефон"]
365
+ if not re.match(
366
+ r"^\+?[1-9]\d{10,14}$", phone.replace(" ", "").replace("-", "")
367
+ ):
368
+ result["warnings"].append(f"Неверный формат телефона: {phone}")
369
+
370
+ # Проверяем дату
371
+ if "дата" in appointment_data:
372
+ try:
373
+ datetime.strptime(appointment_data["дата"], "%Y-%m-%d")
374
+ except ValueError:
375
+ result["errors"].append(f"Неверный формат даты: {appointment_data['дата']}")
376
+ result["valid"] = False
377
+
378
+ # Проверяем время
379
+ if "время" in appointment_data:
380
+ try:
381
+ datetime.strptime(appointment_data["время"], "%H:%M")
382
+ except ValueError:
383
+ result["errors"].append(
384
+ f"Неверный формат времени: {appointment_data['время']}"
385
+ )
386
+ result["valid"] = False
387
+
388
+ # Проверяем, не в прошлом ли запись
389
+ if "is_past" in appointment_data and appointment_data["is_past"]:
390
+ result["warnings"].append("Запись назначена на прошедшую дату")
391
+
392
+ return result
393
+
394
+
395
+ # Глобальный реестр обработчиков событий
396
+ _event_handlers: Dict[str, Callable] = {}
397
+ _scheduled_tasks: Dict[str, Dict[str, Any]] = {}
398
+ _global_handlers: Dict[str, Dict[str, Any]] = {}
399
+
400
+ # Глобальный менеджер роутеров
401
+ _router_manager = None
402
+
403
+
404
+ def event_handler(
405
+ event_type: str,
406
+ notify: bool = False,
407
+ once_only: bool = True,
408
+ send_ai_response: bool = True,
409
+ ):
410
+ """
411
+ Декоратор для регистрации обработчика события
412
+
413
+ Args:
414
+ event_type: Тип события (например, 'appointment_booking', 'phone_collection')
415
+ notify: Уведомлять ли админов о выполнении события (по умолчанию False)
416
+ once_only: Обрабатывать ли событие только один раз (по умолчанию True)
417
+ send_ai_response: Отправлять ли сообщение от ИИ после обработки события (по умолчанию True)
418
+
419
+ Example:
420
+ # Обработчик с отправкой сообщения от ИИ
421
+ @event_handler("appointment_booking", notify=True)
422
+ async def book_appointment(user_id: int, appointment_data: dict):
423
+ # Логика записи на прием
424
+ return {"status": "success", "appointment_id": "123"}
425
+
426
+ # Обработчик БЕЗ отправки сообщения от ИИ
427
+ @event_handler("phone_collection", once_only=False, send_ai_response=False)
428
+ async def collect_phone(user_id: int, phone_data: dict):
429
+ # Логика сбора телефона - ИИ не отправит сообщение
430
+ return {"status": "phone_collected"}
431
+ """
432
+
433
+ def decorator(func: Callable) -> Callable:
434
+ _event_handlers[event_type] = {
435
+ "handler": func,
436
+ "name": func.__name__,
437
+ "notify": notify,
438
+ "once_only": once_only,
439
+ "send_ai_response": send_ai_response,
440
+ }
441
+
442
+ logger.info(
443
+ f"📝 Зарегистрирован обработчик события '{event_type}': {func.__name__}"
444
+ )
445
+
446
+ @wraps(func)
447
+ async def wrapper(*args, **kwargs):
448
+ try:
449
+ logger.info(f"🔧 Выполняем обработчик события '{event_type}'")
450
+ result = await func(*args, **kwargs)
451
+ logger.info(f"✅ Обработчик '{event_type}' выполнен успешно")
452
+
453
+ # Автоматически добавляем флаги notify и send_ai_response к результату
454
+ if isinstance(result, dict):
455
+ result["notify"] = notify
456
+ result["send_ai_response"] = send_ai_response
457
+ else:
458
+ # Если результат не словарь, создаем словарь
459
+ result = {
460
+ "status": "success",
461
+ "result": result,
462
+ "notify": notify,
463
+ "send_ai_response": send_ai_response,
464
+ }
465
+
466
+ return result
467
+ except Exception as e:
468
+ logger.error(f"❌ Ошибка в обработчике '{event_type}': {e}")
469
+ raise
470
+
471
+ return wrapper
472
+
473
+ return decorator
474
+
475
+
476
+ def schedule_task(
477
+ task_name: str,
478
+ notify: bool = False,
479
+ notify_time: str = "after", # 'after' или 'before'
480
+ smart_check: bool = True,
481
+ once_only: bool = True,
482
+ delay: Union[str, int] = None,
483
+ event_type: Union[str, Callable] = None,
484
+ send_ai_response: bool = True,
485
+ ):
486
+ """
487
+ Декоратор для регистрации задачи, которую можно запланировать на время
488
+
489
+ Args:
490
+ task_name: Название задачи (например, 'send_reminder', 'follow_up')
491
+ notify: Уведомлять ли админов о выполнении задачи (по умолчанию False)
492
+ smart_check: Использовать ли умную проверку активности пользователя (по умолчанию True)
493
+ once_only: Выполнять ли задачу только один раз (по умолчанию True)
494
+ delay: Время задержки в удобном формате (например, "1h 30m", "45m", 3600) - ОБЯЗАТЕЛЬНО
495
+ event_type: Источник времени события - ОПЦИОНАЛЬНО:
496
+ - str: Тип события для поиска в БД (например, 'appointment_booking')
497
+ - Callable: Функция для получения datetime (например, async def(user_id, user_data) -> datetime)
498
+ send_ai_response: Отправлять ли сообщение от ИИ после выполнения задачи (по умолчанию True)
499
+
500
+ Example:
501
+ # Обычная задача с фиксированным временем
502
+ @schedule_task("send_reminder", delay="1h 30m")
503
+ async def send_reminder(user_id: int, user_data: str):
504
+ # Задача будет запланирована на 1 час 30 минут
505
+ return {"status": "sent", "message": user_data}
506
+
507
+ # Напоминание о событии из БД (за delay времени до события)
508
+ @schedule_task("appointment_reminder", delay="2h", event_type="appointment_booking")
509
+ async def appointment_reminder(user_id: int, user_data: str):
510
+ # Ищет событие "appointment_booking" в БД
511
+ # Напоминание будет за 2 часа до времени из события
512
+ return {"status": "sent", "message": user_data}
513
+
514
+ # Напоминание с кастомной функцией получения времени
515
+ async def get_yclients_appointment_time(user_id: int, user_data: str) -> datetime:
516
+ '''Получает время записи из YClients API'''
517
+ from yclients_api import get_next_booking
518
+ booking = await get_next_booking(user_id)
519
+ return booking['datetime'] # datetime объект
520
+
521
+ @schedule_task("yclients_reminder", delay="1h", event_type=get_yclients_appointment_time)
522
+ async def yclients_reminder(user_id: int, user_data: str):
523
+ # Вызовет get_yclients_appointment_time(user_id, user_data)
524
+ # Напоминание будет за 1 час до возвращенного datetime
525
+ return {"status": "sent"}
526
+
527
+ # Форматы времени:
528
+ # delay="1h 30m 45s" - 1 час 30 минут 45 секунд
529
+ # delay="2h" - 2 часа
530
+ # delay="30m" - 30 минут
531
+ # delay=3600 - 3600 секунд (число)
532
+
533
+ # ИИ может передавать только данные (текст):
534
+ # {"тип": "send_reminder", "инфо": "Текст напоминания"} - только текст
535
+ # {"тип": "appointment_reminder", "инфо": ""} - пустой текст, время берется из события/функции
536
+ """
537
+
538
+ def decorator(func: Callable) -> Callable:
539
+ # Время ОБЯЗАТЕЛЬНО должно быть указано
540
+ if delay is None:
541
+ raise ValueError(
542
+ f"Для задачи '{task_name}' ОБЯЗАТЕЛЬНО нужно указать параметр delay"
543
+ )
544
+
545
+ # Парсим время
546
+ try:
547
+ default_delay_seconds = parse_time_string(delay)
548
+ if event_type:
549
+ logger.info(
550
+ f"⏰ Задача '{task_name}' настроена как напоминание о событии '{event_type}' за {delay} ({default_delay_seconds}с)"
551
+ )
552
+ else:
553
+ logger.info(
554
+ f"⏰ Задача '{task_name}' настроена с задержкой: {delay} ({default_delay_seconds}с)"
555
+ )
556
+ except ValueError as e:
557
+ logger.error(f"❌ Ошибка парсинга времени для задачи '{task_name}': {e}")
558
+ raise
559
+
560
+ _scheduled_tasks[task_name] = {
561
+ "handler": func,
562
+ "name": func.__name__,
563
+ "notify": notify,
564
+ "smart_check": smart_check,
565
+ "once_only": once_only,
566
+ "default_delay": default_delay_seconds,
567
+ "event_type": event_type, # Новое поле для типа события
568
+ "send_ai_response": send_ai_response,
569
+ }
570
+
571
+ if event_type:
572
+ logger.info(
573
+ f"⏰ Зарегистрирована задача-напоминание '{task_name}' для события '{event_type}': {func.__name__}"
574
+ )
575
+ else:
576
+ logger.info(f"⏰ Зарегистрирована задача '{task_name}': {func.__name__}")
577
+
578
+ @wraps(func)
579
+ async def wrapper(*args, **kwargs):
580
+ try:
581
+ logger.info(f"⏰ Выполняем запланированную задачу '{task_name}'")
582
+ result = await func(*args, **kwargs)
583
+ logger.info(f"✅ Задача '{task_name}' выполнена успешно")
584
+
585
+ # Автоматически добавляем флаги notify и send_ai_response к результату
586
+ if isinstance(result, dict):
587
+ result["notify"] = notify
588
+ result["send_ai_response"] = send_ai_response
589
+ else:
590
+ # Если результат не словарь, создаем словарь
591
+ result = {
592
+ "status": "success",
593
+ "result": result,
594
+ "notify": notify,
595
+ "send_ai_response": send_ai_response,
596
+ }
597
+
598
+ return result
599
+ except Exception as e:
600
+ logger.error(f"❌ Ошибка в задаче '{task_name}': {e}")
601
+ raise
602
+
603
+ return wrapper
604
+
605
+ return decorator
606
+
607
+
608
+ def global_handler(
609
+ handler_type: str,
610
+ notify: bool = False,
611
+ once_only: bool = True,
612
+ delay: Union[str, int] = None,
613
+ event_type: Union[str, Callable] = None,
614
+ send_ai_response: bool = True,
615
+ ):
616
+ """
617
+ Декоратор для регистрации глобального обработчика (для всех пользователей)
618
+
619
+ Args:
620
+ handler_type: Тип глобального обработчика (например, 'global_announcement', 'mass_notification')
621
+ notify: Уведомлять ли админов о выполнении (по умолчанию False)
622
+ once_only: Выполнять ли обработчик только один раз (по умолчанию True)
623
+ delay: Время задержки в удобном формате (например, "1h 30m", "45m", 3600) - ОБЯЗАТЕЛЬНО
624
+ event_type: Источник времени события - ОПЦИОНАЛЬНО:
625
+ - str: Тип события для поиска в БД
626
+ - Callable: Функция для получения datetime (например, async def(handler_data: str) -> datetime)
627
+ send_ai_response: Отправлять ли сообщение от ИИ после выполнения обработчика (по умолчанию True)
628
+
629
+ Example:
630
+ # Глобальный обработчик с задержкой
631
+ @global_handler("global_announcement", delay="2h", notify=True)
632
+ async def send_global_announcement(announcement_text: str):
633
+ # Выполнится через 2 часа
634
+ return {"status": "sent", "recipients_count": 150}
635
+
636
+ # Глобальный обработчик может выполняться многократно
637
+ @global_handler("daily_report", delay="24h", once_only=False)
638
+ async def send_daily_report(report_data: str):
639
+ # Может запускаться каждый день через 24 часа
640
+ return {"status": "sent", "report_type": "daily"}
641
+
642
+ # С кастомной функцией для получения времени
643
+ async def get_promo_end_time(handler_data: str) -> datetime:
644
+ '''Получает время окончания акции из CRM'''
645
+ from crm_api import get_active_promo
646
+ promo = await get_active_promo()
647
+ return promo['end_datetime']
648
+
649
+ @global_handler("promo_ending_notification", delay="2h", event_type=get_promo_end_time)
650
+ async def notify_promo_ending(handler_data: str):
651
+ # Уведомление за 2 часа до окончания акции
652
+ return {"status": "sent"}
653
+
654
+ # Форматы времени:
655
+ # delay="1h 30m 45s" - 1 час 30 минут 45 секунд
656
+ # delay="2h" - 2 часа
657
+ # delay="45m" - 45 минут
658
+ # delay=3600 - 3600 секунд (число)
659
+
660
+ # ИИ может передавать только данные (текст):
661
+ # {"тип": "global_announcement", "инфо": "Важное объявление!"} - только текст
662
+ # {"тип": "global_announcement", "инфо": ""} - пустой текст, время из функции
663
+ """
664
+
665
+ def decorator(func: Callable) -> Callable:
666
+ # Время ОБЯЗАТЕЛЬНО должно быть указано
667
+ if delay is None:
668
+ raise ValueError(
669
+ f"Для глобального обработчика '{handler_type}' ОБЯЗАТЕЛЬНО нужно указать параметр delay"
670
+ )
671
+
672
+ # Парсим время
673
+ try:
674
+ default_delay_seconds = parse_time_string(delay)
675
+ logger.info(
676
+ f"🌍 Глобальный обработчик '{handler_type}' настроен с задержкой: {delay} ({default_delay_seconds}с)"
677
+ )
678
+ except ValueError as e:
679
+ logger.error(
680
+ f"❌ Ошибка парсинга времени для глобального обработчика '{handler_type}': {e}"
681
+ )
682
+ raise
683
+
684
+ _global_handlers[handler_type] = {
685
+ "handler": func,
686
+ "name": func.__name__,
687
+ "notify": notify,
688
+ "once_only": once_only,
689
+ "default_delay": default_delay_seconds,
690
+ "event_type": event_type, # Добавляем event_type для глобальных обработчиков
691
+ "send_ai_response": send_ai_response,
692
+ }
693
+
694
+ logger.info(
695
+ f"🌍 Зарегистрирован глобальный обработчик '{handler_type}': {func.__name__}"
696
+ )
697
+
698
+ @wraps(func)
699
+ async def wrapper(*args, **kwargs):
700
+ try:
701
+ logger.info(f"🌍 Выполняем глобальный обработчик '{handler_type}'")
702
+ result = await func(*args, **kwargs)
703
+ logger.info(
704
+ f"✅ Глобальный обработчик '{handler_type}' выполнен успешно"
705
+ )
706
+
707
+ # Автоматически добавляем флаги notify и send_ai_response к результату
708
+ if isinstance(result, dict):
709
+ result["notify"] = notify
710
+ result["send_ai_response"] = send_ai_response
711
+ else:
712
+ # Если результат не словарь, создаем словарь
713
+ result = {
714
+ "status": "success",
715
+ "result": result,
716
+ "notify": notify,
717
+ "send_ai_response": send_ai_response,
718
+ }
719
+
720
+ return result
721
+ except Exception as e:
722
+ logger.error(
723
+ f"❌ Ошибка в глобальном обработчике '{handler_type}': {e}"
724
+ )
725
+ raise
726
+
727
+ return wrapper
728
+
729
+ return decorator
730
+
731
+
732
+ def get_event_handlers() -> Dict[str, Dict[str, Any]]:
733
+ """Возвращает все зарегистрированные обработчики событий"""
734
+ return _event_handlers.copy()
735
+
736
+
737
+ def get_scheduled_tasks() -> Dict[str, Dict[str, Any]]:
738
+ """Возвращает все зарегистрированные задачи"""
739
+ return _scheduled_tasks.copy()
740
+
741
+
742
+ def get_global_handlers() -> Dict[str, Dict[str, Any]]:
743
+ """Возвращает все зарегистрированные глобальные обработчики"""
744
+ return _global_handlers.copy()
745
+
746
+
747
+ def set_router_manager(router_manager):
748
+ """Устанавливает глобальный менеджер роутеров"""
749
+ global _router_manager
750
+ _router_manager = router_manager
751
+ logger.info("🔄 RouterManager установлен в decorators")
752
+
753
+
754
+ def get_router_manager():
755
+ """Получает глобальный менеджер роутеров"""
756
+ return _router_manager
757
+
758
+
759
+ def get_handlers_for_prompt() -> str:
760
+ """
761
+ Возвращает описание всех обработчиков для добавления в промпт
762
+ """
763
+ # Сначала пробуем получить из роутеров
764
+ if _router_manager:
765
+ return _router_manager.get_handlers_for_prompt()
766
+
767
+ # Fallback к старым декораторам
768
+ if not _event_handlers and not _scheduled_tasks and not _global_handlers:
769
+ return ""
770
+
771
+ prompt_parts = []
772
+
773
+ if _event_handlers:
774
+ prompt_parts.append("ДОСТУПНЫЕ ОБРАБОТЧИКИ СОБЫТИЙ:")
775
+ for event_type, handler_info in _event_handlers.items():
776
+ prompt_parts.append(f"- {event_type}: {handler_info['name']}")
777
+
778
+ if _scheduled_tasks:
779
+ prompt_parts.append("\nДОСТУПНЫЕ ЗАДАЧИ ДЛЯ ПЛАНИРОВАНИЯ:")
780
+ for task_name, task_info in _scheduled_tasks.items():
781
+ prompt_parts.append(f"- {task_name}: {task_info['name']}")
782
+
783
+ if _global_handlers:
784
+ prompt_parts.append("\nДОСТУПНЫЕ ГЛОБАЛЬНЫЕ ОБРАБОТЧИКИ:")
785
+ for handler_type, handler_info in _global_handlers.items():
786
+ prompt_parts.append(f"- {handler_type}: {handler_info['name']}")
787
+
788
+ return "\n".join(prompt_parts)
789
+
790
+
791
+ async def execute_event_handler(event_type: str, *args, **kwargs) -> Any:
792
+ """Выполняет обработчик события по типу"""
793
+ # Сначала пробуем получить из роутеров
794
+ if _router_manager:
795
+ event_handlers = _router_manager.get_event_handlers()
796
+ if event_type in event_handlers:
797
+ handler_info = event_handlers[event_type]
798
+ return await handler_info["handler"](*args, **kwargs)
799
+
800
+ # Fallback к старым декораторам
801
+ if event_type not in _event_handlers:
802
+ import inspect
803
+
804
+ frame = inspect.currentframe()
805
+ line_no = frame.f_lineno if frame else "unknown"
806
+ logger.error(
807
+ f"❌ [decorators.py:{line_no}] Обработчик события '{event_type}' не найден"
808
+ )
809
+ raise ValueError(f"Обработчик события '{event_type}' не найден")
810
+
811
+ handler_info = _event_handlers[event_type]
812
+ return await handler_info["handler"](*args, **kwargs)
813
+
814
+
815
+ async def execute_scheduled_task(task_name: str, user_id: int, user_data: str) -> Any:
816
+ """Выполняет запланированную задачу по имени (без планирования, только выполнение)"""
817
+ # Сначала пробуем получить из роутеров
818
+ if _router_manager:
819
+ scheduled_tasks = _router_manager.get_scheduled_tasks()
820
+ if task_name in scheduled_tasks:
821
+ task_info = scheduled_tasks[task_name]
822
+ return await task_info["handler"](user_id, user_data)
823
+
824
+ task_info = _scheduled_tasks[task_name]
825
+ return await task_info["handler"](user_id, user_data)
826
+
827
+
828
+ async def execute_global_handler(handler_type: str, *args, **kwargs) -> Any:
829
+ """Выполняет глобальный обработчик по типу"""
830
+ router_manager = get_router_manager()
831
+ if router_manager:
832
+ global_handlers = router_manager.get_global_handlers()
833
+ else:
834
+ global_handlers = _global_handlers
835
+
836
+ if handler_type not in global_handlers:
837
+ raise ValueError(f"Глобальный обработчик '{handler_type}' не найден")
838
+
839
+ handler_info = global_handlers[handler_type]
840
+ return await handler_info["handler"](*args, **kwargs)
841
+
842
+
843
+ async def schedule_task_for_later(
844
+ task_name: str, delay_seconds: int, user_id: int, user_data: str
845
+ ):
846
+ """
847
+ Планирует выполнение задачи через указанное время
848
+
849
+ Args:
850
+ task_name: Название задачи
851
+ delay_seconds: Задержка в секундах
852
+ user_id: ID пользователя
853
+ user_data: Простой текст для задачи
854
+ """
855
+ # Ищем задачу через RouterManager (новая логика)
856
+ router_manager = get_router_manager()
857
+ if router_manager:
858
+ scheduled_tasks = router_manager.get_scheduled_tasks()
859
+ logger.debug(f"🔍 Поиск задачи '{task_name}' через RouterManager")
860
+ else:
861
+ scheduled_tasks = _scheduled_tasks
862
+ logger.debug(f"🔍 Поиск задачи '{task_name}' через глобальный реестр")
863
+
864
+ if task_name not in scheduled_tasks:
865
+ available_tasks = list(scheduled_tasks.keys())
866
+ logger.error(
867
+ f"❌ Задача '{task_name}' не найдена. Доступные задачи: {available_tasks}"
868
+ )
869
+ raise ValueError(
870
+ f"Задача '{task_name}' не найдена. Доступные: {available_tasks}"
871
+ )
872
+
873
+ logger.info(f"⏰ Планируем задачу '{task_name}' через {delay_seconds} секунд")
874
+
875
+ async def delayed_task():
876
+ await asyncio.sleep(delay_seconds)
877
+ await execute_scheduled_task(task_name, user_id, user_data)
878
+
879
+ # Запускаем задачу в фоне
880
+ asyncio.create_task(delayed_task())
881
+
882
+ return {
883
+ "status": "scheduled",
884
+ "task_name": task_name,
885
+ "delay_seconds": delay_seconds,
886
+ "scheduled_at": datetime.now().isoformat(),
887
+ }
888
+
889
+
890
+ async def execute_scheduled_task_from_event(
891
+ user_id: int, task_name: str, event_info: str, session_id: str = None
892
+ ):
893
+ """
894
+ Выполняет запланированную задачу на основе события от ИИ
895
+
896
+ Args:
897
+ user_id: ID пользователя
898
+ task_name: Название задачи
899
+ event_info: Информация от ИИ (только текст, время задается в декораторе или событии)
900
+ session_id: ID сессии для отслеживания
901
+ """
902
+ router_manager = get_router_manager()
903
+ if router_manager:
904
+ scheduled_tasks = router_manager.get_scheduled_tasks()
905
+ logger.debug(
906
+ f"🔍 RouterManager найден, доступные задачи: {list(scheduled_tasks.keys())}"
907
+ )
908
+ else:
909
+ scheduled_tasks = _scheduled_tasks
910
+ logger.debug(
911
+ f"🔍 RouterManager не найден, старые задачи: {list(scheduled_tasks.keys())}"
912
+ )
913
+
914
+ if task_name not in scheduled_tasks:
915
+ available_tasks = list(scheduled_tasks.keys())
916
+ logger.error(
917
+ f"❌ Задача '{task_name}' не найдена. Доступные задачи: {available_tasks}"
918
+ )
919
+ logger.error(
920
+ f"❌ RouterManager статус: {'найден' if router_manager else 'НЕ НАЙДЕН'}"
921
+ )
922
+ raise ValueError(
923
+ f"Задача '{task_name}' не найдена. Доступные задачи: {available_tasks}"
924
+ )
925
+
926
+ task_info = scheduled_tasks[task_name]
927
+ default_delay = task_info.get("default_delay")
928
+ event_type = task_info.get("event_type")
929
+
930
+ # Время всегда берется из декоратора, ИИ может передавать только текст
931
+ if default_delay is None:
932
+ raise ValueError(
933
+ f"Для задачи '{task_name}' не указано время в декораторе (параметр delay)"
934
+ )
935
+
936
+ # event_info содержит только текст для задачи (если ИИ не передал - пустая строка)
937
+ user_data = event_info.strip() if event_info else ""
938
+
939
+ # Если указан event_type, то это напоминание о событии
940
+ if event_type:
941
+ event_datetime = None
942
+
943
+ # ========== ПРОВЕРЯЕМ ТИП event_type: СТРОКА ИЛИ ФУНКЦИЯ ==========
944
+ if callable(event_type):
945
+ # ВАРИАНТ 2: Функция - вызываем для получения datetime
946
+ logger.info(
947
+ f"⏰ Задача '{task_name}' - вызываем функцию для получения времени события"
948
+ )
949
+
950
+ try:
951
+ # Вызываем функцию пользователя с теми же аргументами что у обработчика
952
+ event_datetime = await event_type(user_id, user_data)
953
+
954
+ if not isinstance(event_datetime, datetime):
955
+ raise ValueError(
956
+ f"Функция event_type должна вернуть datetime, получен {type(event_datetime)}"
957
+ )
958
+
959
+ logger.info(f"✅ Функция вернула время события: {event_datetime}")
960
+
961
+ except Exception as e:
962
+ logger.error(f"❌ Ошибка в функции event_type: {e}")
963
+ # Fallback - планируем через default_delay
964
+ result = await schedule_task_for_later_with_db(
965
+ task_name, user_id, user_data, default_delay, session_id
966
+ )
967
+ return result
968
+
969
+ else:
970
+ # ВАРИАНТ 1: Строка - ищем событие в БД (текущая логика)
971
+ logger.info(
972
+ f"⏰ Задача '{task_name}' - напоминание о событии '{event_type}' за {default_delay}с"
973
+ )
974
+
975
+ # Получаем клиент Supabase
976
+ supabase_client = get_supabase_client()
977
+ if not supabase_client:
978
+ raise RuntimeError(
979
+ "Supabase клиент не найден для получения времени события"
980
+ )
981
+
982
+ try:
983
+ # Получаем данные события из БД
984
+ event_data_str = (
985
+ await supabase_client.get_last_event_info_by_user_and_type(
986
+ user_id, event_type
987
+ )
988
+ )
989
+
990
+ if not event_data_str:
991
+ logger.warning(
992
+ f"Событие '{event_type}' не найдено для пользователя {user_id}"
993
+ )
994
+ # Fallback - планируем через default_delay
995
+ result = await schedule_task_for_later_with_db(
996
+ task_name, user_id, user_data, default_delay, session_id
997
+ )
998
+ return result
999
+
1000
+ # Парсим данные события
1001
+ event_data = parse_appointment_data(event_data_str)
1002
+
1003
+ if "datetime" not in event_data:
1004
+ logger.warning(
1005
+ f"Не удалось распарсить дату/время из события '{event_type}'"
1006
+ )
1007
+ # Fallback - планируем через default_delay
1008
+ result = await schedule_task_for_later_with_db(
1009
+ task_name, user_id, user_data, default_delay, session_id
1010
+ )
1011
+ return result
1012
+
1013
+ event_datetime = event_data["datetime"]
1014
+ logger.info(f"✅ Получено время события из БД: {event_datetime}")
1015
+
1016
+ except Exception as e:
1017
+ logger.error(f"❌ Ошибка получения события из БД: {e}")
1018
+ # Fallback - планируем через default_delay
1019
+ result = await schedule_task_for_later_with_db(
1020
+ task_name, user_id, user_data, default_delay, session_id
1021
+ )
1022
+ return result
1023
+
1024
+ # ========== ОБЩАЯ ЛОГИКА ДЛЯ ОБОИХ ВАРИАНТОВ ==========
1025
+ # Теперь у нас есть event_datetime (из БД или из функции)
1026
+ now = datetime.now()
1027
+
1028
+ # Вычисляем время напоминания (за default_delay до события)
1029
+ reminder_datetime = event_datetime - timedelta(seconds=default_delay)
1030
+
1031
+ # Проверяем, не в прошлом ли напоминание
1032
+ if reminder_datetime <= now:
1033
+ logger.warning("Напоминание уже в прошлом, отправляем немедленно")
1034
+ # Выполняем задачу немедленно
1035
+ result = await execute_scheduled_task(task_name, user_id, user_data)
1036
+ return {
1037
+ "status": "executed_immediately",
1038
+ "task_name": task_name,
1039
+ "reason": "reminder_time_passed",
1040
+ "event_datetime": event_datetime.isoformat(),
1041
+ "result": result,
1042
+ }
1043
+
1044
+ # Вычисляем задержку до напоминания
1045
+ delay_seconds = int((reminder_datetime - now).total_seconds())
1046
+
1047
+ event_source = (
1048
+ "функции"
1049
+ if callable(task_info.get("event_type"))
1050
+ else f"события '{event_type}'"
1051
+ )
1052
+ human_time = format_seconds_to_human(delay_seconds)
1053
+ logger.info(
1054
+ f"⏰ Планируем напоминание '{task_name}' за {format_seconds_to_human(default_delay)} до {event_source} (через {human_time} / {delay_seconds}с)"
1055
+ )
1056
+
1057
+ # Планируем напоминание
1058
+ result = await schedule_task_for_later_with_db(
1059
+ task_name, user_id, user_data, delay_seconds, session_id
1060
+ )
1061
+ result["event_datetime"] = event_datetime.isoformat()
1062
+ result["reminder_type"] = "event_reminder"
1063
+
1064
+ return result
1065
+ else:
1066
+ # Обычная задача с фиксированным временем
1067
+ human_time = format_seconds_to_human(default_delay)
1068
+ logger.info(
1069
+ f"⏰ Планируем задачу '{task_name}' через {human_time} ({default_delay}с) с текстом: '{user_data}'"
1070
+ )
1071
+
1072
+ # Планируем задачу на фоне с сохранением в БД
1073
+ result = await schedule_task_for_later_with_db(
1074
+ task_name, user_id, user_data, default_delay, session_id
1075
+ )
1076
+
1077
+ return result
1078
+
1079
+
1080
+ async def schedule_global_handler_for_later(
1081
+ handler_type: str, delay_seconds: int, handler_data: str
1082
+ ):
1083
+ """
1084
+ Планирует выполнение глобального обработчика через указанное время
1085
+
1086
+ Args:
1087
+ handler_type: Тип глобального обработчика
1088
+ delay_seconds: Задержка в секундах
1089
+ handler_data: Данные для обработчика (время в секундах как строка)
1090
+ """
1091
+ # Ищем глобальный обработчик через RouterManager (новая логика)
1092
+ router_manager = get_router_manager()
1093
+ if router_manager:
1094
+ global_handlers = router_manager.get_global_handlers()
1095
+ logger.debug(
1096
+ f"🔍 Поиск глобального обработчика '{handler_type}' через RouterManager"
1097
+ )
1098
+ else:
1099
+ global_handlers = _global_handlers
1100
+ logger.debug(
1101
+ f"🔍 Поиск глобального обработчика '{handler_type}' через глобальный реестр"
1102
+ )
1103
+
1104
+ if handler_type not in global_handlers:
1105
+ available_handlers = list(global_handlers.keys())
1106
+ logger.error(
1107
+ f"❌ Глобальный обработчик '{handler_type}' не найден. Доступные: {available_handlers}"
1108
+ )
1109
+ raise ValueError(
1110
+ f"Глобальный обработчик '{handler_type}' не найден. Доступные: {available_handlers}"
1111
+ )
1112
+
1113
+ logger.info(
1114
+ f"🌍 Планируем глобальный обработчик '{handler_type}' через {delay_seconds} секунд"
1115
+ )
1116
+
1117
+ async def delayed_global_handler():
1118
+ await asyncio.sleep(delay_seconds)
1119
+ # Передаем данные обработчику (может быть текст анонса или другие данные)
1120
+ await execute_global_handler(handler_type, handler_data)
1121
+
1122
+ # Запускаем задачу в фоне
1123
+ asyncio.create_task(delayed_global_handler())
1124
+
1125
+ return {
1126
+ "status": "scheduled",
1127
+ "handler_type": handler_type,
1128
+ "delay_seconds": delay_seconds,
1129
+ "scheduled_at": datetime.now().isoformat(),
1130
+ }
1131
+
1132
+
1133
+ async def execute_global_handler_from_event(handler_type: str, event_info: str):
1134
+ """
1135
+ Выполняет глобальный обработчик на основе события от ИИ
1136
+
1137
+ Args:
1138
+ handler_type: Тип глобального обработчика
1139
+ event_info: Информация от ИИ (только текст, время задается в декораторе или функции)
1140
+ """
1141
+ router_manager = get_router_manager()
1142
+ if router_manager:
1143
+ global_handlers = router_manager.get_global_handlers()
1144
+ else:
1145
+ global_handlers = _global_handlers
1146
+
1147
+ if handler_type not in global_handlers:
1148
+ raise ValueError(f"Глобальный обработчик '{handler_type}' не найден")
1149
+
1150
+ handler_info = global_handlers[handler_type]
1151
+ default_delay = handler_info.get("default_delay")
1152
+ event_type = handler_info.get("event_type")
1153
+
1154
+ # Время всегда берется из декоратора, ИИ может передавать только текст
1155
+ if default_delay is None:
1156
+ raise ValueError(
1157
+ f"Для глобального обработчика '{handler_type}' не указано время в декораторе (параметр delay)"
1158
+ )
1159
+
1160
+ # event_info содержит только текст для обработчика (если ИИ не передал - пустая строка)
1161
+ handler_data = event_info.strip() if event_info else ""
1162
+
1163
+ # Если указан event_type, вычисляем время относительно события
1164
+ if event_type:
1165
+ event_datetime = None
1166
+
1167
+ # Проверяем тип event_type: строка или функция
1168
+ if callable(event_type):
1169
+ # ВАРИАНТ 2: Функция - вызываем для получения datetime
1170
+ logger.info(
1171
+ f"🌍 Глобальный обработчик '{handler_type}' - вызываем функцию для получения времени"
1172
+ )
1173
+
1174
+ try:
1175
+ # Вызываем функцию (только с handler_data для глобальных)
1176
+ event_datetime = await event_type(handler_data)
1177
+
1178
+ if not isinstance(event_datetime, datetime):
1179
+ raise ValueError(
1180
+ f"Функция event_type должна вернуть datetime, получен {type(event_datetime)}"
1181
+ )
1182
+
1183
+ logger.info(f"✅ Функция вернула время события: {event_datetime}")
1184
+
1185
+ except Exception as e:
1186
+ logger.error(f"❌ Ошибка в функции event_type: {e}")
1187
+ # Fallback - планируем через default_delay
1188
+ result = await schedule_global_handler_for_later_with_db(
1189
+ handler_type, default_delay, handler_data
1190
+ )
1191
+ return result
1192
+
1193
+ else:
1194
+ # ВАРИАНТ 1: Строка - ищем в БД (можно расширить логику если нужно)
1195
+ logger.info(
1196
+ f"🌍 Глобальный обработчик '{handler_type}' - event_type '{event_type}' (строка)"
1197
+ )
1198
+ # Для глобальных обработчиков пока просто используем default_delay
1199
+ # Можно расширить логику если понадобится
1200
+ result = await schedule_global_handler_for_later_with_db(
1201
+ handler_type, default_delay, handler_data
1202
+ )
1203
+ return result
1204
+
1205
+ # Общая логика для функций
1206
+ now = datetime.now()
1207
+
1208
+ # Вычисляем время напоминания (за default_delay до события)
1209
+ reminder_datetime = event_datetime - timedelta(seconds=default_delay)
1210
+
1211
+ # Проверяем, не в прошлом ли напоминание
1212
+ if reminder_datetime <= now:
1213
+ logger.warning(
1214
+ "Напоминание глобального события уже в прошлом, выполняем немедленно"
1215
+ )
1216
+ # Выполняем немедленно
1217
+ result = await execute_global_handler(handler_type, handler_data)
1218
+ return {
1219
+ "status": "executed_immediately",
1220
+ "handler_type": handler_type,
1221
+ "reason": "reminder_time_passed",
1222
+ "event_datetime": event_datetime.isoformat(),
1223
+ "result": result,
1224
+ }
1225
+
1226
+ # Вычисляем задержку до напоминания
1227
+ delay_seconds = int((reminder_datetime - now).total_seconds())
1228
+
1229
+ human_time = format_seconds_to_human(delay_seconds)
1230
+ logger.info(
1231
+ f"🌍 Планируем глобальный обработчик '{handler_type}' за {format_seconds_to_human(default_delay)} до события (через {human_time} / {delay_seconds}с)"
1232
+ )
1233
+
1234
+ # Планируем обработчик
1235
+ result = await schedule_global_handler_for_later_with_db(
1236
+ handler_type, delay_seconds, handler_data
1237
+ )
1238
+ result["event_datetime"] = event_datetime.isoformat()
1239
+ result["reminder_type"] = "global_event_reminder"
1240
+
1241
+ return result
1242
+
1243
+ else:
1244
+ # Обычный глобальный обработчик с фиксированной задержкой
1245
+ logger.info(
1246
+ f"🌍 Планируем глобальный обработчик '{handler_type}' через {default_delay}с с данными: '{handler_data}'"
1247
+ )
1248
+
1249
+ # Планируем обработчик на фоне с сохранением в БД
1250
+ result = await schedule_global_handler_for_later_with_db(
1251
+ handler_type, default_delay, handler_data
1252
+ )
1253
+
1254
+ return result
1255
+
1256
+
1257
+ # =============================================================================
1258
+ # ФУНКЦИИ ДЛЯ РАБОТЫ С БД СОБЫТИЙ
1259
+ # =============================================================================
1260
+
1261
+
1262
+ def get_supabase_client():
1263
+ """Получает клиент Supabase из глобальных переменных"""
1264
+ import sys
1265
+
1266
+ current_module = sys.modules[__name__]
1267
+ supabase_client = getattr(current_module, "supabase_client", None)
1268
+
1269
+ # Если не найден в decorators, пробуем получить из bot_utils
1270
+ if not supabase_client:
1271
+ try:
1272
+ bot_utils_module = sys.modules.get("smart_bot_factory.core.bot_utils")
1273
+ if bot_utils_module:
1274
+ supabase_client = getattr(bot_utils_module, "supabase_client", None)
1275
+ except Exception:
1276
+ logger.debug("Не удалось получить supabase_client из bot_utils")
1277
+
1278
+ return supabase_client
1279
+
1280
+
1281
+ async def save_immediate_event(
1282
+ event_type: str, user_id: int, event_data: str, session_id: str = None
1283
+ ) -> str:
1284
+ """Сохраняет событие для немедленного выполнения"""
1285
+
1286
+ supabase_client = get_supabase_client()
1287
+ if not supabase_client:
1288
+ logger.error("❌ Supabase клиент не найден")
1289
+ raise RuntimeError("Supabase клиент не инициализирован")
1290
+
1291
+ # Проверяем, нужно ли предотвращать дублирование
1292
+ router_manager = get_router_manager()
1293
+ if router_manager:
1294
+ event_handlers = router_manager.get_event_handlers()
1295
+ else:
1296
+ event_handlers = _event_handlers
1297
+
1298
+ event_handler_info = event_handlers.get(event_type, {})
1299
+ once_only = event_handler_info.get("once_only", True)
1300
+
1301
+ if once_only:
1302
+ # Проверяем, было ли уже обработано аналогичное событие для этого пользователя
1303
+ already_processed = await check_event_already_processed(
1304
+ event_type, user_id, session_id
1305
+ )
1306
+ if already_processed:
1307
+ logger.info(
1308
+ f"🔄 Событие '{event_type}' уже обрабатывалось для пользователя {user_id}, пропускаем"
1309
+ )
1310
+ raise ValueError(
1311
+ f"Событие '{event_type}' уже обрабатывалось (once_only=True)"
1312
+ )
1313
+
1314
+ # Получаем bot_id
1315
+ bot_id = supabase_client.bot_id
1316
+ if not bot_id:
1317
+ logger.warning("⚠️ bot_id не указан при создании immediate_event")
1318
+
1319
+ event_record = {
1320
+ "event_type": event_type,
1321
+ "event_category": "user_event",
1322
+ "user_id": user_id,
1323
+ "event_data": event_data,
1324
+ "scheduled_at": None, # Немедленное выполнение
1325
+ "status": "immediate",
1326
+ "session_id": session_id,
1327
+ "bot_id": bot_id, # Всегда добавляем bot_id
1328
+ }
1329
+
1330
+ try:
1331
+ response = (
1332
+ supabase_client.client.table("scheduled_events")
1333
+ .insert(event_record)
1334
+ .execute()
1335
+ )
1336
+ event_id = response.data[0]["id"]
1337
+ logger.info(f"💾 Событие сохранено в БД: {event_id}")
1338
+ return event_id
1339
+ except Exception as e:
1340
+ logger.error(f"❌ Ошибка сохранения события в БД: {e}")
1341
+ raise
1342
+
1343
+
1344
+ async def save_scheduled_task(
1345
+ task_name: str,
1346
+ user_id: int,
1347
+ user_data: str,
1348
+ delay_seconds: int,
1349
+ session_id: str = None,
1350
+ ) -> str:
1351
+ """Сохраняет запланированную задачу"""
1352
+
1353
+ supabase_client = get_supabase_client()
1354
+ if not supabase_client:
1355
+ logger.error("❌ Supabase клиент не найден")
1356
+ raise RuntimeError("Supabase клиент не инициализирован")
1357
+
1358
+ # Проверяем, нужно ли предотвращать дублирование
1359
+ router_manager = get_router_manager()
1360
+ if router_manager:
1361
+ scheduled_tasks = router_manager.get_scheduled_tasks()
1362
+ else:
1363
+ scheduled_tasks = _scheduled_tasks
1364
+
1365
+ task_info = scheduled_tasks.get(task_name, {})
1366
+ once_only = task_info.get("once_only", True)
1367
+
1368
+ if once_only:
1369
+ # Проверяем, была ли уже запланирована аналогичная задача для этого пользователя
1370
+ already_processed = await check_event_already_processed(
1371
+ task_name, user_id, session_id
1372
+ )
1373
+ if already_processed:
1374
+ logger.info(
1375
+ f"🔄 Задача '{task_name}' уже запланирована для пользователя {user_id}, пропускаем"
1376
+ )
1377
+ raise ValueError(f"Задача '{task_name}' уже запланирована (once_only=True)")
1378
+
1379
+ scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
1380
+
1381
+ # Получаем bot_id
1382
+ bot_id = supabase_client.bot_id
1383
+ if not bot_id:
1384
+ logger.warning("⚠️ bot_id не указан при создании scheduled_task")
1385
+
1386
+ event_record = {
1387
+ "event_type": task_name,
1388
+ "event_category": "scheduled_task",
1389
+ "user_id": user_id,
1390
+ "event_data": user_data,
1391
+ "scheduled_at": scheduled_at.isoformat(),
1392
+ "status": "pending",
1393
+ "session_id": session_id,
1394
+ "bot_id": bot_id, # Всегда добавляем bot_id
1395
+ }
1396
+
1397
+ try:
1398
+ response = (
1399
+ supabase_client.client.table("scheduled_events")
1400
+ .insert(event_record)
1401
+ .execute()
1402
+ )
1403
+ event_id = response.data[0]["id"]
1404
+ logger.info(
1405
+ f"⏰ Запланированная задача сохранена в БД: {event_id} (через {delay_seconds}с)"
1406
+ )
1407
+ return event_id
1408
+ except Exception as e:
1409
+ logger.error(f"❌ Ошибка сохранения запланированной задачи в БД: {e}")
1410
+ raise
1411
+
1412
+
1413
+ async def save_global_event(
1414
+ handler_type: str, handler_data: str, delay_seconds: int = 0
1415
+ ) -> str:
1416
+ """Сохраняет глобальное событие"""
1417
+
1418
+ supabase_client = get_supabase_client()
1419
+ if not supabase_client:
1420
+ logger.error("❌ Supabase клиент не найден")
1421
+ raise RuntimeError("Supabase клиент не инициализирован")
1422
+
1423
+ # Проверяем, нужно ли предотвращать дублирование
1424
+ router_manager = get_router_manager()
1425
+ if router_manager:
1426
+ global_handlers = router_manager.get_global_handlers()
1427
+ else:
1428
+ global_handlers = _global_handlers
1429
+
1430
+ handler_info = global_handlers.get(handler_type, {})
1431
+ once_only = handler_info.get("once_only", True)
1432
+
1433
+ if once_only:
1434
+ # Проверяем, было ли уже запланировано аналогичное глобальное событие
1435
+ already_processed = await check_event_already_processed(
1436
+ handler_type, user_id=None
1437
+ )
1438
+ if already_processed:
1439
+ logger.info(
1440
+ f"🔄 Глобальное событие '{handler_type}' уже запланировано, пропускаем"
1441
+ )
1442
+ raise ValueError(
1443
+ f"Глобальное событие '{handler_type}' уже запланировано (once_only=True)"
1444
+ )
1445
+
1446
+ scheduled_at = None
1447
+ status = "immediate"
1448
+
1449
+ if delay_seconds > 0:
1450
+ scheduled_at = datetime.now(timezone.utc) + timedelta(seconds=delay_seconds)
1451
+ status = "pending"
1452
+
1453
+ # Получаем bot_id
1454
+ bot_id = supabase_client.bot_id
1455
+ if not bot_id:
1456
+ logger.warning("⚠️ bot_id не указан при создании global_event")
1457
+
1458
+ event_record = {
1459
+ "event_type": handler_type,
1460
+ "event_category": "global_handler",
1461
+ "user_id": None, # Глобальное событие
1462
+ "event_data": handler_data,
1463
+ "scheduled_at": scheduled_at.isoformat() if scheduled_at else None,
1464
+ "status": status,
1465
+ "bot_id": bot_id, # Всегда добавляем bot_id (глобальные события тоже привязаны к боту)
1466
+ }
1467
+
1468
+ try:
1469
+ response = (
1470
+ supabase_client.client.table("scheduled_events")
1471
+ .insert(event_record)
1472
+ .execute()
1473
+ )
1474
+ event_id = response.data[0]["id"]
1475
+ logger.info(f"🌍 Глобальное событие сохранено в БД: {event_id}")
1476
+ return event_id
1477
+ except Exception as e:
1478
+ logger.error(f"❌ Ошибка сохранения глобального события в БД: {e}")
1479
+ raise
1480
+
1481
+
1482
+ async def update_event_result(
1483
+ event_id: str, status: str, result_data: Any = None, error_message: str = None
1484
+ ):
1485
+ """Обновляет результат выполнения события"""
1486
+
1487
+ supabase_client = get_supabase_client()
1488
+ if not supabase_client:
1489
+ logger.error("❌ Supabase клиент не найден")
1490
+ return
1491
+
1492
+ update_data = {
1493
+ "status": status,
1494
+ "executed_at": datetime.now(timezone.utc).isoformat(),
1495
+ }
1496
+
1497
+ if result_data:
1498
+ import json
1499
+
1500
+ update_data["result_data"] = json.dumps(result_data, ensure_ascii=False)
1501
+
1502
+ # Проверяем наличие поля 'info' для дашборда
1503
+ if isinstance(result_data, dict) and "info" in result_data:
1504
+ update_data["info_dashboard"] = json.dumps(
1505
+ result_data["info"], ensure_ascii=False
1506
+ )
1507
+ logger.info(f"📊 Дашборд данные добавлены в событие {event_id}")
1508
+
1509
+ if error_message:
1510
+ update_data["last_error"] = error_message
1511
+ # Получаем текущее количество попыток
1512
+ try:
1513
+ query = (
1514
+ supabase_client.client.table("scheduled_events")
1515
+ .select("retry_count")
1516
+ .eq("id", event_id)
1517
+ )
1518
+
1519
+ # Добавляем фильтр по bot_id если указан
1520
+ if supabase_client.bot_id:
1521
+ query = query.eq("bot_id", supabase_client.bot_id)
1522
+
1523
+ current_retry = query.execute().data[0]["retry_count"]
1524
+ update_data["retry_count"] = current_retry + 1
1525
+ except Exception:
1526
+ logger.debug("Не удалось получить текущее количество попыток, устанавливаем 1")
1527
+ update_data["retry_count"] = 1
1528
+
1529
+ try:
1530
+ query = supabase_client.client.table("scheduled_events").update(update_data).eq(
1531
+ "id", event_id
1532
+ )
1533
+
1534
+ # Добавляем фильтр по bot_id если указан
1535
+ if supabase_client.bot_id:
1536
+ query = query.eq("bot_id", supabase_client.bot_id)
1537
+
1538
+ query.execute()
1539
+ logger.info(f"📝 Результат события {event_id} обновлен: {status}")
1540
+ except Exception as e:
1541
+ logger.error(f"❌ Ошибка обновления результата события {event_id}: {e}")
1542
+
1543
+
1544
+ async def get_pending_events(limit: int = 50) -> list:
1545
+ """Получает события готовые к выполнению СЕЙЧАС"""
1546
+
1547
+ supabase_client = get_supabase_client()
1548
+ if not supabase_client:
1549
+ logger.error("❌ Supabase клиент не найден")
1550
+ return []
1551
+
1552
+ try:
1553
+ now = datetime.now(timezone.utc).isoformat()
1554
+
1555
+ query = (
1556
+ supabase_client.client.table("scheduled_events")
1557
+ .select("*")
1558
+ .in_("status", ["pending", "immediate"])
1559
+ .or_(f"scheduled_at.is.null,scheduled_at.lte.{now}")
1560
+ .order("created_at")
1561
+ .limit(limit)
1562
+ )
1563
+
1564
+ # 🆕 Фильтруем по bot_id если указан
1565
+ if supabase_client.bot_id:
1566
+ query = query.eq("bot_id", supabase_client.bot_id)
1567
+
1568
+ response = query.execute()
1569
+
1570
+ return response.data
1571
+ except Exception as e:
1572
+ logger.error(f"❌ Ошибка получения событий из БД: {e}")
1573
+ return []
1574
+
1575
+
1576
+ async def get_pending_events_in_next_minute(limit: int = 100) -> list:
1577
+ """Получает события готовые к выполнению в течение следующей минуты"""
1578
+
1579
+ supabase_client = get_supabase_client()
1580
+ if not supabase_client:
1581
+ logger.error("❌ Supabase клиент не найден")
1582
+ return []
1583
+
1584
+ try:
1585
+ now = datetime.now(timezone.utc)
1586
+ next_minute = now + timedelta(seconds=60)
1587
+
1588
+ query = (
1589
+ supabase_client.client.table("scheduled_events")
1590
+ .select("*")
1591
+ .in_("status", ["pending", "immediate"])
1592
+ .or_(f"scheduled_at.is.null,scheduled_at.lte.{next_minute.isoformat()}")
1593
+ .order("created_at")
1594
+ .limit(limit)
1595
+ )
1596
+
1597
+ # 🆕 Фильтруем по bot_id если указан
1598
+ if supabase_client.bot_id:
1599
+ query = query.eq("bot_id", supabase_client.bot_id)
1600
+
1601
+ response = query.execute()
1602
+
1603
+ return response.data
1604
+ except Exception as e:
1605
+ logger.error(f"❌ Ошибка получения событий из БД: {e}")
1606
+ return []
1607
+
1608
+
1609
+ async def background_event_processor():
1610
+ """Фоновый процессор для ВСЕХ типов событий включая админские (проверяет БД каждую минуту)"""
1611
+
1612
+ logger.info(
1613
+ "🔄 Запуск фонового процессора событий (user_event, scheduled_task, global_handler, admin_event)"
1614
+ )
1615
+
1616
+ while True:
1617
+ try:
1618
+ # Получаем события готовые к выполнению в следующую минуту
1619
+ pending_events = await get_pending_events_in_next_minute(limit=100)
1620
+
1621
+ if pending_events:
1622
+ logger.info(f"📋 Найдено {len(pending_events)} событий для обработки")
1623
+
1624
+ for event in pending_events:
1625
+ try:
1626
+ event_type = event["event_type"]
1627
+ event_category = event["event_category"]
1628
+ user_id = event.get("user_id")
1629
+ session_id = event.get("session_id")
1630
+
1631
+ # ========== ОБРАБОТКА АДМИНСКИХ СОБЫТИЙ ==========
1632
+ if event_category == "admin_event":
1633
+ # Проверяем bot_id
1634
+ if not event.get("bot_id"):
1635
+ logger.warning(f"⚠️ Админское событие {event['id']} не имеет bot_id")
1636
+
1637
+ try:
1638
+ logger.info(f"🔄 Начало обработки админского события {event['id']}")
1639
+ logger.info(f"📝 Данные события: {event}")
1640
+
1641
+ # Обрабатываем и получаем результат
1642
+ result = await process_admin_event(event)
1643
+
1644
+ # Сохраняем результат в result_data
1645
+ import json
1646
+
1647
+ supabase_client = get_supabase_client()
1648
+ if not supabase_client:
1649
+ raise RuntimeError("Не найден supabase_client")
1650
+
1651
+ # Готовим данные для обновления
1652
+ update_data = {
1653
+ "status": "completed",
1654
+ "executed_at": datetime.now(timezone.utc).isoformat(),
1655
+ "result_data": json.dumps(result, ensure_ascii=False) if result else None,
1656
+ }
1657
+
1658
+ # Если у события нет bot_id, но он есть в клиенте - добавляем
1659
+ if not event.get("bot_id") and supabase_client.bot_id:
1660
+ update_data["bot_id"] = supabase_client.bot_id
1661
+ logger.info(f"📝 Добавлен bot_id: {supabase_client.bot_id}")
1662
+
1663
+ # Строим запрос
1664
+ query = (
1665
+ supabase_client.client.table("scheduled_events")
1666
+ .update(update_data)
1667
+ .eq("id", event["id"])
1668
+ )
1669
+
1670
+ # Добавляем фильтр по bot_id если он был в событии
1671
+ if event.get("bot_id"):
1672
+ query = query.eq("bot_id", event["bot_id"])
1673
+
1674
+ # Выполняем обновление
1675
+ query.execute()
1676
+
1677
+ logger.info(
1678
+ f"✅ Админское событие {event['id']} выполнено и обновлено в БД"
1679
+ )
1680
+ continue
1681
+
1682
+ except Exception as e:
1683
+ logger.error(
1684
+ f"❌ Ошибка обработки админского события {event['id']}: {e}"
1685
+ )
1686
+ logger.exception("Стек ошибки:")
1687
+
1688
+ try:
1689
+ # Обновляем статус на failed
1690
+ supabase_client = get_supabase_client()
1691
+ if not supabase_client:
1692
+ raise RuntimeError("Не найден supabase_client")
1693
+
1694
+ # Готовим данные для обновления
1695
+ update_data = {
1696
+ "status": "failed",
1697
+ "last_error": str(e),
1698
+ "executed_at": datetime.now(timezone.utc).isoformat(),
1699
+ }
1700
+
1701
+ # Если у события нет bot_id, но он есть в клиенте - добавляем
1702
+ if not event.get("bot_id") and supabase_client.bot_id:
1703
+ update_data["bot_id"] = supabase_client.bot_id
1704
+ logger.info(f"📝 Добавлен bot_id: {supabase_client.bot_id}")
1705
+
1706
+ # Строим запрос
1707
+ query = (
1708
+ supabase_client.client.table("scheduled_events")
1709
+ .update(update_data)
1710
+ .eq("id", event["id"])
1711
+ )
1712
+
1713
+ # Добавляем фильтр по bot_id если он был в событии
1714
+ if event.get("bot_id"):
1715
+ query = query.eq("bot_id", event["bot_id"])
1716
+
1717
+ # Выполняем обновление
1718
+ query.execute()
1719
+ logger.info(f"✅ Статус события {event['id']} обновлен на failed")
1720
+
1721
+ except Exception as update_error:
1722
+ logger.error(f"❌ Ошибка обновления статуса события: {update_error}")
1723
+ logger.exception("Стек ошибки обновления:")
1724
+
1725
+ continue
1726
+
1727
+ # ========== ОБРАБОТКА USER СОБЫТИЙ ==========
1728
+ if event_category == "user_event":
1729
+ router_manager = get_router_manager()
1730
+ if router_manager:
1731
+ event_handlers = router_manager.get_event_handlers()
1732
+ else:
1733
+ event_handlers = _event_handlers
1734
+
1735
+ event_handler_info = event_handlers.get(event_type, {})
1736
+ once_only = event_handler_info.get("once_only", True)
1737
+
1738
+ if once_only:
1739
+ # Проверяем, было ли уже выполнено это событие для данного пользователя
1740
+ supabase_client = get_supabase_client()
1741
+ check_query = (
1742
+ supabase_client.client.table("scheduled_events")
1743
+ .select("id")
1744
+ .eq("event_type", event_type)
1745
+ .eq("user_id", user_id)
1746
+ .eq("status", "completed")
1747
+ .neq("id", event["id"])
1748
+ ) # Исключаем текущее событие
1749
+
1750
+ if session_id:
1751
+ check_query = check_query.eq(
1752
+ "session_id", session_id
1753
+ )
1754
+
1755
+ existing = check_query.execute()
1756
+
1757
+ if existing.data:
1758
+ await update_event_result(
1759
+ event["id"],
1760
+ "cancelled",
1761
+ {"reason": "already_executed_once_only"},
1762
+ )
1763
+ logger.info(
1764
+ f"⛔ Событие {event['id']} ({event_type}) пропущено: уже выполнялось для пользователя {user_id} (once_only=True)"
1765
+ )
1766
+ continue
1767
+
1768
+ # Для scheduled_task - проверяем smart_check и once_only
1769
+ if event_category == "scheduled_task":
1770
+ router_manager = get_router_manager()
1771
+ scheduled_tasks = (
1772
+ router_manager.get_scheduled_tasks()
1773
+ if router_manager
1774
+ else _scheduled_tasks
1775
+ )
1776
+ task_info = scheduled_tasks.get(event_type, {})
1777
+ use_smart_check = task_info.get("smart_check", True)
1778
+ once_only = task_info.get("once_only", True)
1779
+
1780
+ # Проверяем once_only для задач
1781
+ if once_only:
1782
+ supabase_client = get_supabase_client()
1783
+ check_query = (
1784
+ supabase_client.client.table("scheduled_events")
1785
+ .select("id")
1786
+ .eq("event_type", event_type)
1787
+ .eq("user_id", user_id)
1788
+ .eq("status", "completed")
1789
+ .neq("id", event["id"])
1790
+ )
1791
+
1792
+ if session_id:
1793
+ check_query = check_query.eq(
1794
+ "session_id", session_id
1795
+ )
1796
+
1797
+ existing = check_query.execute()
1798
+
1799
+ if existing.data:
1800
+ await update_event_result(
1801
+ event["id"],
1802
+ "cancelled",
1803
+ {"reason": "already_executed_once_only"},
1804
+ )
1805
+ logger.info(
1806
+ f"⛔ Задача {event['id']} ({event_type}) пропущена: уже выполнялась для пользователя {user_id} (once_only=True)"
1807
+ )
1808
+ continue
1809
+
1810
+ if use_smart_check:
1811
+ # Умная проверка
1812
+ check_result = await smart_execute_check(
1813
+ event["id"],
1814
+ user_id,
1815
+ session_id,
1816
+ event_type,
1817
+ event["event_data"],
1818
+ )
1819
+
1820
+ if check_result["action"] == "cancel":
1821
+ await update_event_result(
1822
+ event["id"],
1823
+ "cancelled",
1824
+ {"reason": check_result["reason"]},
1825
+ )
1826
+ logger.info(
1827
+ f"⛔ Задача {event['id']} отменена: {check_result['reason']}"
1828
+ )
1829
+ continue
1830
+ elif check_result["action"] == "reschedule":
1831
+ # Обновляем scheduled_at в БД
1832
+ new_scheduled_at = datetime.now(
1833
+ timezone.utc
1834
+ ) + timedelta(seconds=check_result["new_delay"])
1835
+ supabase_client = get_supabase_client()
1836
+ supabase_client.client.table(
1837
+ "scheduled_events"
1838
+ ).update(
1839
+ {
1840
+ "scheduled_at": new_scheduled_at.isoformat(),
1841
+ "status": "pending",
1842
+ }
1843
+ ).eq(
1844
+ "id", event["id"]
1845
+ ).execute()
1846
+ logger.info(
1847
+ f"🔄 Задача {event['id']} перенесена на {check_result['new_delay']}с"
1848
+ )
1849
+ continue
1850
+
1851
+ # Выполняем событие
1852
+ result = await process_scheduled_event(event)
1853
+
1854
+ # Проверяем наличие поля 'info' для дашборда
1855
+ result_data = {"processed": True}
1856
+ if isinstance(result, dict):
1857
+ result_data.update(result)
1858
+ if "info" in result:
1859
+ logger.info(
1860
+ f" 📊 Дашборд данные для задачи: {result['info'].get('title', 'N/A')}"
1861
+ )
1862
+
1863
+ await update_event_result(event["id"], "completed", result_data)
1864
+ logger.info(f"✅ Событие {event['id']} выполнено")
1865
+
1866
+ except Exception as e:
1867
+ logger.error(f"❌ Ошибка обработки события {event['id']}: {e}")
1868
+ await update_event_result(event["id"], "failed", None, str(e))
1869
+
1870
+ await asyncio.sleep(60) # Проверяем каждую минуту
1871
+
1872
+ except Exception as e:
1873
+ logger.error(f"❌ Ошибка в фоновом процессоре: {e}")
1874
+ await asyncio.sleep(60)
1875
+
1876
+
1877
+ async def process_scheduled_event(event: Dict):
1878
+ """Обрабатывает одно событие из БД и возвращает результат"""
1879
+
1880
+ event_type = event["event_type"]
1881
+ event_category = event["event_category"]
1882
+ event_data = event["event_data"]
1883
+ user_id = event.get("user_id")
1884
+
1885
+ logger.info(f"🔄 Обработка события {event['id']}: {event_category}/{event_type}")
1886
+
1887
+ result = None
1888
+ if event_category == "scheduled_task":
1889
+ # Получаем информацию о задаче
1890
+ router_manager = get_router_manager()
1891
+ if router_manager:
1892
+ scheduled_tasks = router_manager.get_scheduled_tasks()
1893
+ else:
1894
+ scheduled_tasks = _scheduled_tasks
1895
+
1896
+ task_info = scheduled_tasks.get(event_type, {})
1897
+ notify = task_info.get("notify", False)
1898
+ notify_time = task_info.get("notify_time", "after")
1899
+
1900
+ # Выполняем задачу
1901
+ result = await execute_scheduled_task(event_type, user_id, event_data)
1902
+
1903
+ # Отправляем уведомление после выполнения если notify=True и notify_time="after"
1904
+ if notify and notify_time == "after":
1905
+ from ..core.bot_utils import notify_admins_about_event
1906
+ event_for_notify = {"тип": event_type, "инфо": event_data}
1907
+ await notify_admins_about_event(user_id, event_for_notify)
1908
+ logger.info(f" ✅ Админы уведомлены после выполнения задачи '{event_type}'")
1909
+
1910
+ elif event_category == "global_handler":
1911
+ result = await execute_global_handler(event_type, event_data)
1912
+ elif event_category == "user_event":
1913
+ result = await execute_event_handler(event_type, user_id, event_data)
1914
+ else:
1915
+ logger.warning(f"⚠️ Неизвестная категория события: {event_category}")
1916
+
1917
+ return result
1918
+
1919
+
1920
+ # =============================================================================
1921
+ # ОБНОВЛЕННЫЕ ФУНКЦИИ С СОХРАНЕНИЕМ В БД
1922
+ # =============================================================================
1923
+
1924
+
1925
+ async def schedule_task_for_later_with_db(
1926
+ task_name: str,
1927
+ user_id: int,
1928
+ user_data: str,
1929
+ delay_seconds: int,
1930
+ session_id: str = None,
1931
+ ):
1932
+ """Планирует выполнение задачи через указанное время с сохранением в БД (без asyncio.sleep)"""
1933
+
1934
+ # Проверяем через RouterManager или fallback к старым декораторам
1935
+ router_manager = get_router_manager()
1936
+ if router_manager:
1937
+ scheduled_tasks = router_manager.get_scheduled_tasks()
1938
+ else:
1939
+ scheduled_tasks = _scheduled_tasks
1940
+
1941
+ if task_name not in scheduled_tasks:
1942
+ import inspect
1943
+
1944
+ frame = inspect.currentframe()
1945
+ line_no = frame.f_lineno if frame else "unknown"
1946
+ available_tasks = list(scheduled_tasks.keys())
1947
+ logger.error(
1948
+ f"❌ [decorators.py:{line_no}] Задача '{task_name}' не найдена. Доступные: {available_tasks}"
1949
+ )
1950
+ raise ValueError(f"Задача '{task_name}' не найдена")
1951
+
1952
+ human_time = format_seconds_to_human(delay_seconds)
1953
+ logger.info(
1954
+ f"⏰ Планируем задачу '{task_name}' через {human_time} ({delay_seconds}с) для user_id={user_id}"
1955
+ )
1956
+
1957
+ # Просто сохраняем в БД - фоновый процессор сам выполнит задачу
1958
+ event_id = await save_scheduled_task(
1959
+ task_name, user_id, user_data, delay_seconds, session_id
1960
+ )
1961
+
1962
+ logger.info(
1963
+ f"💾 Задача '{task_name}' сохранена в БД с ID {event_id}, будет обработана фоновым процессором"
1964
+ )
1965
+
1966
+ return {
1967
+ "status": "scheduled",
1968
+ "task_name": task_name,
1969
+ "delay_seconds": delay_seconds,
1970
+ "event_id": event_id,
1971
+ "scheduled_at": datetime.now(timezone.utc).isoformat(),
1972
+ }
1973
+
1974
+
1975
+ async def schedule_global_handler_for_later_with_db(
1976
+ handler_type: str, delay_seconds: int, handler_data: str
1977
+ ):
1978
+ """Планирует выполнение глобального обработчика через указанное время с сохранением в БД (без asyncio.sleep)"""
1979
+
1980
+ # Проверяем обработчик через RouterManager или fallback к старым декораторам
1981
+ router_manager = get_router_manager()
1982
+ if router_manager:
1983
+ global_handlers = router_manager.get_global_handlers()
1984
+ else:
1985
+ global_handlers = _global_handlers
1986
+
1987
+ if handler_type not in global_handlers:
1988
+ raise ValueError(f"Глобальный обработчик '{handler_type}' не найден")
1989
+
1990
+ logger.info(
1991
+ f"🌍 Планируем глобальный обработчик '{handler_type}' через {delay_seconds} секунд"
1992
+ )
1993
+
1994
+ # Просто сохраняем в БД - фоновый процессор сам выполнит обработчик
1995
+ event_id = await save_global_event(handler_type, handler_data, delay_seconds)
1996
+
1997
+ logger.info(
1998
+ f"💾 Глобальный обработчик '{handler_type}' сохранен в БД с ID {event_id}, будет обработан фоновым процессором"
1999
+ )
2000
+
2001
+ return {
2002
+ "status": "scheduled",
2003
+ "handler_type": handler_type,
2004
+ "delay_seconds": delay_seconds,
2005
+ "event_id": event_id,
2006
+ "scheduled_at": datetime.now(timezone.utc).isoformat(),
2007
+ }
2008
+
2009
+
2010
+ async def smart_execute_check(
2011
+ event_id: str, user_id: int, session_id: str, task_name: str, user_data: str
2012
+ ) -> Dict[str, Any]:
2013
+ """
2014
+ Умная проверка перед выполнением запланированной задачи
2015
+
2016
+ Логика:
2017
+ 1. Если пользователь перешел на новый этап - отменяем событие
2018
+ 2. Если прошло меньше времени чем планировалось - переносим на разницу
2019
+ 3. Если прошло достаточно времени - выполняем
2020
+
2021
+ Returns:
2022
+ Dict с action: 'execute', 'cancel', 'reschedule'
2023
+ """
2024
+ supabase_client = get_supabase_client()
2025
+ if not supabase_client:
2026
+ logger.error("❌ Supabase клиент не найден для умной проверки")
2027
+ return {"action": "execute", "reason": "no_supabase_client"}
2028
+
2029
+ try:
2030
+ # Получаем информацию о последнем сообщении пользователя
2031
+ user_info = await supabase_client.get_user_last_message_info(user_id)
2032
+
2033
+ if not user_info:
2034
+ logger.info(f"🔄 Пользователь {user_id} не найден, выполняем задачу")
2035
+ return {"action": "execute", "reason": "user_not_found"}
2036
+
2037
+ # Проверяем, изменился ли этап
2038
+ stage_changed = await supabase_client.check_user_stage_changed(
2039
+ user_id, session_id
2040
+ )
2041
+ if stage_changed:
2042
+ logger.info(
2043
+ f"🔄 Пользователь {user_id} перешел на новый этап, отменяем задачу {task_name}"
2044
+ )
2045
+ return {"action": "cancel", "reason": "user_stage_changed"}
2046
+
2047
+ # Получаем информацию о событии из БД
2048
+ event_response = (
2049
+ supabase_client.client.table("scheduled_events")
2050
+ .select("created_at", "scheduled_at")
2051
+ .eq("id", event_id)
2052
+ .execute()
2053
+ )
2054
+
2055
+ if not event_response.data:
2056
+ logger.error(f"❌ Событие {event_id} не найдено в БД")
2057
+ return {"action": "execute", "reason": "event_not_found"}
2058
+
2059
+ event = event_response.data[0]
2060
+ created_at = datetime.fromisoformat(event["created_at"].replace("Z", "+00:00"))
2061
+ scheduled_at = datetime.fromisoformat(
2062
+ event["scheduled_at"].replace("Z", "+00:00")
2063
+ )
2064
+ last_message_at = datetime.fromisoformat(
2065
+ user_info["last_message_at"].replace("Z", "+00:00")
2066
+ )
2067
+
2068
+ # Вычисляем разницу во времени
2069
+ now = datetime.now(timezone.utc)
2070
+ time_since_creation = (now - created_at).total_seconds()
2071
+ time_since_last_message = (now - last_message_at).total_seconds()
2072
+ planned_delay = (scheduled_at - created_at).total_seconds()
2073
+
2074
+ # Проверяем, писал ли пользователь после создания события
2075
+ time_between_creation_and_last_message = (
2076
+ last_message_at - created_at
2077
+ ).total_seconds()
2078
+
2079
+ logger.info(f"🔄 Анализ для пользователя {user_id}:")
2080
+ logger.info(f" Время с создания события: {time_since_creation:.0f}с")
2081
+ logger.info(f" Время с последнего сообщения: {time_since_last_message:.0f}с")
2082
+ logger.info(f" Запланированная задержка: {planned_delay:.0f}с")
2083
+ logger.info(
2084
+ f" Пользователь писал после создания события: {time_between_creation_and_last_message > 0}"
2085
+ )
2086
+
2087
+ # Если пользователь писал ПОСЛЕ создания события (недавно активен)
2088
+ # И с момента его последнего сообщения прошло меньше planned_delay
2089
+ if (
2090
+ time_between_creation_and_last_message > 0
2091
+ and time_since_last_message < planned_delay
2092
+ ):
2093
+ # Пересчитываем время - отправляем через planned_delay после последнего сообщения
2094
+ new_delay = max(0, planned_delay - time_since_last_message)
2095
+ logger.info(
2096
+ f"🔄 Переносим задачу на {new_delay:.0f}с (пользователь был активен, через {planned_delay:.0f}с после последнего сообщения)"
2097
+ )
2098
+ return {
2099
+ "action": "reschedule",
2100
+ "new_delay": new_delay,
2101
+ "reason": f"user_active_after_event_creation_{new_delay:.0f}s_delay",
2102
+ }
2103
+
2104
+ # Если прошло достаточно времени с последнего сообщения - выполняем
2105
+ if time_since_last_message >= planned_delay:
2106
+ logger.info(
2107
+ f"🔄 Выполняем задачу {task_name} для пользователя {user_id} (прошло {time_since_last_message:.0f}с с последнего сообщения)"
2108
+ )
2109
+ return {"action": "execute", "reason": "time_expired_since_last_message"}
2110
+
2111
+ # Если что-то пошло не так - выполняем
2112
+ logger.info(f"🔄 Неожиданная ситуация, выполняем задачу {task_name}")
2113
+ return {"action": "execute", "reason": "unexpected_situation"}
2114
+
2115
+ except Exception as e:
2116
+ logger.error(f"❌ Ошибка в умной проверке для пользователя {user_id}: {e}")
2117
+ return {"action": "execute", "reason": f"error_in_check: {str(e)}"}
2118
+
2119
+
2120
+ async def check_event_already_processed(
2121
+ event_type: str, user_id: int = None, session_id: str = None
2122
+ ) -> bool:
2123
+ """
2124
+ Проверяет, был ли уже обработан аналогичный event_type для пользователя/сессии
2125
+
2126
+ Args:
2127
+ event_type: Тип события
2128
+ user_id: ID пользователя (для user_event и scheduled_task)
2129
+ session_id: ID сессии (для дополнительной проверки)
2130
+
2131
+ Returns:
2132
+ True если событие уже обрабатывалось или в процессе
2133
+ """
2134
+ supabase_client = get_supabase_client()
2135
+ if not supabase_client:
2136
+ logger.error("❌ Supabase клиент не найден для проверки дублирования")
2137
+ return False
2138
+
2139
+ try:
2140
+ # Строим запрос для поиска аналогичных событий
2141
+ query = (
2142
+ supabase_client.client.table("scheduled_events")
2143
+ .select("id")
2144
+ .eq("event_type", event_type)
2145
+ )
2146
+
2147
+ # Для глобальных событий (user_id = None)
2148
+ if user_id is None:
2149
+ query = query.is_("user_id", "null")
2150
+ else:
2151
+ query = query.eq("user_id", user_id)
2152
+
2153
+ # Добавляем фильтр по статусам (pending, immediate, completed)
2154
+ query = query.in_("status", ["pending", "immediate", "completed"])
2155
+
2156
+ # Если есть session_id, добавляем его в фильтр
2157
+ if session_id:
2158
+ query = query.eq("session_id", session_id)
2159
+
2160
+ # 🆕 Фильтруем по bot_id если указан
2161
+ if supabase_client.bot_id:
2162
+ query = query.eq("bot_id", supabase_client.bot_id)
2163
+
2164
+ response = query.execute()
2165
+
2166
+ if response.data:
2167
+ logger.info(
2168
+ f"🔄 Найдено {len(response.data)} аналогичных событий для '{event_type}'"
2169
+ )
2170
+ return True
2171
+
2172
+ return False
2173
+
2174
+ except Exception as e:
2175
+ logger.error(f"❌ Ошибка проверки дублирования для '{event_type}': {e}")
2176
+ return False
2177
+
2178
+
2179
+ async def process_admin_event(event: Dict, single_user_id: Optional[int] = None):
2180
+ """
2181
+ Обрабатывает одно админское событие - скачивает файлы из Storage и отправляет пользователям
2182
+
2183
+ Args:
2184
+ event: Событие из БД с данными для отправки
2185
+ single_user_id: ID пользователя для тестовой отправки. Если указан, сообщение будет отправлено только ему
2186
+ """
2187
+ import json
2188
+ import shutil
2189
+ from pathlib import Path
2190
+
2191
+ from aiogram.types import FSInputFile, InputMediaPhoto, InputMediaVideo
2192
+
2193
+ event_id = event["id"]
2194
+ event_name = event["event_type"]
2195
+ event_data_str = event["event_data"]
2196
+
2197
+ try:
2198
+ event_data = json.loads(event_data_str)
2199
+ except Exception as e:
2200
+ logger.error(f"❌ Не удалось распарсить event_data для события {event_id}: {e}")
2201
+ return {
2202
+ "success_count": 0,
2203
+ "failed_count": 0,
2204
+ "total_users": 0,
2205
+ "error": f"Ошибка парсинга event_data: {str(e)}",
2206
+ }
2207
+
2208
+ segment = event_data.get("segment")
2209
+ message_text = event_data.get("message")
2210
+ files_metadata = event_data.get("files", [])
2211
+
2212
+ logger.info(
2213
+ f"📨 Обработка события '{event_name}': сегмент='{segment}', файлов={len(files_metadata)}"
2214
+ )
2215
+
2216
+ # Получаем клиенты
2217
+ supabase_client = get_supabase_client()
2218
+ if not supabase_client:
2219
+ logger.error("❌ Supabase клиент не найден")
2220
+ return {
2221
+ "success_count": 0,
2222
+ "failed_count": 0,
2223
+ "total_users": 0,
2224
+ "error": "Нет Supabase клиента",
2225
+ }
2226
+
2227
+ from ..handlers.handlers import get_global_var
2228
+
2229
+ bot = get_global_var("bot")
2230
+ if not bot:
2231
+ logger.error("❌ Бот не найден")
2232
+ return {
2233
+ "success_count": 0,
2234
+ "failed_count": 0,
2235
+ "total_users": 0,
2236
+ "error": "Нет бота",
2237
+ }
2238
+
2239
+ # Создаем временные папки
2240
+ temp_with_msg = Path("temp_with_msg")
2241
+ temp_after_msg = Path("temp_after_msg")
2242
+ temp_with_msg.mkdir(exist_ok=True)
2243
+ temp_after_msg.mkdir(exist_ok=True)
2244
+
2245
+ try:
2246
+ # 1. Скачиваем файлы из Storage
2247
+ for file_info in files_metadata:
2248
+ try:
2249
+ file_bytes = await supabase_client.download_event_file(
2250
+ event_id=event_id, storage_path=file_info["storage_path"]
2251
+ )
2252
+
2253
+ # Сохраняем в соответствующую папку
2254
+ if file_info["stage"] == "with_message":
2255
+ file_path = temp_with_msg / file_info["original_name"]
2256
+ else:
2257
+ file_path = temp_after_msg / file_info["original_name"]
2258
+
2259
+ with open(file_path, "wb") as f:
2260
+ f.write(file_bytes)
2261
+
2262
+ logger.info(f"📥 Скачан файл: {file_path}")
2263
+
2264
+ except Exception as e:
2265
+ logger.error(f"❌ Ошибка скачивания файла {file_info['name']}: {e}")
2266
+ raise
2267
+
2268
+ # 2. Получаем пользователей
2269
+ if single_user_id:
2270
+ users = [{"telegram_id": single_user_id}]
2271
+ logger.info(f"🔍 Тестовая отправка для пользователя {single_user_id}")
2272
+ else:
2273
+ users = await supabase_client.get_users_by_segment(segment)
2274
+ if not users:
2275
+ logger.warning(f"⚠️ Нет пользователей для сегмента '{segment}'")
2276
+ return {
2277
+ "success_count": 0,
2278
+ "failed_count": 0,
2279
+ "total_users": 0,
2280
+ "segment": segment or "Все",
2281
+ "warning": "Нет пользователей",
2282
+ }
2283
+
2284
+ success_count = 0
2285
+ failed_count = 0
2286
+
2287
+ # 3. Отправляем каждому пользователю
2288
+ for user in users:
2289
+ telegram_id = user["telegram_id"]
2290
+
2291
+ try:
2292
+ # 3.1. Отправляем медиа-группу с сообщением
2293
+ files_with_msg = [
2294
+ f for f in files_metadata if f["stage"] == "with_message"
2295
+ ]
2296
+
2297
+ if files_with_msg:
2298
+ media_group = []
2299
+ first_file = True
2300
+
2301
+ # Сортируем файлы по порядку
2302
+ sorted_files = sorted(
2303
+ files_with_msg, key=lambda x: x.get("order", 0)
2304
+ )
2305
+
2306
+ for file_info in sorted_files:
2307
+ file_path = temp_with_msg / file_info["original_name"]
2308
+
2309
+ if file_info["type"] == "photo":
2310
+ media = InputMediaPhoto(
2311
+ media=FSInputFile(file_path),
2312
+ caption=message_text if first_file else None,
2313
+ parse_mode="Markdown" if first_file else None,
2314
+ )
2315
+ media_group.append(media)
2316
+ elif file_info["type"] == "video":
2317
+ media = InputMediaVideo(
2318
+ media=FSInputFile(file_path),
2319
+ caption=message_text if first_file else None,
2320
+ parse_mode="Markdown" if first_file else None,
2321
+ )
2322
+ media_group.append(media)
2323
+
2324
+ first_file = False
2325
+
2326
+ if media_group:
2327
+ await bot.send_media_group(
2328
+ chat_id=telegram_id, media=media_group
2329
+ )
2330
+ else:
2331
+ # Только текст без файлов
2332
+ await bot.send_message(
2333
+ chat_id=telegram_id, text=message_text, parse_mode="Markdown"
2334
+ )
2335
+
2336
+ # 3.2. Отправляем файлы после сообщения
2337
+ files_after = [
2338
+ f for f in files_metadata if f["stage"] == "after_message"
2339
+ ]
2340
+
2341
+ for file_info in files_after:
2342
+ file_path = temp_after_msg / file_info["original_name"]
2343
+
2344
+ if file_info["type"] == "document":
2345
+ await bot.send_document(
2346
+ chat_id=telegram_id, document=FSInputFile(file_path)
2347
+ )
2348
+ elif file_info["type"] == "photo":
2349
+ await bot.send_photo(
2350
+ chat_id=telegram_id, photo=FSInputFile(file_path)
2351
+ )
2352
+ elif file_info["type"] == "video":
2353
+ await bot.send_video(
2354
+ chat_id=telegram_id, video=FSInputFile(file_path)
2355
+ )
2356
+
2357
+ success_count += 1
2358
+ logger.info(f"✅ Отправлено пользователю {telegram_id}")
2359
+
2360
+ except Exception as e:
2361
+ logger.error(f"❌ Ошибка отправки пользователю {telegram_id}: {e}")
2362
+ failed_count += 1
2363
+
2364
+ logger.info(
2365
+ f"📊 Результат '{event_name}': успешно={success_count}, ошибок={failed_count}"
2366
+ )
2367
+
2368
+ # 4. Очистка после успешной отправки
2369
+ # 4.1. Удаляем локальные временные файлы
2370
+ shutil.rmtree(temp_with_msg, ignore_errors=True)
2371
+ shutil.rmtree(temp_after_msg, ignore_errors=True)
2372
+ logger.info("🗑️ Временные папки очищены")
2373
+
2374
+ # 4.2. Удаляем файлы из Supabase Storage
2375
+ try:
2376
+ await supabase_client.delete_event_files(event_id)
2377
+ logger.info(f"🗑️ Файлы события '{event_id}' удалены из Storage")
2378
+ except Exception as e:
2379
+ logger.error(f"❌ Ошибка удаления из Storage: {e}")
2380
+
2381
+ return {
2382
+ "success_count": success_count,
2383
+ "failed_count": failed_count,
2384
+ "total_users": len(users),
2385
+ "segment": segment or "Все пользователи",
2386
+ "files_count": len(files_metadata),
2387
+ }
2388
+
2389
+ except Exception as e:
2390
+ # В случае ошибки все равно чистим временные файлы
2391
+ shutil.rmtree(temp_with_msg, ignore_errors=True)
2392
+ shutil.rmtree(temp_after_msg, ignore_errors=True)
2393
+ logger.error(f"❌ Критическая ошибка обработки события: {e}")
2394
+ raise
2395
+
2396
+
2397
+ # =============================================================================
2398
+ # ФУНКЦИЯ ДЛЯ ПОДГОТОВКИ ДАННЫХ ДАШБОРДА
2399
+ # =============================================================================
2400
+
2401
+
2402
+ async def prepare_dashboard_info(
2403
+ description_template: str, title: str, user_id: int
2404
+ ) -> Dict[str, Any]:
2405
+ """
2406
+ Подготавливает данные для дашборда (БЕЗ записи в БД)
2407
+
2408
+ Возвращаемый dict нужно поместить в поле 'info' результата обработчика.
2409
+ bot_utils.py автоматически запишет его в столбец info_dashboard таблицы.
2410
+
2411
+ Args:
2412
+ description_template: Строка с {username}, например "{username} купил подписку"
2413
+ title: Заголовок для дашборда
2414
+ user_id: Telegram ID
2415
+
2416
+ Returns:
2417
+ Dict с данными для дашборда
2418
+
2419
+ Example:
2420
+ @event_router.event_handler("collect_phone", notify=True)
2421
+ async def handle_phone_collection(user_id: int, phone_number: str):
2422
+ # ... бизнес-логика ...
2423
+
2424
+ return {
2425
+ "status": "success",
2426
+ "phone": phone_number,
2427
+ "info": await prepare_dashboard_info(
2428
+ description_template="{username} оставил телефон",
2429
+ title="Новый контакт",
2430
+ user_id=user_id
2431
+ )
2432
+ }
2433
+ """
2434
+ supabase_client = get_supabase_client()
2435
+
2436
+ # Получаем username из sales_users
2437
+ username = f"user_{user_id}" # fallback
2438
+ if supabase_client:
2439
+ try:
2440
+ query = (
2441
+ supabase_client.client.table("sales_users")
2442
+ .select("username")
2443
+ .eq("telegram_id", user_id)
2444
+ )
2445
+ if supabase_client.bot_id:
2446
+ query = query.eq("bot_id", supabase_client.bot_id)
2447
+ response = query.execute()
2448
+ if response.data:
2449
+ username = response.data[0].get("username") or username
2450
+ except Exception as e:
2451
+ logger.warning(f"⚠️ Не удалось получить username для дашборда: {e}")
2452
+
2453
+ # Форматируем строку
2454
+ description = description_template.format(username=username)
2455
+
2456
+ # Московское время (UTC+3)
2457
+ moscow_tz = timezone(timedelta(hours=3))
2458
+ moscow_time = datetime.now(moscow_tz)
2459
+
2460
+ return {
2461
+ "title": title,
2462
+ "description": description,
2463
+ "created_at": moscow_time.isoformat(),
2464
+ }