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,1223 @@
1
+ # Обработчики для создания админских событий
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import uuid
8
+ from datetime import datetime
9
+
10
+ import pytz
11
+ from aiogram import F, Router
12
+ from aiogram.filters import Command
13
+ from aiogram.fsm.context import FSMContext
14
+ from aiogram.types import (CallbackQuery, InlineKeyboardButton,
15
+ InlineKeyboardMarkup, Message)
16
+ from aiogram_media_group import media_group_handler
17
+ from dateutil.relativedelta import relativedelta
18
+
19
+ from ..aiogram_calendar import SimpleCalendar, SimpleCalendarCallback
20
+ from ..core.states import AdminStates
21
+
22
+ TEMP_DIR = "temp_event_files"
23
+
24
+
25
+ def generate_file_id() -> str:
26
+ """Генерирует уникальный ID для файла"""
27
+ return f"file_{uuid.uuid4().hex}"
28
+
29
+
30
+ def ensure_temp_dir():
31
+ """Создает временную папку если её нет"""
32
+ if not os.path.exists(TEMP_DIR):
33
+ os.makedirs(TEMP_DIR)
34
+ logger.info(f"📁 Создана временная папка {TEMP_DIR}")
35
+
36
+
37
+ async def cleanup_temp_files(state: FSMContext = None):
38
+ """Очистка временных файлов события"""
39
+ # Удаляем все файлы из временной папки
40
+ if os.path.exists(TEMP_DIR):
41
+ try:
42
+ shutil.rmtree(TEMP_DIR)
43
+ logger.info(f"🗑️ Удалена папка {TEMP_DIR}")
44
+ except Exception as e:
45
+ logger.error(f"❌ Ошибка при удалении {TEMP_DIR}: {e}")
46
+
47
+ # Очищаем информацию о файлах в состоянии
48
+ if state:
49
+ try:
50
+ data = await state.get_data()
51
+ if "files" in data:
52
+ data["files"] = []
53
+ await state.set_data(data)
54
+ except Exception as e:
55
+ logger.error(f"❌ Ошибка при очистке состояния: {e}")
56
+
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ # Московская временная зона
61
+ MOSCOW_TZ = pytz.timezone("Europe/Moscow")
62
+
63
+ # Создаем роутер для админских событий
64
+ admin_events_router = Router()
65
+
66
+
67
+ def setup_admin_events_handlers(dp):
68
+ """Настройка обработчиков админских событий"""
69
+ dp.include_router(admin_events_router)
70
+
71
+
72
+ @admin_events_router.message(Command(commands=["создать_событие", "create_event"]))
73
+ async def create_event_start(message: Message, state: FSMContext):
74
+ """Начало создания события"""
75
+ from ..handlers.handlers import get_global_var
76
+
77
+ admin_manager = get_global_var("admin_manager")
78
+
79
+ if not admin_manager.is_admin(message.from_user.id):
80
+ return
81
+
82
+ await state.set_state(AdminStates.create_event_name)
83
+
84
+ await message.answer(
85
+ "📝 **Введите название события**\n\n"
86
+ "💡 _По этому названию вы сможете:\n"
87
+ "• Найти событие в списке\n"
88
+ "• Отменить его при необходимости_",
89
+ parse_mode="Markdown",
90
+ )
91
+
92
+
93
+ @admin_events_router.message(AdminStates.create_event_name)
94
+ async def process_event_name(message: Message, state: FSMContext):
95
+ """Обработка названия события"""
96
+ from ..handlers.handlers import get_global_var
97
+
98
+ event_name = message.text.strip()
99
+
100
+ if not event_name:
101
+ await message.answer("❌ Название не может быть пустым. Попробуйте еще раз:")
102
+ return
103
+
104
+ # Проверяем уникальность названия (только среди активных событий)
105
+ supabase_client = get_global_var("supabase_client")
106
+ name_exists = await supabase_client.check_event_name_exists(event_name)
107
+
108
+ if name_exists:
109
+ await message.answer(
110
+ f"⚠️ **Событие с названием «{event_name}» уже существует и находится в статусе ожидания!**\n\n"
111
+ f"Пожалуйста, выберите другое название или дождитесь выполнения/отмены существующего события.\n\n"
112
+ f"💡 _Вы можете использовать это же название после завершения или отмены текущего события._",
113
+ parse_mode="Markdown",
114
+ )
115
+ return
116
+
117
+ # Сохраняем название
118
+ await state.update_data(event_name=event_name)
119
+
120
+ # Создаем клавиатуру с выбором времени
121
+ keyboard = InlineKeyboardMarkup(
122
+ inline_keyboard=[
123
+ [
124
+ InlineKeyboardButton(text="🚀 Запустить сразу", callback_data="timing:immediate"),
125
+ InlineKeyboardButton(text="📅 Выбрать время", callback_data="timing:scheduled")
126
+ ]
127
+ ]
128
+ )
129
+
130
+ await message.answer(
131
+ f"✅ Название события: **{event_name}**\n\n"
132
+ "🕒 Когда запустить событие?",
133
+ reply_markup=keyboard,
134
+ parse_mode="Markdown"
135
+ )
136
+
137
+
138
+ @admin_events_router.callback_query(F.data.startswith("timing:"))
139
+ async def process_event_timing(callback_query: CallbackQuery, state: FSMContext):
140
+ """Обработка выбора времени запуска события"""
141
+ action = callback_query.data.split(":", 1)[1]
142
+
143
+ if action == "immediate":
144
+ # Устанавливаем текущее время
145
+ now = datetime.now(MOSCOW_TZ)
146
+ await state.update_data(
147
+ event_date=now.strftime("%Y-%m-%d"),
148
+ event_time=now.strftime("%H:%M"),
149
+ is_immediate=True
150
+ )
151
+ # Переходим к выбору сегмента
152
+ await state.set_state(AdminStates.create_event_segment)
153
+
154
+ # Получаем все доступные сегменты
155
+ from ..handlers.handlers import get_global_var
156
+ supabase_client = get_global_var("supabase_client")
157
+ segments = await supabase_client.get_all_segments()
158
+
159
+ # Создаем клавиатуру с сегментами
160
+ keyboard = []
161
+ keyboard.append([InlineKeyboardButton(text="📢 Отправить всем", callback_data="segment:all")])
162
+ if segments:
163
+ for i in range(0, len(segments), 2):
164
+ row = [InlineKeyboardButton(text=f"👥 {segments[i]}", callback_data=f"segment:{segments[i]}")]
165
+ if i + 1 < len(segments):
166
+ row.append(InlineKeyboardButton(text=f"👥 {segments[i+1]}", callback_data=f"segment:{segments[i+1]}"))
167
+ keyboard.append(row)
168
+
169
+ markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
170
+ await callback_query.message.edit_text(
171
+ f"✅ Время: **Сейчас**\n\n"
172
+ f"👥 Выберите сегмент пользователей для отправки:\n"
173
+ f"_(Найдено сегментов: {len(segments)})_",
174
+ reply_markup=markup,
175
+ parse_mode="Markdown"
176
+ )
177
+
178
+ else: # scheduled
179
+ await state.set_state(AdminStates.create_event_date)
180
+ # Показываем календарь для выбора даты
181
+ calendar = SimpleCalendar(locale="ru", today_btn="Сегодня", cancel_btn="Отмена")
182
+ # Ограничиваем выбор датами от вчера до +12 месяцев (чтобы сегодня был доступен)
183
+ calendar.set_dates_range(
184
+ datetime.now() + relativedelta(days=-1),
185
+ datetime.now() + relativedelta(months=+12),
186
+ )
187
+ calendar_markup = await calendar.start_calendar()
188
+
189
+ await callback_query.message.edit_text(
190
+ "📅 Выберите дату отправки:",
191
+ reply_markup=calendar_markup,
192
+ parse_mode="Markdown"
193
+ )
194
+
195
+
196
+ @admin_events_router.callback_query(
197
+ SimpleCalendarCallback.filter(), AdminStates.create_event_date
198
+ )
199
+ async def process_event_date(
200
+ callback_query: CallbackQuery, callback_data: dict, state: FSMContext
201
+ ):
202
+ """Обработка выбора даты"""
203
+ calendar = SimpleCalendar(locale="ru", cancel_btn="Отмена", today_btn="Сегодня")
204
+
205
+ # Ограничиваем выбор датами от вчера до +12 месяцев (чтобы сегодня был доступен)
206
+ calendar.set_dates_range(
207
+ datetime.now() + relativedelta(days=-1),
208
+ datetime.now() + relativedelta(months=+12),
209
+ )
210
+ selected, date = await calendar.process_selection(callback_query, callback_data)
211
+
212
+ if selected == "cancel":
213
+ # Нажата кнопка "Отмена"
214
+ await state.clear()
215
+ await callback_query.message.edit_text(
216
+ "❌ Создание события отменено", parse_mode="Markdown"
217
+ )
218
+ elif selected:
219
+ # Дата выбрана успешно (True или обычный выбор)
220
+ await state.update_data(event_date=date.strftime("%Y-%m-%d"))
221
+ await state.set_state(AdminStates.create_event_time)
222
+
223
+ await callback_query.message.edit_text(
224
+ f"✅ Дата: **{date.strftime('%d.%m.%Y')}**\n\n"
225
+ "⏰ Введите время отправки в формате ЧЧ:ММ\n"
226
+ "_(Например: 14:30)_",
227
+ parse_mode="Markdown",
228
+ )
229
+ # Если selected is False/None - это навигация по календарю, ничего не делаем
230
+ # Календарь сам обновится при навигации
231
+
232
+
233
+ @admin_events_router.message(AdminStates.create_event_time)
234
+ async def process_event_time(message: Message, state: FSMContext):
235
+ """Обработка времени события"""
236
+ time_str = message.text.strip()
237
+
238
+ # Валидация формата времени
239
+ try:
240
+ datetime.strptime(time_str, "%H:%M").time()
241
+ except ValueError:
242
+ await message.answer(
243
+ "❌ Неверный формат времени. Используйте формат HH:MM\n"
244
+ "_(Например: 14:30)_",
245
+ parse_mode="Markdown",
246
+ )
247
+ return
248
+
249
+ # Сохраняем время
250
+ await state.update_data(event_time=time_str)
251
+ await state.set_state(AdminStates.create_event_segment)
252
+
253
+ # Получаем все доступные сегменты
254
+ from ..handlers.handlers import get_global_var
255
+
256
+ supabase_client = get_global_var("supabase_client")
257
+
258
+ segments = await supabase_client.get_all_segments()
259
+
260
+ # Создаем клавиатуру с сегментами
261
+ keyboard = []
262
+
263
+ # Большая кнопка "Отправить всем" на два столбца
264
+ keyboard.append(
265
+ [InlineKeyboardButton(text="📢 Отправить всем", callback_data="segment:all")]
266
+ )
267
+
268
+ # Кнопки сегментов (по 2 в ряд)
269
+ if segments:
270
+ for i in range(0, len(segments), 2):
271
+ row = []
272
+ row.append(
273
+ InlineKeyboardButton(
274
+ text=f"👥 {segments[i]}", callback_data=f"segment:{segments[i]}"
275
+ )
276
+ )
277
+ if i + 1 < len(segments):
278
+ row.append(
279
+ InlineKeyboardButton(
280
+ text=f"👥 {segments[i+1]}",
281
+ callback_data=f"segment:{segments[i+1]}",
282
+ )
283
+ )
284
+ keyboard.append(row)
285
+
286
+ markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
287
+
288
+ await message.answer(
289
+ f"✅ Время: **{time_str}**\n\n"
290
+ f"👥 Выберите сегмент пользователей для отправки:\n"
291
+ f"_(Найдено сегментов: {len(segments)})_",
292
+ reply_markup=markup,
293
+ parse_mode="Markdown",
294
+ )
295
+
296
+
297
+ @admin_events_router.callback_query(
298
+ F.data.startswith("segment:"), AdminStates.create_event_segment
299
+ )
300
+ async def process_event_segment(callback_query: CallbackQuery, state: FSMContext):
301
+ """Обработка выбора сегмента"""
302
+ segment_data = callback_query.data.split(":", 1)[1]
303
+
304
+ # segment_data = "all" или название сегмента
305
+ segment_name = None if segment_data == "all" else segment_data
306
+ segment_display = "Все пользователи" if segment_data == "all" else segment_data
307
+
308
+ # Сохраняем сегмент
309
+ await state.update_data(segment=segment_name, segment_display=segment_display)
310
+ await state.set_state(AdminStates.create_event_message)
311
+
312
+ await callback_query.message.edit_text(
313
+ f"✅ Сегмент: **{segment_display}**\n\n"
314
+ "💬 **Введите сообщение для пользователей**\n\n"
315
+ "📸 _Вы можете прикрепить к сообщению **фото или видео** — они будут отправлены пользователям в том же порядке_\n\n"
316
+ "📄 _Если нужно добавить **PDF или другие документы**, вы сможете это сделать на следующем шаге_",
317
+ parse_mode="Markdown",
318
+ )
319
+
320
+
321
+ @admin_events_router.message(
322
+ AdminStates.create_event_message,
323
+ F.media_group_id,
324
+ F.content_type.in_({"photo", "video"}),
325
+ )
326
+ @media_group_handler
327
+ async def handle_album(messages: list[Message], state: FSMContext):
328
+ """Обработка альбома фотографий/видео"""
329
+ if not messages:
330
+ return
331
+
332
+ # Берем текст из первого сообщения с подписью
333
+ event_message = next((msg.caption for msg in messages if msg.caption), None)
334
+ if not event_message:
335
+ await messages[0].answer(
336
+ "❌ **Добавьте подпись к альбому**\n\n"
337
+ "💡 _Отправьте альбом заново с текстом сообщения в подписи к любой фотографии_",
338
+ parse_mode="Markdown",
339
+ )
340
+ return
341
+
342
+ # Сохраняем сообщение
343
+ await state.update_data(event_message=event_message)
344
+
345
+ # Показываем сообщение о начале загрузки
346
+ await messages[0].answer(
347
+ "📸 **Загружаю файлы...**\n\n" "💡 _Дождитесь загрузки всех файлов из альбома_",
348
+ parse_mode="Markdown",
349
+ )
350
+
351
+ # Сохраняем все файлы
352
+ from ..handlers.handlers import get_global_var
353
+
354
+ bot = get_global_var("bot")
355
+ ensure_temp_dir()
356
+
357
+ data = await state.get_data()
358
+ files = data.get("files", [])
359
+
360
+ for i, message in enumerate(messages, 1):
361
+ try:
362
+ if message.photo:
363
+ photo = message.photo[-1]
364
+ file = await bot.get_file(photo.file_id)
365
+ file_name = f"photo_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{i}.jpg"
366
+ file_path = os.path.join(TEMP_DIR, file_name)
367
+ await bot.download_file(file.file_path, file_path)
368
+
369
+ files.append(
370
+ {
371
+ "type": "photo",
372
+ "file_path": file_path,
373
+ "name": file_name,
374
+ "stage": "with_message",
375
+ "has_caption": bool(message.caption),
376
+ "order": i, # Сохраняем порядок в альбоме
377
+ }
378
+ )
379
+
380
+ elif message.video:
381
+ file = await bot.get_file(message.video.file_id)
382
+ file_name = message.video.file_name or f"{message.video.file_id}.mp4"
383
+ file_path = os.path.join(TEMP_DIR, file_name)
384
+ await bot.download_file(file.file_path, file_path)
385
+
386
+ files.append(
387
+ {
388
+ "type": "video",
389
+ "file_path": file_path,
390
+ "name": file_name,
391
+ "stage": "with_message",
392
+ "has_caption": bool(message.caption),
393
+ "order": i, # Сохраняем порядок в альбоме
394
+ }
395
+ )
396
+
397
+ # Показываем прогресс каждые 5 файлов
398
+ if i % 5 == 0:
399
+ await messages[0].answer(
400
+ f"📸 Загружено файлов: {i}/{len(messages)}", parse_mode="Markdown"
401
+ )
402
+
403
+ except Exception as e:
404
+ logger.error(f"❌ Ошибка загрузки файла {i}: {e}")
405
+ continue
406
+
407
+ # Сохраняем файлы
408
+ await state.update_data(files=files)
409
+
410
+ # Переходим к следующему этапу
411
+ await state.set_state(AdminStates.create_event_files)
412
+
413
+ keyboard = InlineKeyboardMarkup(
414
+ inline_keyboard=[
415
+ [
416
+ InlineKeyboardButton(
417
+ text="➡️ Продолжить без файлов", callback_data="files:skip"
418
+ )
419
+ ]
420
+ ]
421
+ )
422
+
423
+ await messages[0].answer(
424
+ f"✅ **Сообщение и {len(files)} файлов сохранены!**\n\n"
425
+ "📎 **Дополнительные файлы**\n\n"
426
+ "Теперь вы можете отправить:\n"
427
+ "📄 PDF документы\n"
428
+ "📁 Файлы любых форматов\n"
429
+ "🎥 Дополнительные видео\n"
430
+ "🖼 Дополнительные фото\n\n"
431
+ "💡 _Можно отправить несколько файлов по очереди_\n\n"
432
+ "Или нажмите кнопку, если дополнительных файлов нет:",
433
+ reply_markup=keyboard,
434
+ parse_mode="Markdown",
435
+ )
436
+
437
+
438
+ @admin_events_router.message(
439
+ AdminStates.create_event_message, F.text | F.photo | F.video
440
+ )
441
+ async def process_event_message(message: Message, state: FSMContext):
442
+ """Обработка одиночного сообщения с текстом/фото/видео"""
443
+ # Если это часть альбома - пропускаем, его обработает другой handler
444
+ if message.media_group_id:
445
+ return
446
+
447
+ event_message = message.text or message.caption or ""
448
+
449
+ # Проверяем текст
450
+ if not event_message.strip():
451
+ await message.answer("❌ Сообщение не может быть пустым. Попробуйте еще раз:")
452
+ return
453
+
454
+ # Сохраняем сообщение
455
+ await state.update_data(event_message=event_message)
456
+
457
+ # Если есть медиа, сохраняем его
458
+ data = await state.get_data()
459
+ files = data.get("files", [])
460
+
461
+ if message.photo or message.video:
462
+ import os
463
+
464
+ from ..handlers.handlers import get_global_var
465
+
466
+ bot = get_global_var("bot")
467
+
468
+ # Создаем временную папку
469
+ ensure_temp_dir()
470
+
471
+ if message.photo:
472
+ # Скачиваем фото
473
+ photo = message.photo[-1] # Берем самое большое фото
474
+ file = await bot.get_file(photo.file_id)
475
+ file_name = f"photo_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jpg"
476
+ file_path = os.path.join(TEMP_DIR, file_name)
477
+ await bot.download_file(file.file_path, file_path)
478
+
479
+ files.append(
480
+ {
481
+ "type": "photo",
482
+ "file_path": file_path,
483
+ "name": file_name,
484
+ "stage": "with_message",
485
+ "has_caption": bool(message.caption),
486
+ }
487
+ )
488
+ logger.info(f"Фото сохранено: {file_path} (with_message)")
489
+
490
+ elif message.video:
491
+ # Скачиваем видео
492
+ file = await bot.get_file(message.video.file_id)
493
+ file_name = message.video.file_name or f"{message.video.file_id}.mp4"
494
+ file_path = os.path.join(TEMP_DIR, file_name)
495
+ await bot.download_file(file.file_path, file_path)
496
+
497
+ files.append(
498
+ {
499
+ "type": "video",
500
+ "file_path": file_path,
501
+ "name": file_name,
502
+ "stage": "with_message",
503
+ "has_caption": bool(message.caption),
504
+ }
505
+ )
506
+ logger.info(f"Видео сохранено: {file_path} (with_message)")
507
+
508
+ await state.update_data(files=files)
509
+
510
+ # Переходим к добавлению файлов
511
+ await state.set_state(AdminStates.create_event_files)
512
+
513
+ keyboard = InlineKeyboardMarkup(
514
+ inline_keyboard=[
515
+ [
516
+ InlineKeyboardButton(
517
+ text="➡️ Продолжить без файлов", callback_data="files:skip"
518
+ )
519
+ ]
520
+ ]
521
+ )
522
+
523
+ await message.answer(
524
+ "✅ **Сообщение сохранено!**\n\n"
525
+ "📎 **Дополнительные файлы**\n\n"
526
+ "Теперь вы можете отправить:\n"
527
+ "📄 PDF документы\n"
528
+ "📁 Файлы любых форматов\n"
529
+ "🎥 Дополнительные видео\n"
530
+ "🖼 Дополнительные фото\n\n"
531
+ "💡 _Можно отправить несколько файлов по очереди_\n\n"
532
+ "Или нажмите кнопку, если дополнительных файлов нет:",
533
+ reply_markup=keyboard,
534
+ parse_mode="Markdown",
535
+ )
536
+
537
+
538
+ @admin_events_router.message(
539
+ AdminStates.create_event_files, F.document | F.photo | F.video
540
+ )
541
+ async def process_event_files(message: Message, state: FSMContext):
542
+ """Обработка файлов для события"""
543
+ import os
544
+
545
+ from ..handlers.handlers import get_global_var
546
+
547
+ data = await state.get_data()
548
+ files = data.get("files", [])
549
+ bot = get_global_var("bot")
550
+
551
+ # Создаем временную папку
552
+ ensure_temp_dir()
553
+
554
+ # Скачиваем и добавляем файл в список
555
+ if message.document:
556
+ file = await bot.get_file(message.document.file_id)
557
+ file_path = os.path.join(TEMP_DIR, message.document.file_name)
558
+ await bot.download_file(file.file_path, file_path)
559
+
560
+ files.append(
561
+ {
562
+ "type": "document",
563
+ "file_path": file_path,
564
+ "name": message.document.file_name,
565
+ "stage": "after_message",
566
+ }
567
+ )
568
+ logger.info(f"Документ сохранен: {file_path} (after_message)")
569
+
570
+ elif message.photo:
571
+ photo = message.photo[-1]
572
+ file = await bot.get_file(photo.file_id)
573
+ file_name = f"photo_{datetime.now().strftime('%H%M%S')}.jpg"
574
+ file_path = os.path.join(TEMP_DIR, file_name)
575
+ await bot.download_file(file.file_path, file_path)
576
+
577
+ files.append(
578
+ {
579
+ "type": "photo",
580
+ "file_path": file_path,
581
+ "name": file_name,
582
+ "stage": "after_message",
583
+ }
584
+ )
585
+ logger.info(f"Фото сохранено: {file_path} (after_message)")
586
+
587
+ elif message.video:
588
+ file = await bot.get_file(message.video.file_id)
589
+ file_name = (
590
+ message.video.file_name or f"{message.video.file_id}.mp4"
591
+ ) # Используем оригинальное имя или file_id
592
+ file_path = os.path.join(TEMP_DIR, file_name)
593
+ await bot.download_file(file.file_path, file_path)
594
+
595
+ files.append(
596
+ {
597
+ "type": "video",
598
+ "file_path": file_path,
599
+ "name": file_name,
600
+ "stage": "after_message",
601
+ }
602
+ )
603
+ logger.info(f"Видео сохранено: {file_path} (after_message)")
604
+
605
+ await state.update_data(files=files)
606
+
607
+ # Кнопка для завершения добавления файлов
608
+ keyboard = InlineKeyboardMarkup(
609
+ inline_keyboard=[
610
+ [
611
+ InlineKeyboardButton(
612
+ text="✅ Завершить добавление файлов", callback_data="files:done"
613
+ )
614
+ ]
615
+ ]
616
+ )
617
+
618
+ await message.answer(
619
+ f"✅ Файл добавлен (всего: {len(files)})\n\n"
620
+ "Отправьте еще файлы или нажмите кнопку для завершения:",
621
+ reply_markup=keyboard,
622
+ )
623
+
624
+
625
+ @admin_events_router.callback_query(
626
+ F.data.startswith("files:"), AdminStates.create_event_files
627
+ )
628
+ async def process_files_action(callback_query: CallbackQuery, state: FSMContext):
629
+ """Обработка действий с файлами"""
630
+ action = callback_query.data.split(":", 1)[1]
631
+
632
+ data = await state.get_data()
633
+ files = data.get("files", [])
634
+
635
+ if action == "skip" and not files:
636
+ # Если файлов нет и нажали "Продолжить без файлов" - очищаем
637
+ files = []
638
+ await state.update_data(files=files)
639
+ elif action == "skip":
640
+ # Если файлы уже есть - оставляем их
641
+ logger.info(f"Продолжаем с {len(files)} существующими файлами")
642
+
643
+ # Переход к подтверждению
644
+ await state.set_state(AdminStates.create_event_confirm)
645
+
646
+ # Формируем дату и время для отображения
647
+ is_immediate = data.get("is_immediate", False)
648
+
649
+ if is_immediate:
650
+ time_display = "Прямо сейчас 🔥"
651
+ else:
652
+ event_date = data.get("event_date")
653
+ event_time = data.get("event_time")
654
+ naive_datetime = datetime.strptime(f"{event_date} {event_time}", "%Y-%m-%d %H:%M")
655
+ moscow_datetime = MOSCOW_TZ.localize(naive_datetime)
656
+ time_display = f"{moscow_datetime.strftime('%d.%m.%Y %H:%M')} (МСК)"
657
+
658
+ # Отправляем сообщение с подтверждением
659
+ summary = (
660
+ f"📋 **Подтверждение создания события**\n\n"
661
+ f"📝 Название: **{data.get('event_name')}**\n"
662
+ f"📅 Время запуска: **{time_display}**\n"
663
+ f"👥 Сегмент: **{data.get('segment_display')}**\n"
664
+ f"📎 Файлов: **{len(files)}**\n\n"
665
+ "Подтвердите создание события:"
666
+ )
667
+
668
+ keyboard = InlineKeyboardMarkup(
669
+ inline_keyboard=[
670
+ [
671
+ InlineKeyboardButton(text="✅ Создать", callback_data="confirm:yes"),
672
+ InlineKeyboardButton(text="❌ Отменить", callback_data="confirm:no"),
673
+ ],
674
+ [InlineKeyboardButton(text="👁 Предпросмотр", callback_data="preview:show")],
675
+ ]
676
+ )
677
+
678
+ await callback_query.message.edit_text(
679
+ summary, reply_markup=keyboard, parse_mode="Markdown"
680
+ )
681
+
682
+
683
+ @admin_events_router.callback_query(
684
+ F.data == "preview:show", AdminStates.create_event_confirm
685
+ )
686
+ async def show_event_preview(callback_query: CallbackQuery, state: FSMContext):
687
+ """Показываем предпросмотр сообщения"""
688
+ from aiogram.types import FSInputFile, InputMediaPhoto, InputMediaVideo
689
+
690
+ # Получаем данные
691
+ data = await state.get_data()
692
+ files = data.get("files", [])
693
+ logger.info(f"Предпросмотр: получено {len(files)} файлов из состояния")
694
+
695
+ # Удаляем сообщение с кнопкой предпросмотра
696
+ await callback_query.message.delete()
697
+
698
+ # Анализируем файлы
699
+ files_with_msg = [f for f in files if f.get("stage") == "with_message"]
700
+ files_after = [f for f in files if f.get("stage") == "after_message"]
701
+ logger.info(
702
+ f"Предпросмотр: {len(files_with_msg)} файлов с сообщением, {len(files_after)} дополнительных файлов"
703
+ )
704
+
705
+ # 1. Отправляем сообщение с прикрепленными файлами
706
+ if files_with_msg:
707
+ media_group = []
708
+ first_file = True
709
+
710
+ # Сортируем файлы по порядку
711
+ sorted_files = sorted(files_with_msg, key=lambda x: x.get("order", 0))
712
+
713
+ # Добавляем файлы в том порядке, в котором они были загружены
714
+ for file_info in sorted_files:
715
+ logger.info(
716
+ f"Добавляем в медиа-группу: {file_info['type']} файл {file_info['file_path']}"
717
+ )
718
+ try:
719
+ if file_info["type"] == "photo":
720
+ media = InputMediaPhoto(
721
+ media=FSInputFile(file_info["file_path"]),
722
+ caption=data.get("event_message") if first_file else None,
723
+ parse_mode="Markdown" if first_file else None,
724
+ )
725
+ media_group.append(media)
726
+ first_file = False
727
+ elif file_info["type"] == "video":
728
+ media = InputMediaVideo(
729
+ media=FSInputFile(file_info["file_path"]),
730
+ caption=data.get("event_message") if first_file else None,
731
+ parse_mode="Markdown" if first_file else None,
732
+ )
733
+ media_group.append(media)
734
+ first_file = False
735
+ logger.info("✅ Файл успешно добавлен в медиа-группу")
736
+ except Exception as e:
737
+ logger.error(f"❌ Ошибка добавления файла в медиа-группу: {e}")
738
+
739
+ if media_group:
740
+ try:
741
+ from ..handlers.handlers import get_global_var
742
+
743
+ bot = get_global_var("bot")
744
+ await bot.send_media_group(
745
+ chat_id=callback_query.message.chat.id, media=media_group
746
+ )
747
+ logger.info(f"✅ Отправлена медиа-группа из {len(media_group)} файлов")
748
+ except Exception as e:
749
+ logger.error(f"❌ Ошибка отправки медиа-группы: {e}")
750
+ # Если не удалось отправить группой, отправляем по одному
751
+ first_file = True
752
+ for media in media_group:
753
+ try:
754
+ if isinstance(media, InputMediaPhoto):
755
+ await callback_query.message.answer_photo(
756
+ photo=media.media,
757
+ caption=(
758
+ data.get("event_message") if first_file else None
759
+ ),
760
+ parse_mode="Markdown" if first_file else None,
761
+ )
762
+ elif isinstance(media, InputMediaVideo):
763
+ await callback_query.message.answer_video(
764
+ video=media.media,
765
+ caption=(
766
+ data.get("event_message") if first_file else None
767
+ ),
768
+ parse_mode="Markdown" if first_file else None,
769
+ )
770
+ first_file = False
771
+ except Exception as e2:
772
+ logger.error(f"❌ Ошибка отправки отдельного файла: {e2}")
773
+ else:
774
+ # Только текст
775
+ await callback_query.message.answer(
776
+ data.get("event_message"), parse_mode="Markdown"
777
+ )
778
+
779
+ # 2. Отправляем дополнительные файлы
780
+ for file_info in files_after:
781
+ if file_info["type"] == "document":
782
+ await callback_query.message.answer_document(
783
+ FSInputFile(file_info["file_path"])
784
+ )
785
+ elif file_info["type"] == "photo":
786
+ await callback_query.message.answer_photo(
787
+ FSInputFile(file_info["file_path"])
788
+ )
789
+ elif file_info["type"] == "video":
790
+ await callback_query.message.answer_video(
791
+ FSInputFile(file_info["file_path"])
792
+ )
793
+
794
+ # 3. Отправляем сообщение с подтверждением (такое же как было)
795
+ is_immediate = data.get("is_immediate", False)
796
+
797
+ if is_immediate:
798
+ time_display = "Прямо сейчас 🔥"
799
+ else:
800
+ event_date = data.get("event_date")
801
+ event_time = data.get("event_time")
802
+ naive_datetime = datetime.strptime(f"{event_date} {event_time}", "%Y-%m-%d %H:%M")
803
+ moscow_datetime = MOSCOW_TZ.localize(naive_datetime)
804
+ time_display = f"{moscow_datetime.strftime('%d.%m.%Y %H:%M')} (МСК)"
805
+
806
+ summary = (
807
+ f"📋 **Подтверждение создания события**\n\n"
808
+ f"📝 Название: **{data.get('event_name')}**\n"
809
+ f"📅 Время запуска: **{time_display}**\n"
810
+ f"👥 Сегмент: **{data.get('segment_display')}**\n"
811
+ f"📎 Файлов: **{len(files)}**\n\n"
812
+ "Подтвердите создание события:"
813
+ )
814
+
815
+ keyboard = InlineKeyboardMarkup(
816
+ inline_keyboard=[
817
+ [
818
+ InlineKeyboardButton(text="✅ Создать", callback_data="confirm:yes"),
819
+ InlineKeyboardButton(text="❌ Отменить", callback_data="confirm:no"),
820
+ ],
821
+ [InlineKeyboardButton(text="👁 Предпросмотр", callback_data="preview:show")],
822
+ ]
823
+ )
824
+
825
+ await callback_query.message.answer(
826
+ summary, reply_markup=keyboard, parse_mode="Markdown"
827
+ )
828
+
829
+
830
+ @admin_events_router.callback_query(
831
+ F.data.startswith("confirm:"), AdminStates.create_event_confirm
832
+ )
833
+ async def process_event_confirmation(callback_query: CallbackQuery, state: FSMContext):
834
+ """Обработка подтверждения создания события"""
835
+ action = callback_query.data.split(":", 1)[1]
836
+
837
+ if action == "no":
838
+ # Очищаем временные файлы
839
+ await cleanup_temp_files(state)
840
+ # Очищаем состояние
841
+ await state.clear()
842
+ await callback_query.message.edit_text(
843
+ "❌ Создание события отменено", parse_mode="Markdown"
844
+ )
845
+ return
846
+
847
+ # Получаем данные события
848
+ data = await state.get_data()
849
+ is_immediate = data.get("is_immediate", False)
850
+ files = data.get("files", [])
851
+
852
+ from ..handlers.handlers import get_global_var
853
+ from aiogram.types import FSInputFile, InputMediaPhoto, InputMediaVideo
854
+ bot = get_global_var("bot")
855
+ supabase_client = get_global_var("supabase_client")
856
+
857
+ if is_immediate:
858
+ # Для немедленной отправки - сразу рассылаем сообщения
859
+ try:
860
+ # Показываем сообщение о начале рассылки
861
+ await callback_query.message.edit_text(
862
+ "📤 **Выполняется рассылка...**",
863
+ parse_mode="Markdown"
864
+ )
865
+
866
+ # Получаем список пользователей для рассылки
867
+ segment = data.get("segment")
868
+ users = await supabase_client.get_users_by_segment(segment)
869
+ total_users = len(users)
870
+ sent_count = 0
871
+ failed_count = 0
872
+
873
+ # Преобразуем результат в нужный формат
874
+ user_ids = [user["telegram_id"] for user in users] # используем telegram_id вместо user_id
875
+
876
+ # Группируем файлы по стадиям
877
+ files_with_msg = [f for f in files if f.get("stage") == "with_message"]
878
+ files_after = [f for f in files if f.get("stage") == "after_message"]
879
+
880
+ # Отправляем сообщения каждому пользователю
881
+ for user in users:
882
+ user_id = user["telegram_id"]
883
+ try:
884
+ # 1. Отправляем основное сообщение с медиа (если есть)
885
+ if files_with_msg:
886
+ sorted_files = sorted(files_with_msg, key=lambda x: x.get("order", 0))
887
+
888
+ # Если только один файл - отправляем как обычный файл с caption
889
+ if len(sorted_files) == 1:
890
+ file_info = sorted_files[0]
891
+ if file_info["type"] == "photo":
892
+ await bot.send_photo(
893
+ chat_id=user_id,
894
+ photo=FSInputFile(file_info["file_path"]),
895
+ caption=data.get("event_message"),
896
+ parse_mode="Markdown"
897
+ )
898
+ elif file_info["type"] == "video":
899
+ await bot.send_video(
900
+ chat_id=user_id,
901
+ video=FSInputFile(file_info["file_path"]),
902
+ caption=data.get("event_message"),
903
+ parse_mode="Markdown"
904
+ )
905
+ else:
906
+ # Если несколько файлов - используем media_group
907
+ media_group = []
908
+ first_file = True
909
+
910
+ for file_info in sorted_files:
911
+ if file_info["type"] == "photo":
912
+ media = InputMediaPhoto(
913
+ media=FSInputFile(file_info["file_path"]),
914
+ caption=data.get("event_message") if first_file else None,
915
+ parse_mode="Markdown" if first_file else None,
916
+ )
917
+ media_group.append(media)
918
+ elif file_info["type"] == "video":
919
+ media = InputMediaVideo(
920
+ media=FSInputFile(file_info["file_path"]),
921
+ caption=data.get("event_message") if first_file else None,
922
+ parse_mode="Markdown" if first_file else None,
923
+ )
924
+ media_group.append(media)
925
+ first_file = False
926
+
927
+ if media_group:
928
+ await bot.send_media_group(chat_id=user_id, media=media_group)
929
+ else:
930
+ # Только текст
931
+ await bot.send_message(
932
+ chat_id=user_id,
933
+ text=data.get("event_message"),
934
+ parse_mode="Markdown"
935
+ )
936
+
937
+ # 2. Отправляем дополнительные файлы
938
+ for file_info in files_after:
939
+ if file_info["type"] == "document":
940
+ await bot.send_document(
941
+ chat_id=user_id,
942
+ document=FSInputFile(file_info["file_path"])
943
+ )
944
+ elif file_info["type"] == "photo":
945
+ await bot.send_photo(
946
+ chat_id=user_id,
947
+ photo=FSInputFile(file_info["file_path"])
948
+ )
949
+ elif file_info["type"] == "video":
950
+ await bot.send_video(
951
+ chat_id=user_id,
952
+ video=FSInputFile(file_info["file_path"])
953
+ )
954
+
955
+ sent_count += 1
956
+
957
+ except Exception as e:
958
+ logger.error(f"❌ Ошибка отправки пользователю {user_id}: {e}")
959
+ failed_count += 1
960
+
961
+ # Сохраняем событие в БД
962
+ event_status = "success" if failed_count == 0 else "partial_success"
963
+ await supabase_client.save_admin_event(
964
+ event_name=data.get("event_name"),
965
+ event_data={
966
+ "segment": segment,
967
+ "total_users": total_users,
968
+ "sent_success": sent_count,
969
+ "failed_count": failed_count,
970
+ "type": "immediate_event",
971
+ "admin_id": callback_query.from_user.id,
972
+ "execution_status": event_status,
973
+ "completed_at": datetime.now(pytz.UTC).isoformat()
974
+ },
975
+ scheduled_datetime=datetime.now(pytz.UTC)
976
+ )
977
+
978
+ # Показываем итоговое сообщение
979
+ status = "✅" if failed_count == 0 else "⚠️"
980
+
981
+ await callback_query.message.edit_text(
982
+ f"{status} **Админское событие выполнено**\n\n"
983
+ f"📝 Название: **{data.get('event_name')}**\n"
984
+ f"👥 Сегмент: **{data.get('segment_display')}**\n\n"
985
+ f"📊 Результат:\n"
986
+ f"• Доставлено: **{sent_count}**\n"
987
+ f"• Не доставлено: **{failed_count}**",
988
+ parse_mode="Markdown"
989
+ )
990
+
991
+ except Exception as e:
992
+ logger.error(f"❌ Ошибка массовой рассылки: {e}")
993
+
994
+ # Сохраняем ошибку события в БД
995
+ await supabase_client.save_admin_event(
996
+ event_name=data.get("event_name"),
997
+ event_data={
998
+ "segment": segment,
999
+ "error": str(e),
1000
+ "type": "immediate_event",
1001
+ "admin_id": callback_query.from_user.id,
1002
+ "execution_status": "error",
1003
+ "completed_at": datetime.now(pytz.UTC).isoformat()
1004
+ },
1005
+ scheduled_datetime=datetime.now(pytz.UTC)
1006
+ )
1007
+
1008
+ await callback_query.message.edit_text(
1009
+ f"❌ **Ошибка выполнения админского события**\n\n"
1010
+ f"📝 Название: **{data.get('event_name')}**\n"
1011
+ f"👥 Сегмент: **{data.get('segment_display')}**\n\n"
1012
+ f"Произошла техническая ошибка. Попробуйте позже.",
1013
+ parse_mode="Markdown"
1014
+ )
1015
+
1016
+ else:
1017
+ # Для отложенного события - стандартная логика
1018
+ try:
1019
+ # Формируем datetime для планирования
1020
+ event_date = data.get("event_date")
1021
+ event_time = data.get("event_time")
1022
+ naive_datetime = datetime.strptime(f"{event_date} {event_time}", "%Y-%m-%d %H:%M")
1023
+ moscow_datetime = MOSCOW_TZ.localize(naive_datetime)
1024
+ utc_datetime = moscow_datetime.astimezone(pytz.UTC)
1025
+
1026
+ # Создаем событие
1027
+ event = await supabase_client.save_admin_event(
1028
+ event_name=data.get("event_name"),
1029
+ event_data={
1030
+ "segment": data.get("segment"),
1031
+ "message": data.get("event_message"),
1032
+ "files": [],
1033
+ },
1034
+ scheduled_datetime=utc_datetime,
1035
+ )
1036
+ event_id = event["id"]
1037
+
1038
+ # Загружаем файлы в Storage
1039
+ uploaded_files = []
1040
+ for file_info in files:
1041
+ try:
1042
+ with open(file_info["file_path"], "rb") as f:
1043
+ file_bytes = f.read()
1044
+ file_id = generate_file_id()
1045
+ storage_info = await supabase_client.upload_event_file(
1046
+ event_id=event_id,
1047
+ file_data=file_bytes,
1048
+ original_name=file_info["name"],
1049
+ file_id=file_id,
1050
+ )
1051
+ uploaded_files.append({
1052
+ "type": file_info["type"],
1053
+ "storage_path": storage_info["storage_path"],
1054
+ "original_name": file_info["name"],
1055
+ "stage": file_info["stage"],
1056
+ "has_caption": file_info.get("has_caption", False),
1057
+ "order": file_info.get("order", 0),
1058
+ })
1059
+ except Exception as e:
1060
+ logger.error(f"❌ Ошибка загрузки файла {file_info['name']}: {e}")
1061
+ await supabase_client.delete_event_files(event_id)
1062
+ raise
1063
+
1064
+ # Обновляем событие с информацией о файлах
1065
+ event_data = {
1066
+ "segment": data.get("segment"),
1067
+ "message": data.get("event_message"),
1068
+ "files": uploaded_files,
1069
+ }
1070
+ supabase_client.client.table("scheduled_events").update(
1071
+ {"event_data": json.dumps(event_data, ensure_ascii=False)}
1072
+ ).eq("id", event_id).execute()
1073
+
1074
+ except Exception as e:
1075
+ logger.error(f"❌ Ошибка создания отложенного события: {e}")
1076
+ raise
1077
+ is_immediate = data.get("is_immediate", False)
1078
+
1079
+ if is_immediate:
1080
+ time_display = "🔥 Прямо сейчас"
1081
+ else:
1082
+ time_display = f"{moscow_datetime.strftime('%d.%m.%Y %H:%M')} (МСК)"
1083
+
1084
+ await callback_query.message.edit_text(
1085
+ f"✅ **Событие успешно создано!**\n\n"
1086
+ f"📝 Название: `{data.get('event_name')}`\n"
1087
+ f"📅 Время запуска: **{time_display}**\n"
1088
+ f"👥 Сегмент: **{data.get('segment_display')}**\n\n"
1089
+ f"💡 _Нажмите на название для копирования_",
1090
+ parse_mode="Markdown",
1091
+ )
1092
+
1093
+ # Очищаем временные файлы и состояние
1094
+ await cleanup_temp_files(state)
1095
+ await state.set_state(AdminStates.admin_mode)
1096
+
1097
+
1098
+ @admin_events_router.message(Command(commands=["список_событий", "list_events"]))
1099
+ async def list_events_command(message: Message, state: FSMContext):
1100
+ """Просмотр всех запланированных событий"""
1101
+ from ..handlers.handlers import get_global_var
1102
+
1103
+ admin_manager = get_global_var("admin_manager")
1104
+ supabase_client = get_global_var("supabase_client")
1105
+
1106
+ if not admin_manager.is_admin(message.from_user.id):
1107
+ return
1108
+
1109
+ try:
1110
+ # Получаем все pending события (незавершенные и неотмененные)
1111
+ events = await supabase_client.get_admin_events(status="pending")
1112
+
1113
+ if not events:
1114
+ await message.answer(
1115
+ "📋 **Нет активных событий**\n\n"
1116
+ "Используйте `/create_event` для создания нового события",
1117
+ parse_mode="Markdown",
1118
+ )
1119
+ return
1120
+
1121
+ # Формируем список событий в красивом формате
1122
+ text_parts = [f"📋 **Активные события** ({len(events)})\n"]
1123
+
1124
+ for idx, event in enumerate(events, 1):
1125
+ event_name = event["event_type"]
1126
+
1127
+ # Конвертируем UTC в московское время для отображения
1128
+ utc_time = datetime.fromisoformat(
1129
+ event["scheduled_at"].replace("Z", "+00:00")
1130
+ )
1131
+ moscow_time = utc_time.astimezone(MOSCOW_TZ)
1132
+
1133
+ # Красивый формат с эмодзи и структурой
1134
+ text_parts.append(
1135
+ f"📌 **{idx}.** `{event_name}`\n"
1136
+ f" 🕐 {moscow_time.strftime('%d.%m.%Y в %H:%M')} МСК\n"
1137
+ )
1138
+
1139
+ text_parts.append(
1140
+ "━━━━━━━━━━━━━━━━━━━━\n"
1141
+ "💡 _Нажмите на название для копирования_\n"
1142
+ "🗑️ Удалить: `/delete_event название`"
1143
+ )
1144
+
1145
+ await message.answer("\n".join(text_parts), parse_mode="Markdown")
1146
+
1147
+ except Exception as e:
1148
+ logger.error(f"Ошибка получения событий: {e}")
1149
+ await message.answer(
1150
+ f"❌ Ошибка получения событий:\n`{str(e)}`", parse_mode="Markdown"
1151
+ )
1152
+
1153
+
1154
+ @admin_events_router.message(Command(commands=["удалить_событие", "delete_event"]))
1155
+ async def delete_event_command(message: Message, state: FSMContext):
1156
+ """Удаление события по названию"""
1157
+ from ..handlers.handlers import get_global_var
1158
+
1159
+ admin_manager = get_global_var("admin_manager")
1160
+ supabase_client = get_global_var("supabase_client")
1161
+
1162
+ if not admin_manager.is_admin(message.from_user.id):
1163
+ return
1164
+
1165
+ # Парсим название из команды
1166
+ parts = message.text.split(maxsplit=1)
1167
+ if len(parts) < 2:
1168
+ await message.answer(
1169
+ "❌ Укажите название события:\n"
1170
+ "`/delete_event название`\n\n"
1171
+ "Используйте /list_events для просмотра списка событий",
1172
+ parse_mode="Markdown",
1173
+ )
1174
+ return
1175
+
1176
+ event_name = parts[1].strip()
1177
+
1178
+ try:
1179
+ # Сначала получаем событие чтобы узнать его ID
1180
+ response = (
1181
+ supabase_client.client.table("scheduled_events")
1182
+ .select("id")
1183
+ .eq("event_type", event_name)
1184
+ .eq("event_category", "admin_event")
1185
+ .eq("status", "pending")
1186
+ .execute()
1187
+ )
1188
+
1189
+ if response.data:
1190
+ event_id = response.data[0]["id"]
1191
+
1192
+ # Удаляем файлы из Storage
1193
+ try:
1194
+ await supabase_client.delete_event_files(event_id)
1195
+ logger.info(
1196
+ f"🗑️ Удалены файлы события '{event_name}' (ID: {event_id}) из Storage"
1197
+ )
1198
+ except Exception as e:
1199
+ logger.error(f"❌ Ошибка удаления файлов события из Storage: {e}")
1200
+
1201
+ # Отмечаем событие как отмененное
1202
+ supabase_client.client.table("scheduled_events").update(
1203
+ {"status": "cancelled"}
1204
+ ).eq("id", event_id).execute()
1205
+
1206
+ await message.answer(
1207
+ f"✅ Событие `{event_name}` успешно отменено\n"
1208
+ f"_(файлы удалены из Storage)_",
1209
+ parse_mode="Markdown",
1210
+ )
1211
+ logger.info(f"Отменено событие '{event_name}' (ID: {event_id})")
1212
+ else:
1213
+ await message.answer(
1214
+ f"❌ Активное событие с названием `{event_name}` не найдено\n\n"
1215
+ f"Используйте /list_events для просмотра списка активных событий",
1216
+ parse_mode="Markdown",
1217
+ )
1218
+
1219
+ except Exception as e:
1220
+ logger.error(f"Ошибка удаления события: {e}")
1221
+ await message.answer(
1222
+ f"❌ Ошибка удаления события:\n`{str(e)}`", parse_mode="Markdown"
1223
+ )