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