smart-bot-factory 0.1.2__py3-none-any.whl → 0.1.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.

Files changed (61) hide show
  1. smart_bot_factory/__init__.py +51 -0
  2. smart_bot_factory/admin/__init__.py +16 -0
  3. smart_bot_factory/admin/admin_logic.py +430 -0
  4. smart_bot_factory/admin/admin_manager.py +141 -0
  5. smart_bot_factory/admin/admin_migration.sql +136 -0
  6. smart_bot_factory/admin/admin_tester.py +151 -0
  7. smart_bot_factory/admin/timeout_checker.py +499 -0
  8. smart_bot_factory/analytics/__init__.py +7 -0
  9. smart_bot_factory/analytics/analytics_manager.py +355 -0
  10. smart_bot_factory/cli.py +642 -0
  11. smart_bot_factory/config.py +235 -0
  12. smart_bot_factory/configs/growthmed-helper/env_example.txt +1 -0
  13. smart_bot_factory/configs/growthmed-helper/prompts/1sales_context.txt +9 -0
  14. smart_bot_factory/configs/growthmed-helper/prompts/2product_info.txt +582 -0
  15. smart_bot_factory/configs/growthmed-helper/prompts/3objection_handling.txt +66 -0
  16. smart_bot_factory/configs/growthmed-helper/prompts/final_instructions.txt +232 -0
  17. smart_bot_factory/configs/growthmed-helper/prompts/help_message.txt +28 -0
  18. smart_bot_factory/configs/growthmed-helper/prompts/welcome_message.txt +7 -0
  19. smart_bot_factory/configs/growthmed-helper/welcome_file/welcome_file_msg.txt +16 -0
  20. smart_bot_factory/configs/growthmed-helper/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
  21. smart_bot_factory/configs/growthmed-october-24/prompts/1sales_context.txt +16 -0
  22. smart_bot_factory/configs/growthmed-october-24/prompts/2product_info.txt +582 -0
  23. smart_bot_factory/configs/growthmed-october-24/prompts/3objection_handling.txt +66 -0
  24. smart_bot_factory/configs/growthmed-october-24/prompts/final_instructions.txt +212 -0
  25. smart_bot_factory/configs/growthmed-october-24/prompts/help_message.txt +28 -0
  26. smart_bot_factory/configs/growthmed-october-24/prompts/welcome_message.txt +8 -0
  27. smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064229.txt +818 -0
  28. smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064335.txt +32 -0
  29. smart_bot_factory/configs/growthmed-october-24/reports/test_20250924_064638.txt +35 -0
  30. smart_bot_factory/configs/growthmed-october-24/tests/quick_scenarios.yaml +66 -0
  31. smart_bot_factory/configs/growthmed-october-24/tests/realistic_scenarios.yaml +108 -0
  32. smart_bot_factory/configs/growthmed-october-24/tests/scenario_examples.yaml +46 -0
  33. smart_bot_factory/configs/growthmed-october-24/welcome_file/welcome_file_msg.txt +16 -0
  34. 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
  35. smart_bot_factory/core/__init__.py +22 -0
  36. smart_bot_factory/core/bot_utils.py +693 -0
  37. smart_bot_factory/core/conversation_manager.py +536 -0
  38. smart_bot_factory/core/decorators.py +229 -0
  39. smart_bot_factory/core/message_sender.py +249 -0
  40. smart_bot_factory/core/states.py +14 -0
  41. smart_bot_factory/creation/__init__.py +8 -0
  42. smart_bot_factory/creation/bot_builder.py +329 -0
  43. smart_bot_factory/creation/bot_testing.py +986 -0
  44. smart_bot_factory/database/database_structure.sql +57 -0
  45. smart_bot_factory/database/schema.sql +1094 -0
  46. smart_bot_factory/handlers/handlers.py +583 -0
  47. smart_bot_factory/integrations/__init__.py +9 -0
  48. smart_bot_factory/integrations/openai_client.py +435 -0
  49. smart_bot_factory/integrations/supabase_client.py +592 -0
  50. smart_bot_factory/setup_checker.py +476 -0
  51. smart_bot_factory/utils/__init__.py +9 -0
  52. smart_bot_factory/utils/debug_routing.py +103 -0
  53. smart_bot_factory/utils/prompt_loader.py +427 -0
  54. smart_bot_factory/uv.lock +2004 -0
  55. smart_bot_factory-0.1.3.dist-info/METADATA +126 -0
  56. smart_bot_factory-0.1.3.dist-info/RECORD +59 -0
  57. smart_bot_factory-0.1.3.dist-info/licenses/LICENSE +24 -0
  58. smart_bot_factory-0.1.2.dist-info/METADATA +0 -31
  59. smart_bot_factory-0.1.2.dist-info/RECORD +0 -4
  60. {smart_bot_factory-0.1.2.dist-info → smart_bot_factory-0.1.3.dist-info}/WHEEL +0 -0
  61. {smart_bot_factory-0.1.2.dist-info → smart_bot_factory-0.1.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,693 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from datetime import datetime
5
+ from aiogram import Router
6
+ from aiogram.filters import Command
7
+ from aiogram.types import (
8
+ Message,
9
+ InlineKeyboardMarkup,
10
+ InlineKeyboardButton,
11
+ FSInputFile,
12
+ )
13
+ from aiogram.utils.media_group import MediaGroupBuilder
14
+
15
+ from pathlib import Path
16
+ from ..core.decorators import execute_event_handler, execute_scheduled_task
17
+
18
+ # Функция для получения глобальных переменных
19
+ def get_global_var(var_name):
20
+ """Получает глобальную переменную из модуля bot_utils"""
21
+ import sys
22
+ current_module = sys.modules[__name__]
23
+ return getattr(current_module, var_name, None)
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Создаем роутер для общих команд
28
+ utils_router = Router()
29
+
30
+ def setup_utils_handlers(dp):
31
+ """Настройка обработчиков утилит"""
32
+ dp.include_router(utils_router)
33
+
34
+ def parse_ai_response(ai_response: str) -> tuple[str, dict]:
35
+ """Исправленная функция парсинга JSON из конца ответа ИИ"""
36
+ try:
37
+ # Метод 1: Ищем последнюю позицию, где начинается JSON с "этап"
38
+ last_etap_pos = ai_response.rfind('"этап"')
39
+ if last_etap_pos == -1:
40
+ logger.debug("JSON без ключа 'этап' не найден")
41
+ return ai_response, {}
42
+
43
+ # Ищем открывающую скобку перед "этап"
44
+ json_start = -1
45
+ for i in range(last_etap_pos, -1, -1):
46
+ if ai_response[i] == '{':
47
+ json_start = i
48
+ break
49
+
50
+ if json_start == -1:
51
+ logger.debug("Открывающая скобка перед 'этап' не найдена")
52
+ return ai_response, {}
53
+
54
+ # Теперь найдем соответствующую закрывающую скобку
55
+ brace_count = 0
56
+ json_end = -1
57
+
58
+ for i in range(json_start, len(ai_response)):
59
+ char = ai_response[i]
60
+ if char == '{':
61
+ brace_count += 1
62
+ elif char == '}':
63
+ brace_count -= 1
64
+ if brace_count == 0:
65
+ json_end = i
66
+ break
67
+
68
+ if json_end == -1:
69
+ logger.debug("Соответствующая закрывающая скобка не найдена")
70
+ return ai_response, {}
71
+
72
+ # Извлекаем JSON и текст ответа
73
+ json_str = ai_response[json_start:json_end + 1]
74
+ response_text = ai_response[:json_start].strip()
75
+
76
+ # 🆕 ИСПРАВЛЕНИЕ: Если response_text пустой, используем исходный ответ БЕЗ JSON
77
+ if not response_text:
78
+ logger.debug("Текст ответа пустой после удаления JSON, используем исходный ответ без JSON части")
79
+ # Берем все кроме JSON части
80
+ remaining_text = ai_response[json_end + 1:].strip()
81
+ if remaining_text:
82
+ response_text = remaining_text
83
+ else:
84
+ # Если и после JSON ничего нет, значит ответ был только JSON
85
+ response_text = "Ответ обработан системой."
86
+ logger.warning("Ответ ИИ содержал только JSON без текста")
87
+
88
+ try:
89
+ metadata = json.loads(json_str)
90
+ logger.debug(f"JSON успешно распарсен: {metadata}")
91
+ return response_text, metadata
92
+ except json.JSONDecodeError as e:
93
+ logger.warning(f"Ошибка парсинга JSON: {e}")
94
+ logger.debug(f"JSON строка: {json_str}")
95
+ return parse_ai_response_method2(ai_response)
96
+
97
+ except Exception as e:
98
+ logger.warning(f"Ошибка парсинга JSON от ИИ: {e}")
99
+ return parse_ai_response_method2(ai_response)
100
+
101
+ def parse_ai_response_method2(ai_response: str) -> tuple[str, dict]:
102
+ """Резервный метод парсинга JSON - поиск по строкам (переименован для соответствия тестам)"""
103
+ try:
104
+ logger.debug("Используем резервный метод парсинга JSON")
105
+
106
+ lines = ai_response.strip().split('\n')
107
+
108
+ # Ищем строку с "этап"
109
+ etap_line = -1
110
+ for i, line in enumerate(lines):
111
+ if '"этап"' in line:
112
+ etap_line = i
113
+ break
114
+
115
+ if etap_line == -1:
116
+ return ai_response, {}
117
+
118
+ # Ищем начало JSON (строку с { перед этап)
119
+ json_start_line = -1
120
+ for i in range(etap_line, -1, -1):
121
+ if lines[i].strip().startswith('{'):
122
+ json_start_line = i
123
+ break
124
+
125
+ if json_start_line == -1:
126
+ return ai_response, {}
127
+
128
+ # Ищем конец JSON (балансируем скобки)
129
+ brace_count = 0
130
+ json_end_line = -1
131
+
132
+ for i in range(json_start_line, len(lines)):
133
+ line = lines[i]
134
+ for char in line:
135
+ if char == '{':
136
+ brace_count += 1
137
+ elif char == '}':
138
+ brace_count -= 1
139
+ if brace_count == 0:
140
+ json_end_line = i
141
+ break
142
+ if json_end_line != -1:
143
+ break
144
+
145
+ if json_end_line == -1:
146
+ return ai_response, {}
147
+
148
+ # Собираем JSON
149
+ json_lines = lines[json_start_line:json_end_line + 1]
150
+ json_str = '\n'.join(json_lines)
151
+
152
+ # Собираем текст ответа
153
+ response_lines = lines[:json_start_line]
154
+ response_text = '\n'.join(response_lines).strip()
155
+
156
+ try:
157
+ metadata = json.loads(json_str)
158
+ logger.debug(f"JSON распарсен резервным методом: {metadata}")
159
+ return response_text, metadata
160
+ except json.JSONDecodeError as e:
161
+ logger.warning(f"Резервный метод: ошибка JSON: {e}")
162
+ return ai_response, {}
163
+
164
+ except Exception as e:
165
+ logger.warning(f"Ошибка резервного метода: {e}")
166
+ return ai_response, {}
167
+
168
+ async def process_events(session_id: str, events: list, user_id: int):
169
+ """Обрабатывает события из ответа ИИ"""
170
+ supabase_client = get_global_var('supabase_client')
171
+
172
+ for event in events:
173
+ try:
174
+ event_type = event.get('тип', '')
175
+ event_info = event.get('инфо', '')
176
+
177
+ if not event_type:
178
+ logger.warning(f"⚠️ Событие без типа: {event}")
179
+ continue
180
+
181
+ logger.info(f"\n🔔 Обработка события:")
182
+ logger.info(f" 📝 Тип: {event_type}")
183
+ logger.info(f" 📄 Данные: {event_info}")
184
+
185
+ # Сохраняем в БД
186
+ await supabase_client.add_session_event(session_id, event_type, event_info)
187
+ logger.info(f" ✅ Событие сохранено в БД")
188
+
189
+ # Уведомляем админов
190
+ await notify_admins_about_event(user_id, event)
191
+ logger.info(f" ✅ Админы уведомлены")
192
+
193
+ # Вызываем зарегистрированный обработчик события или задачи
194
+ try:
195
+ # Сначала пробуем как обычное событие
196
+ try:
197
+ logger.info(f" 🎯 Вызываем обработчик события '{event_type}'")
198
+ result = await execute_event_handler(event_type, user_id, event_info)
199
+ logger.info(f" ✅ Обработчик события вернул: {result}")
200
+ except ValueError:
201
+ # Если обработчик события не найден, пробуем как запланированную задачу
202
+ logger.info(f" ⏰ Пробуем как запланированную задачу '{event_type}'")
203
+ result = await execute_scheduled_task(event_type, user_id, event_info)
204
+ logger.info(f" ✅ Задача выполнена: {result}")
205
+ except ValueError as e:
206
+ logger.warning(f" ⚠️ Обработчик/задача не найдены: {e}")
207
+ except Exception as e:
208
+ logger.error(f" ❌ Ошибка в обработчике/задаче: {e}")
209
+ logger.exception(" Стек ошибки:")
210
+
211
+ except Exception as e:
212
+ logger.error(f"❌ Ошибка обработки события {event}: {e}")
213
+ logger.exception("Стек ошибки:")
214
+
215
+ async def notify_admins_about_event(user_id: int, event: dict):
216
+ """Отправляем уведомление админам о событии с явным указанием ID пользователя"""
217
+ supabase_client = get_global_var('supabase_client')
218
+ admin_manager = get_global_var('admin_manager')
219
+ bot = get_global_var('bot')
220
+
221
+ event_type = event.get('тип', '')
222
+ event_info = event.get('инфо', '')
223
+
224
+ if not event_type:
225
+ return
226
+
227
+ # Получаем информацию о пользователе для username
228
+ try:
229
+ user_response = supabase_client.client.table('sales_users').select(
230
+ 'first_name', 'last_name', 'username'
231
+ ).eq('telegram_id', user_id).execute()
232
+
233
+ user_info = user_response.data[0] if user_response.data else {}
234
+
235
+ # Формируем имя пользователя (без ID)
236
+ name_parts = []
237
+ if user_info.get('first_name'):
238
+ name_parts.append(user_info['first_name'])
239
+ if user_info.get('last_name'):
240
+ name_parts.append(user_info['last_name'])
241
+
242
+ user_name = " ".join(name_parts) if name_parts else "Без имени"
243
+
244
+ # Формируем отображение пользователя с ОБЯЗАТЕЛЬНЫМ ID
245
+ if user_info.get('username'):
246
+ user_display = f"{user_name} (@{user_info['username']})"
247
+ else:
248
+ user_display = user_name
249
+
250
+ except Exception as e:
251
+ logger.error(f"Ошибка получения информации о пользователе {user_id}: {e}")
252
+ user_display = "Пользователь"
253
+
254
+ emoji_map = {
255
+ 'телефон': '📱',
256
+ 'консультация': '💬',
257
+ 'покупка': '💰',
258
+ 'отказ': '❌'
259
+ }
260
+
261
+ emoji = emoji_map.get(event_type, '🔔')
262
+
263
+ # 🆕 ИСПРАВЛЕНИЕ: ID всегда отображается отдельной строкой для удобства копирования
264
+ notification = f"""
265
+ {emoji} {event_type.upper()}!
266
+ 👤 {user_display}
267
+ 🆔 ID: {user_id}
268
+ 📝 {event_info}
269
+ 🕐 {datetime.now().strftime('%H:%M')}
270
+ """
271
+
272
+ # Создаем клавиатуру с кнопками
273
+ keyboard = InlineKeyboardMarkup(inline_keyboard=[
274
+ [
275
+ InlineKeyboardButton(text="💬 Чат", callback_data=f"admin_chat_{user_id}"),
276
+ InlineKeyboardButton(text="📋 История", callback_data=f"admin_history_{user_id}")
277
+ ]
278
+ ])
279
+
280
+ try:
281
+ # Отправляем всем активным админам
282
+ active_admins = await admin_manager.get_active_admins()
283
+ for admin_id in active_admins:
284
+ try:
285
+ await bot.send_message(admin_id, notification.strip(), reply_markup=keyboard)
286
+ except Exception as e:
287
+ logger.error(f"Ошибка отправки уведомления админу {admin_id}: {e}")
288
+
289
+ except Exception as e:
290
+ logger.error(f"Ошибка отправки уведомления админам: {e}")
291
+
292
+ async def send_message(message: Message, text: str, files_list: list = [], directories_list: list = [], **kwargs):
293
+ """Вспомогательная функция для отправки сообщений с настройкой parse_mode"""
294
+ config = get_global_var('config')
295
+
296
+ logger.info(f"📤 send_message вызвана:")
297
+ logger.info(f" 👤 Пользователь: {message.from_user.id}")
298
+ logger.info(f" 📝 Длина текста: {len(text)} символов")
299
+ logger.info(f" 🐛 Debug режим: {config.DEBUG_MODE}")
300
+
301
+ try:
302
+ parse_mode = config.MESSAGE_PARSE_MODE if config.MESSAGE_PARSE_MODE != 'None' else None
303
+ logger.info(f" 🔧 Parse mode: {parse_mode}")
304
+
305
+ # В режиме отладки не скрываем JSON
306
+ if config.DEBUG_MODE:
307
+ final_text = text
308
+ logger.info(f" 🐛 Отправляем полный текст (debug режим)")
309
+ else:
310
+ # Убираем JSON если он есть
311
+ final_text, json_metadata = parse_ai_response(text)
312
+ logger.info(f" ✂️ После очистки JSON: {len(final_text)} символов")
313
+
314
+ # Добавляем информацию о файлах и каталогах в конец сообщения
315
+ if json_metadata:
316
+ logger.info(f" 📊 Найден JSON: {json_metadata}")
317
+
318
+ files_list = json_metadata.get('файлы', [])
319
+ directories_list = json_metadata.get('каталоги', [])
320
+
321
+ files_info = []
322
+ if files_list:
323
+ files_str = "\n".join(f"• {file}" for file in files_list)
324
+ files_info.append(f"\n\n📎 Доступные файлы:\n{files_str}")
325
+
326
+ if directories_list:
327
+ dirs_str = "\n".join(f"• {directory}" for directory in directories_list)
328
+ files_info.append(f"\n\n📂 Доступные каталоги:\n{dirs_str}")
329
+
330
+ if files_info:
331
+ final_text = final_text.strip() + "".join(files_info)
332
+ logger.info(f" ✨ Добавлена информация о {len(files_list)} файлах и {len(directories_list)} каталогах")
333
+
334
+
335
+ # Проверяем, что есть что отправлять
336
+ if not final_text or not final_text.strip():
337
+ logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: final_text пуст после обработки!")
338
+ logger.error(f" Исходный text: '{text[:200]}...'")
339
+ final_text = "Ошибка формирования ответа. Попробуйте еще раз."
340
+
341
+ logger.info(f"📱 Подготовка сообщения: {len(final_text)} символов")
342
+
343
+ # Проверяем наличие файлов для отправки
344
+ if files_list or directories_list:
345
+ # Функция определения типа медиа по расширению
346
+ def get_media_type(file_path: str) -> str:
347
+ ext = Path(file_path).suffix.lower()
348
+ if ext in {'.jpg', '.jpeg', '.png'}:
349
+ return 'photo'
350
+ elif ext in {'.mp4'}:
351
+ return 'video'
352
+ else:
353
+ return 'document'
354
+
355
+ # Создаем списки для разных типов файлов
356
+ media_files = [] # для фото и видео
357
+ document_files = [] # для документов
358
+
359
+ # Функция обработки файла
360
+ def process_file(file_path: Path, source: str = ""):
361
+ if file_path.is_file():
362
+ media_type = get_media_type(str(file_path))
363
+ if media_type in ('photo', 'video'):
364
+ media_files.append((file_path, media_type))
365
+ logger.info(f" 📸 Добавлен медиафайл{f' из {source}' if source else ''}: {file_path.name}")
366
+ else:
367
+ document_files.append(file_path)
368
+ logger.info(f" 📄 Добавлен документ{f' из {source}' if source else ''}: {file_path.name}")
369
+ else:
370
+ logger.warning(f" ⚠️ Файл не найден: {file_path}")
371
+
372
+ # Обрабатываем прямые файлы
373
+ for file_name in files_list:
374
+ try:
375
+ # Получаем путь к папке бота
376
+ config = get_global_var('config')
377
+ bot_id = config.BOT_ID if config else "unknown"
378
+ file_path = Path(f"bots/{bot_id}/files/{file_name}")
379
+ process_file(file_path)
380
+ except Exception as e:
381
+ logger.error(f" ❌ Ошибка обработки файла {file_name}: {e}")
382
+
383
+ # Обрабатываем файлы из каталогов
384
+ for dir_name in directories_list:
385
+ # Получаем путь к каталогу относительно папки бота
386
+ config = get_global_var('config')
387
+ bot_id = config.BOT_ID if config else "unknown"
388
+ dir_path = Path(f"bots/{bot_id}/{dir_name}")
389
+ try:
390
+ if dir_path.is_dir():
391
+ for file_path in dir_path.iterdir():
392
+ try:
393
+ process_file(file_path, dir_path)
394
+ except Exception as e:
395
+ logger.error(f" ❌ Ошибка обработки файла {file_path}: {e}")
396
+ else:
397
+ logger.warning(f" ⚠️ Каталог не найден: {dir_path}")
398
+ except Exception as e:
399
+ logger.error(f" ❌ Ошибка обработки каталога {dir_path}: {e}")
400
+
401
+ # Отправляем сообщение с медиа (если есть)
402
+ if media_files:
403
+ # Создаем медиа-группу с фото/видео и текстом
404
+ media_group = MediaGroupBuilder(caption=final_text)
405
+ for file_path, media_type in media_files:
406
+ if media_type == 'photo':
407
+ media_group.add_photo(media=FSInputFile(str(file_path)))
408
+ else: # video
409
+ media_group.add_video(media=FSInputFile(str(file_path)))
410
+
411
+ media = media_group.build()
412
+ if media:
413
+ result = await message.answer_media_group(media=media)
414
+ logger.info(f" ✅ Отправлено сообщение с {len(media)} медиафайлами")
415
+ else:
416
+ # Если нет медиа, отправляем просто текст
417
+ result = await message.answer(final_text, parse_mode=parse_mode, **kwargs)
418
+ logger.info(f" ✅ Отправлен текст сообщения")
419
+
420
+ # Отправляем документы отдельно (если есть)
421
+ if document_files:
422
+ doc_group = MediaGroupBuilder()
423
+ for file_path in document_files:
424
+ doc_group.add_document(media=FSInputFile(str(file_path)))
425
+
426
+ docs = doc_group.build()
427
+ if docs:
428
+ await message.answer_media_group(media=docs)
429
+ logger.info(f" ✅ Отправлена группа документов: {len(docs)} файлов")
430
+
431
+ return result
432
+ else:
433
+ # Если нет файлов, отправляем просто текст
434
+ logger.warning(" ⚠️ Нет файлов для отправки, отправляем как текст")
435
+ result = await message.answer(final_text, parse_mode=parse_mode, **kwargs)
436
+ return result
437
+
438
+ except Exception as e:
439
+ logger.error(f"❌ ОШИБКА в send_message: {e}")
440
+ logger.exception("Полный стек ошибки send_message:")
441
+
442
+ # Пытаемся отправить простое сообщение без форматирования
443
+ try:
444
+ fallback_text = "Произошла ошибка при отправке ответа. Попробуйте еще раз."
445
+ result = await message.answer(fallback_text)
446
+ logger.info(f"✅ Запасное сообщение отправлено")
447
+ return result
448
+ except Exception as e2:
449
+ logger.error(f"❌ Даже запасное сообщение не отправилось: {e2}")
450
+ raise
451
+
452
+ async def cleanup_expired_conversations():
453
+ """Периодическая очистка просроченных диалогов"""
454
+ conversation_manager = get_global_var('conversation_manager')
455
+
456
+ while True:
457
+ try:
458
+ await asyncio.sleep(300) # каждые 5 минут
459
+ await conversation_manager.cleanup_expired_conversations()
460
+ except Exception as e:
461
+ logger.error(f"Ошибка очистки просроченных диалогов: {e}")
462
+
463
+ # 🆕 Вспомогательные функции для приветственного файла
464
+
465
+ async def get_welcome_file_path() -> str | None:
466
+ """Возвращает путь к PDF файлу из папки WELCOME_FILE_DIR из конфига.
467
+
468
+ Источник настроек: configs/<bot_id>/.env (переменная WELCOME_FILE_DIR)
469
+ Рабочая директория уже установлена запускалкой на configs/<bot_id>.
470
+
471
+ Returns:
472
+ str | None: Путь к PDF файлу или None, если файл не найден
473
+ """
474
+ config = get_global_var('config')
475
+ try:
476
+ folder_value = config.WELCOME_FILE_DIR
477
+ if not folder_value:
478
+ return None
479
+
480
+ folder = Path(folder_value)
481
+ if not folder.exists():
482
+ logger.info(f"Директория приветственных файлов не существует: {folder_value}")
483
+ return None
484
+
485
+ if not folder.is_dir():
486
+ logger.info(f"Путь не является директорией: {folder_value}")
487
+ return None
488
+
489
+ # Ищем первый PDF файл в директории
490
+ for path in folder.iterdir():
491
+ if path.is_file() and path.suffix.lower() == '.pdf':
492
+ return str(path)
493
+
494
+ logger.info(f"PDF файл не найден в директории: {folder_value}")
495
+ return None
496
+
497
+ except Exception as e:
498
+ logger.error(f"Ошибка при поиске приветственного файла: {e}")
499
+ return None
500
+
501
+ async def get_welcome_msg_path() -> str | None:
502
+ """Возвращает путь к файлу welcome_file_msg.txt из той же директории, где находится PDF файл.
503
+
504
+ Returns:
505
+ str | None: Путь к файлу с подписью или None, если файл не найден
506
+ """
507
+ try:
508
+ pdf_path = await get_welcome_file_path()
509
+ if not pdf_path:
510
+ return None
511
+
512
+ msg_path = str(Path(pdf_path).parent / 'welcome_file_msg.txt')
513
+ if not Path(msg_path).is_file():
514
+ logger.info(f"Файл подписи не найден: {msg_path}")
515
+ return None
516
+
517
+ return msg_path
518
+
519
+ except Exception as e:
520
+ logger.error(f"Ошибка при поиске файла подписи: {e}")
521
+ return None
522
+
523
+ async def send_welcome_file(message: Message) -> str:
524
+ """
525
+ Отправляет приветственный файл с подписью из файла welcome_file_msg.txt.
526
+ Если файл подписи не найден, используется пустая подпись.
527
+
528
+ Returns:
529
+ str: текст подписи
530
+ """
531
+ try:
532
+ config = get_global_var('config')
533
+
534
+ file_path = await get_welcome_file_path()
535
+ if not file_path:
536
+ return ""
537
+
538
+ # Получаем путь к файлу с подписью и читаем его
539
+ caption = ""
540
+ msg_path = await get_welcome_msg_path()
541
+ if msg_path:
542
+ try:
543
+ with open(msg_path, 'r', encoding='utf-8') as f:
544
+ caption = f.read().strip()
545
+ logger.info(f"Подпись загружена из файла: {msg_path}")
546
+ except Exception as e:
547
+ logger.error(f"Ошибка при чтении файла подписи {msg_path}: {e}")
548
+
549
+ parse_mode = config.MESSAGE_PARSE_MODE
550
+ document = FSInputFile(file_path)
551
+
552
+ await message.answer_document(document=document, caption=caption, parse_mode=parse_mode)
553
+
554
+ logger.info(f"Приветственный файл отправлен: {file_path}")
555
+ return caption
556
+ except Exception as e:
557
+ logger.error(f"Ошибка при отправке приветственного файла: {e}")
558
+ return ""
559
+
560
+ # Общие команды
561
+
562
+ @utils_router.message(Command("help"))
563
+ async def help_handler(message: Message):
564
+ """Справка"""
565
+ admin_manager = get_global_var('admin_manager')
566
+ prompt_loader = get_global_var('prompt_loader')
567
+
568
+ try:
569
+ # Разная справка для админов и пользователей
570
+ if admin_manager.is_admin(message.from_user.id):
571
+ if admin_manager.is_in_admin_mode(message.from_user.id):
572
+ help_text = """
573
+ 👑 **Справка для администратора**
574
+
575
+ **Команды:**
576
+ • `/стат` - статистика воронки и событий
577
+ • `/история <user_id>` - история пользователя
578
+ • `/чат <user_id>` - начать диалог с пользователем
579
+ • `/чаты` - показать активные диалоги
580
+ • `/стоп` - завершить текущий диалог
581
+ • `/админ` - переключиться в режим пользователя
582
+
583
+ **Особенности:**
584
+ • Все сообщения пользователей к админу пересылаются
585
+ • Ваши сообщения отправляются пользователю как от бота
586
+ • Диалоги автоматически завершаются через 30 минут
587
+ """
588
+ await message.answer(help_text, parse_mode='Markdown')
589
+ return
590
+
591
+ # Обычная справка для пользователей
592
+ help_text = await prompt_loader.load_help_message()
593
+ await send_message(message, help_text)
594
+
595
+ except Exception as e:
596
+ logger.error(f"Ошибка загрузки справки: {e}")
597
+ # Fallback справка
598
+ await send_message(message, "🤖 Ваш помощник готов к работе! Напишите /start для начала диалога.")
599
+
600
+ @utils_router.message(Command("status"))
601
+ async def status_handler(message: Message):
602
+ """Проверка статуса системы"""
603
+ openai_client = get_global_var('openai_client')
604
+ prompt_loader = get_global_var('prompt_loader')
605
+ admin_manager = get_global_var('admin_manager')
606
+ config = get_global_var('config')
607
+
608
+ try:
609
+ # Проверяем OpenAI
610
+ openai_status = await openai_client.check_api_health()
611
+
612
+ # Проверяем промпты
613
+ prompts_status = await prompt_loader.validate_prompts()
614
+
615
+ # Статистика для админов
616
+ if admin_manager.is_admin(message.from_user.id):
617
+ admin_stats = admin_manager.get_stats()
618
+
619
+ status_message = f"""
620
+ 🔧 **Статус системы:**
621
+
622
+ OpenAI API: {'✅' if openai_status else '❌'}
623
+ Промпты: {'✅ ' + str(sum(prompts_status.values())) + '/' + str(len(prompts_status)) + ' загружено' if any(prompts_status.values()) else '❌'}
624
+ База данных: ✅ (соединение активно)
625
+
626
+ 👑 **Админы:** {admin_stats['active_admins']}/{admin_stats['total_admins']} активны
627
+ 🐛 **Режим отладки:** {'Включен' if config.DEBUG_MODE else 'Выключен'}
628
+
629
+ Все системы работают нормально!
630
+ """
631
+ else:
632
+ status_message = f"""
633
+ 🔧 **Статус системы:**
634
+
635
+ OpenAI API: {'✅' if openai_status else '❌'}
636
+ Промпты: {'✅ ' + str(sum(prompts_status.values())) + '/' + str(len(prompts_status)) + ' загружено' if any(prompts_status.values()) else '❌'}
637
+ База данных: ✅ (соединение активно)
638
+
639
+ Все системы работают нормально!
640
+ """
641
+
642
+ await send_message(message, status_message)
643
+
644
+ except Exception as e:
645
+ logger.error(f"Ошибка проверки статуса: {e}")
646
+ await send_message(message, "❌ Ошибка при проверке статуса системы")
647
+
648
+
649
+ def parse_utm_from_start_param(start_param: str) -> dict:
650
+ """Парсит UTM-метки из start параметра в формате utmSource-vk_utmCampaign-summer2025
651
+
652
+ Args:
653
+ start_param: строка вида 'utmSource-vk_utmCampaign-summer2025' или полная ссылка
654
+
655
+ Returns:
656
+ dict: {'utm_source': 'vk', 'utm_campaign': 'summer2025'}
657
+
658
+ Examples:
659
+ >>> parse_utm_from_start_param('utmSource-vk_utmCampaign-summer2025')
660
+ {'utm_source': 'vk', 'utm_campaign': 'summer2025'}
661
+
662
+ >>> parse_utm_from_start_param('https://t.me/bot?start=utmSource-vk_utmCampaign-summer2025')
663
+ {'utm_source': 'vk', 'utm_campaign': 'summer2025'}
664
+ """
665
+ import re
666
+ from urllib.parse import unquote
667
+
668
+ utm_data = {}
669
+
670
+ try:
671
+ # Если это полная ссылка, извлекаем start параметр
672
+ if 't.me/' in start_param or 'https://' in start_param:
673
+ match = re.search(r'[?&]start=([^&]+)', start_param)
674
+ if match:
675
+ start_param = unquote(match.group(1))
676
+ else:
677
+ return {}
678
+
679
+ # Парсим формат: utmSource-vk_utmCampaign-summer2025
680
+ if '_' in start_param and '-' in start_param:
681
+ parts = start_param.split('_')
682
+ for part in parts:
683
+ if '-' in part:
684
+ key, value = part.split('-', 1)
685
+ # Преобразуем utmSource в utm_source
686
+ if key.startswith('utm'):
687
+ key = 'utm_' + key[3:].lower()
688
+ utm_data[key] = value
689
+
690
+ except Exception as e:
691
+ print(f"Ошибка парсинга UTM параметров: {e}")
692
+
693
+ return utm_data