smart-bot-factory 0.3.2__py3-none-any.whl → 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of smart-bot-factory might be problematic. Click here for more details.

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