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.
- smart_bot_factory/__init__.py +3 -0
- smart_bot_factory/admin/__init__.py +18 -0
- smart_bot_factory/admin/admin_events.py +1223 -0
- smart_bot_factory/admin/admin_logic.py +553 -0
- smart_bot_factory/admin/admin_manager.py +156 -0
- smart_bot_factory/admin/admin_tester.py +157 -0
- smart_bot_factory/admin/timeout_checker.py +547 -0
- smart_bot_factory/aiogram_calendar/__init__.py +14 -0
- smart_bot_factory/aiogram_calendar/common.py +64 -0
- smart_bot_factory/aiogram_calendar/dialog_calendar.py +259 -0
- smart_bot_factory/aiogram_calendar/schemas.py +99 -0
- smart_bot_factory/aiogram_calendar/simple_calendar.py +224 -0
- smart_bot_factory/analytics/analytics_manager.py +414 -0
- smart_bot_factory/cli.py +806 -0
- smart_bot_factory/config.py +258 -0
- smart_bot_factory/configs/growthmed-october-24/prompts/1sales_context.txt +16 -0
- smart_bot_factory/configs/growthmed-october-24/prompts/2product_info.txt +582 -0
- smart_bot_factory/configs/growthmed-october-24/prompts/3objection_handling.txt +66 -0
- smart_bot_factory/configs/growthmed-october-24/prompts/final_instructions.txt +212 -0
- smart_bot_factory/configs/growthmed-october-24/prompts/help_message.txt +28 -0
- smart_bot_factory/configs/growthmed-october-24/prompts/welcome_message.txt +8 -0
- smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064229.txt +818 -0
- smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064335.txt +32 -0
- smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064638.txt +35 -0
- smart_bot_factory/configs/growthmed-october-24/tests/quick_scenarios.yaml +133 -0
- smart_bot_factory/configs/growthmed-october-24/tests/realistic_scenarios.yaml +108 -0
- smart_bot_factory/configs/growthmed-october-24/tests/scenario_examples.yaml +46 -0
- smart_bot_factory/configs/growthmed-october-24/welcome_file/welcome_file_msg.txt +16 -0
- 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
- smart_bot_factory/core/bot_utils.py +1108 -0
- smart_bot_factory/core/conversation_manager.py +653 -0
- smart_bot_factory/core/decorators.py +2464 -0
- smart_bot_factory/core/message_sender.py +729 -0
- smart_bot_factory/core/router.py +347 -0
- smart_bot_factory/core/router_manager.py +218 -0
- smart_bot_factory/core/states.py +27 -0
- smart_bot_factory/creation/__init__.py +7 -0
- smart_bot_factory/creation/bot_builder.py +1093 -0
- smart_bot_factory/creation/bot_testing.py +1122 -0
- smart_bot_factory/dashboard/__init__.py +3 -0
- smart_bot_factory/event/__init__.py +7 -0
- smart_bot_factory/handlers/handlers.py +2013 -0
- smart_bot_factory/integrations/langchain_openai.py +542 -0
- smart_bot_factory/integrations/openai_client.py +513 -0
- smart_bot_factory/integrations/supabase_client.py +1678 -0
- smart_bot_factory/memory/__init__.py +8 -0
- smart_bot_factory/memory/memory_manager.py +299 -0
- smart_bot_factory/memory/static_memory.py +214 -0
- smart_bot_factory/message/__init__.py +56 -0
- smart_bot_factory/rag/__init__.py +5 -0
- smart_bot_factory/rag/decorators.py +29 -0
- smart_bot_factory/rag/router.py +54 -0
- smart_bot_factory/rag/templates/__init__.py +3 -0
- smart_bot_factory/rag/templates/create_table.sql +7 -0
- smart_bot_factory/rag/templates/create_table_and_function_template.py +94 -0
- smart_bot_factory/rag/templates/match_function.sql +61 -0
- smart_bot_factory/rag/templates/match_services_template.py +82 -0
- smart_bot_factory/rag/vectorstore.py +449 -0
- smart_bot_factory/router/__init__.py +10 -0
- smart_bot_factory/setup_checker.py +512 -0
- smart_bot_factory/supabase/__init__.py +7 -0
- smart_bot_factory/supabase/client.py +631 -0
- smart_bot_factory/utils/__init__.py +11 -0
- smart_bot_factory/utils/debug_routing.py +114 -0
- smart_bot_factory/utils/prompt_loader.py +529 -0
- smart_bot_factory/utils/tool_router.py +68 -0
- smart_bot_factory/utils/user_prompt_loader.py +55 -0
- smart_bot_factory/utm_link_generator.py +123 -0
- smart_bot_factory-1.1.1.dist-info/METADATA +1135 -0
- smart_bot_factory-1.1.1.dist-info/RECORD +73 -0
- smart_bot_factory-1.1.1.dist-info/WHEEL +4 -0
- smart_bot_factory-1.1.1.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|