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,729 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Функции для отправки сообщений через ИИ и от человека
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, Optional, Union
|
|
9
|
+
|
|
10
|
+
from aiogram.types import InlineKeyboardMarkup, FSInputFile
|
|
11
|
+
import pytz
|
|
12
|
+
|
|
13
|
+
from project_root_finder import root
|
|
14
|
+
|
|
15
|
+
PROJECT_ROOT = root
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def send_message_by_ai(
|
|
21
|
+
user_id: int, message_text: str, session_id: str = None
|
|
22
|
+
) -> Dict[str, Any]:
|
|
23
|
+
"""
|
|
24
|
+
Отправляет сообщение пользователю через ИИ (копирует логику process_user_message)
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
user_id: ID пользователя в Telegram
|
|
28
|
+
message_text: Текст сообщения для обработки ИИ
|
|
29
|
+
session_id: ID сессии чата (если не указан, будет использована активная сессия)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Результат отправки
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
# Импортируем необходимые компоненты
|
|
36
|
+
# Получаем компоненты из глобального контекста
|
|
37
|
+
from ..handlers.handlers import get_global_var
|
|
38
|
+
from .bot_utils import parse_ai_response, process_events
|
|
39
|
+
|
|
40
|
+
bot = get_global_var("bot")
|
|
41
|
+
supabase_client = get_global_var("supabase_client")
|
|
42
|
+
openai_client = get_global_var("openai_client")
|
|
43
|
+
config = get_global_var("config")
|
|
44
|
+
prompt_loader = get_global_var("prompt_loader")
|
|
45
|
+
|
|
46
|
+
# Если session_id не указан, получаем активную сессию пользователя
|
|
47
|
+
if not session_id:
|
|
48
|
+
session_info = await supabase_client.get_active_session(user_id)
|
|
49
|
+
if not session_info:
|
|
50
|
+
return {
|
|
51
|
+
"status": "error",
|
|
52
|
+
"error": "Активная сессия не найдена",
|
|
53
|
+
"user_id": user_id,
|
|
54
|
+
}
|
|
55
|
+
session_id = session_info["id"]
|
|
56
|
+
|
|
57
|
+
# Загружаем системный промпт
|
|
58
|
+
try:
|
|
59
|
+
system_prompt = await prompt_loader.load_system_prompt()
|
|
60
|
+
logger.info(f"✅ Системный промпт загружен ({len(system_prompt)} символов)")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"❌ Ошибка загрузки системного промпта: {e}")
|
|
63
|
+
return {
|
|
64
|
+
"status": "error",
|
|
65
|
+
"error": "Не удалось загрузить системный промпт",
|
|
66
|
+
"user_id": user_id,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Сохраняем сообщение пользователя в БД
|
|
70
|
+
await supabase_client.add_message(
|
|
71
|
+
session_id=session_id,
|
|
72
|
+
role="user",
|
|
73
|
+
content=message_text,
|
|
74
|
+
message_type="text",
|
|
75
|
+
)
|
|
76
|
+
logger.info("✅ Сообщение пользователя сохранено в БД")
|
|
77
|
+
|
|
78
|
+
# Получаем историю сообщений
|
|
79
|
+
chat_history = await supabase_client.get_chat_history(
|
|
80
|
+
session_id, limit=config.MAX_CONTEXT_MESSAGES
|
|
81
|
+
)
|
|
82
|
+
logger.info(f"📚 Загружена история: {len(chat_history)} сообщений")
|
|
83
|
+
|
|
84
|
+
# Добавляем текущее время
|
|
85
|
+
moscow_tz = pytz.timezone("Europe/Moscow")
|
|
86
|
+
current_time = datetime.now(moscow_tz)
|
|
87
|
+
time_info = current_time.strftime("%H:%M, %d.%m.%Y, %A")
|
|
88
|
+
|
|
89
|
+
# Модифицируем системный промпт, добавляя время
|
|
90
|
+
system_prompt_with_time = f"""
|
|
91
|
+
{system_prompt}
|
|
92
|
+
|
|
93
|
+
ТЕКУЩЕЕ ВРЕМЯ: {time_info} (московское время)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
# Формируем контекст для OpenAI
|
|
97
|
+
messages = [{"role": "system", "content": system_prompt_with_time}]
|
|
98
|
+
|
|
99
|
+
for msg in chat_history[-config.MAX_CONTEXT_MESSAGES :]:
|
|
100
|
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
|
101
|
+
|
|
102
|
+
# Добавляем финальные инструкции
|
|
103
|
+
final_instructions = await prompt_loader.load_final_instructions()
|
|
104
|
+
if final_instructions:
|
|
105
|
+
messages.append({"role": "system", "content": final_instructions})
|
|
106
|
+
logger.info("🎯 Добавлены финальные инструкции")
|
|
107
|
+
|
|
108
|
+
logger.info(f"📝 Контекст сформирован: {len(messages)} сообщений")
|
|
109
|
+
|
|
110
|
+
# Отправляем действие "печатает"
|
|
111
|
+
await bot.send_chat_action(user_id, "typing")
|
|
112
|
+
|
|
113
|
+
# Получаем ответ от ИИ
|
|
114
|
+
start_time = time.time()
|
|
115
|
+
ai_response = await openai_client.get_completion(messages)
|
|
116
|
+
processing_time = int((time.time() - start_time) * 1000)
|
|
117
|
+
|
|
118
|
+
logger.info(f"🤖 OpenAI ответил за {processing_time}мс")
|
|
119
|
+
|
|
120
|
+
# Обрабатываем ответ
|
|
121
|
+
tokens_used = 0
|
|
122
|
+
ai_metadata = {}
|
|
123
|
+
response_text = ""
|
|
124
|
+
|
|
125
|
+
if not ai_response or not ai_response.strip():
|
|
126
|
+
logger.warning("❌ OpenAI вернул пустой ответ!")
|
|
127
|
+
fallback_message = "Извините, произошла техническая ошибка. Попробуйте переформулировать вопрос."
|
|
128
|
+
ai_response = fallback_message
|
|
129
|
+
response_text = fallback_message
|
|
130
|
+
else:
|
|
131
|
+
tokens_used = openai_client.estimate_tokens(ai_response)
|
|
132
|
+
response_text, ai_metadata = parse_ai_response(ai_response)
|
|
133
|
+
|
|
134
|
+
if not ai_metadata:
|
|
135
|
+
response_text = ai_response
|
|
136
|
+
ai_metadata = {}
|
|
137
|
+
elif not response_text.strip():
|
|
138
|
+
response_text = ai_response
|
|
139
|
+
|
|
140
|
+
# Обновляем этап сессии и качество лида
|
|
141
|
+
if ai_metadata:
|
|
142
|
+
stage = ai_metadata.get("этап")
|
|
143
|
+
quality = ai_metadata.get("качество")
|
|
144
|
+
|
|
145
|
+
if stage or quality is not None:
|
|
146
|
+
await supabase_client.update_session_stage(session_id, stage, quality)
|
|
147
|
+
logger.info("✅ Этап и качество обновлены в БД")
|
|
148
|
+
|
|
149
|
+
# Обрабатываем события
|
|
150
|
+
events = ai_metadata.get("събития", [])
|
|
151
|
+
if events:
|
|
152
|
+
logger.info(f"🔔 Обрабатываем {len(events)} событий")
|
|
153
|
+
should_send_response = await process_events(session_id, events, user_id)
|
|
154
|
+
|
|
155
|
+
# Сохраняем ответ ассистента
|
|
156
|
+
await supabase_client.add_message(
|
|
157
|
+
session_id=session_id,
|
|
158
|
+
role="assistant",
|
|
159
|
+
content=response_text,
|
|
160
|
+
message_type="text",
|
|
161
|
+
tokens_used=tokens_used,
|
|
162
|
+
processing_time_ms=processing_time,
|
|
163
|
+
ai_metadata=ai_metadata,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Определяем финальный ответ
|
|
167
|
+
if config.DEBUG_MODE:
|
|
168
|
+
final_response = ai_response
|
|
169
|
+
else:
|
|
170
|
+
final_response = response_text
|
|
171
|
+
|
|
172
|
+
# Проверяем, нужно ли отправлять сообщение от ИИ
|
|
173
|
+
if "should_send_response" in locals() and not should_send_response:
|
|
174
|
+
logger.info(
|
|
175
|
+
"🔇 События запретили отправку сообщения от ИИ (message_sender), пропускаем отправку"
|
|
176
|
+
)
|
|
177
|
+
return {
|
|
178
|
+
"status": "skipped",
|
|
179
|
+
"reason": "send_ai_response=False",
|
|
180
|
+
"user_id": user_id,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Отправляем ответ пользователю напрямую через бота
|
|
184
|
+
await bot.send_message(chat_id=user_id, text=final_response)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"status": "success",
|
|
188
|
+
"user_id": user_id,
|
|
189
|
+
"response_text": response_text,
|
|
190
|
+
"tokens_used": tokens_used,
|
|
191
|
+
"processing_time_ms": processing_time,
|
|
192
|
+
"events_processed": len(events) if events else 0,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.error(f"❌ Ошибка в send_message_by_ai: {e}")
|
|
197
|
+
return {"status": "error", "error": str(e), "user_id": user_id}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def send_message_by_human(
|
|
201
|
+
user_id: int, message_text: str, session_id: Optional[str] = None, parse_mode: str = "Markdown", reply_markup: Optional[InlineKeyboardMarkup] = None, photo: Optional[str] = None
|
|
202
|
+
) -> Dict[str, Any]:
|
|
203
|
+
"""
|
|
204
|
+
Отправляет сообщение пользователю от имени человека (готовый текст или фото с подписью).
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
user_id: ID пользователя в Telegram
|
|
208
|
+
message_text: Готовый текст сообщения или подпись к фото
|
|
209
|
+
session_id: ID сессии (опционально, для сохранения в БД)
|
|
210
|
+
parse_mode: Тип форматирования текста
|
|
211
|
+
reply_markup: Клавиатура/markup (опционально)
|
|
212
|
+
photo: (str) путь к локальному файлу относительно корня проекта
|
|
213
|
+
Returns:
|
|
214
|
+
Результат отправки
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
# Импортируем необходимые компоненты
|
|
218
|
+
from ..handlers.handlers import get_global_var
|
|
219
|
+
|
|
220
|
+
bot = get_global_var("bot")
|
|
221
|
+
supabase_client = get_global_var("supabase_client")
|
|
222
|
+
|
|
223
|
+
msg_type = "text"
|
|
224
|
+
message = None
|
|
225
|
+
|
|
226
|
+
if photo:
|
|
227
|
+
from pathlib import Path
|
|
228
|
+
photo_path = PROJECT_ROOT / photo
|
|
229
|
+
if not photo_path.exists():
|
|
230
|
+
raise FileNotFoundError(f"Файл с фото не найден: {photo}")
|
|
231
|
+
message = await bot.send_photo(
|
|
232
|
+
chat_id=user_id,
|
|
233
|
+
photo=FSInputFile(str(photo_path)),
|
|
234
|
+
caption=message_text,
|
|
235
|
+
parse_mode=parse_mode,
|
|
236
|
+
reply_markup=reply_markup
|
|
237
|
+
)
|
|
238
|
+
msg_type = "photo"
|
|
239
|
+
else:
|
|
240
|
+
message = await bot.send_message(
|
|
241
|
+
chat_id=user_id,
|
|
242
|
+
text=message_text,
|
|
243
|
+
parse_mode=parse_mode,
|
|
244
|
+
reply_markup=reply_markup,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Если указана сессия, сохраняем сообщение в БД
|
|
248
|
+
if session_id:
|
|
249
|
+
await supabase_client.add_message(
|
|
250
|
+
session_id=session_id,
|
|
251
|
+
role="assistant",
|
|
252
|
+
content=message_text,
|
|
253
|
+
message_type=msg_type,
|
|
254
|
+
metadata={"sent_by_human": True, "has_photo": bool(photo)},
|
|
255
|
+
)
|
|
256
|
+
logger.info(f"💾 Сообщение от человека сохранено в БД (photo={bool(photo)})")
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"status": "success",
|
|
260
|
+
"user_id": user_id,
|
|
261
|
+
"message_id": message.message_id,
|
|
262
|
+
"message_text": message_text,
|
|
263
|
+
"saved_to_db": bool(session_id),
|
|
264
|
+
"has_photo": bool(photo)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"❌ Ошибка в send_message_by_human: {e}")
|
|
269
|
+
return {"status": "error", "error": str(e), "user_id": user_id}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def send_message_to_users_by_stage(
|
|
273
|
+
stage: str, message_text: str, bot_id: str, photo: Optional[str] = None
|
|
274
|
+
) -> Dict[str, Any]:
|
|
275
|
+
"""
|
|
276
|
+
Отправляет сообщение (или фото с подписью) всем пользователям, находящимся на определенной стадии
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
stage: Стадия диалога (например, 'introduction', 'qualification', 'closing')
|
|
280
|
+
message_text: Текст сообщения для отправки / подпись к фото
|
|
281
|
+
bot_id: ID бота (если не указан, используется текущий бот)
|
|
282
|
+
photo: путь к файлу с фото (относительно корня проекта, опционально)
|
|
283
|
+
Returns:
|
|
284
|
+
Результат отправки с количеством отправленных сообщений
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
from ..handlers.handlers import get_global_var
|
|
288
|
+
from pathlib import Path
|
|
289
|
+
|
|
290
|
+
bot = get_global_var("bot")
|
|
291
|
+
supabase_client = get_global_var("supabase_client")
|
|
292
|
+
current_bot_id = (
|
|
293
|
+
get_global_var("config").BOT_ID if get_global_var("config") else bot_id
|
|
294
|
+
)
|
|
295
|
+
if not current_bot_id:
|
|
296
|
+
return {"status": "error", "error": "Не удалось определить bot_id"}
|
|
297
|
+
logger.info(
|
|
298
|
+
f"🔍 Ищем пользователей на стадии '{stage}' для бота '{current_bot_id}'"
|
|
299
|
+
)
|
|
300
|
+
sessions_query = (
|
|
301
|
+
supabase_client.client.table("sales_chat_sessions")
|
|
302
|
+
.select("user_id, id, current_stage, created_at")
|
|
303
|
+
.eq("status", "active")
|
|
304
|
+
.eq("current_stage", stage)
|
|
305
|
+
)
|
|
306
|
+
if current_bot_id:
|
|
307
|
+
sessions_query = sessions_query.eq("bot_id", current_bot_id)
|
|
308
|
+
sessions_query = sessions_query.order("created_at", desc=True)
|
|
309
|
+
sessions_data = sessions_query.execute()
|
|
310
|
+
if not sessions_data.data:
|
|
311
|
+
logger.info(f"📭 Пользователи на стадии '{stage}' не найдены")
|
|
312
|
+
return {
|
|
313
|
+
"status": "success",
|
|
314
|
+
"stage": stage,
|
|
315
|
+
"users_found": 0,
|
|
316
|
+
"messages_sent": 0,
|
|
317
|
+
"errors": [],
|
|
318
|
+
}
|
|
319
|
+
unique_users = {}
|
|
320
|
+
for session in sessions_data.data:
|
|
321
|
+
user_id = session["user_id"]
|
|
322
|
+
if user_id not in unique_users:
|
|
323
|
+
unique_users[user_id] = {
|
|
324
|
+
"session_id": session["id"],
|
|
325
|
+
"current_stage": session["current_stage"],
|
|
326
|
+
}
|
|
327
|
+
logger.info(
|
|
328
|
+
f"👥 Найдено {len(unique_users)} уникальных пользователей на стадии '{stage}'"
|
|
329
|
+
)
|
|
330
|
+
messages_sent = 0
|
|
331
|
+
errors = []
|
|
332
|
+
photo_path = None
|
|
333
|
+
if photo:
|
|
334
|
+
photo_path = PROJECT_ROOT / photo
|
|
335
|
+
if not photo_path.exists():
|
|
336
|
+
raise FileNotFoundError(f"Файл с фото не найден: {photo}")
|
|
337
|
+
for user_id, user_data in unique_users.items():
|
|
338
|
+
session_id = user_data["session_id"]
|
|
339
|
+
try:
|
|
340
|
+
if photo_path:
|
|
341
|
+
msg = await bot.send_photo(chat_id=user_id, photo=FSInputFile(str(photo_path)), caption=message_text)
|
|
342
|
+
msg_type = "photo"
|
|
343
|
+
else:
|
|
344
|
+
msg = await bot.send_message(chat_id=user_id, text=message_text)
|
|
345
|
+
msg_type = "text"
|
|
346
|
+
await supabase_client.add_message(
|
|
347
|
+
session_id=session_id,
|
|
348
|
+
role="assistant",
|
|
349
|
+
content=message_text,
|
|
350
|
+
message_type=msg_type,
|
|
351
|
+
metadata={"sent_by_stage_broadcast": True, "target_stage": stage, "broadcast_timestamp": datetime.now().isoformat(), "has_photo": bool(photo)},
|
|
352
|
+
)
|
|
353
|
+
messages_sent += 1
|
|
354
|
+
logger.info(
|
|
355
|
+
f"✅ Сообщение отправлено пользователю {user_id} (стадия: {stage})"
|
|
356
|
+
)
|
|
357
|
+
except Exception as e:
|
|
358
|
+
error_msg = f"Ошибка отправки пользователю {user_id}: {str(e)}"
|
|
359
|
+
errors.append(error_msg)
|
|
360
|
+
logger.error(f"❌ {error_msg}")
|
|
361
|
+
result = {
|
|
362
|
+
"status": "success",
|
|
363
|
+
"stage": stage,
|
|
364
|
+
"users_found": len(unique_users),
|
|
365
|
+
"messages_sent": messages_sent,
|
|
366
|
+
"errors": errors,
|
|
367
|
+
}
|
|
368
|
+
logger.info(
|
|
369
|
+
f"📊 Результат рассылки по стадии '{stage}': {messages_sent}/{len(unique_users)} сообщений отправлено"
|
|
370
|
+
)
|
|
371
|
+
return result
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.error(f"❌ Ошибка в send_message_to_users_by_stage: {e}")
|
|
374
|
+
return {"status": "error", "error": str(e), "stage": stage}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def get_users_by_stage_stats(bot_id: Optional[str] = None) -> Dict[str, Any]:
|
|
378
|
+
"""
|
|
379
|
+
Получает статистику пользователей по стадиям
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
bot_id: ID бота (если не указан, используется текущий бот)
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Статистика по стадиям с количеством пользователей
|
|
386
|
+
"""
|
|
387
|
+
try:
|
|
388
|
+
# Импортируем необходимые компоненты
|
|
389
|
+
from ..handlers.handlers import get_global_var
|
|
390
|
+
|
|
391
|
+
supabase_client = get_global_var("supabase_client")
|
|
392
|
+
current_bot_id = (
|
|
393
|
+
get_global_var("config").BOT_ID if get_global_var("config") else bot_id
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not current_bot_id:
|
|
397
|
+
return {"status": "error", "error": "Не удалось определить bot_id"}
|
|
398
|
+
|
|
399
|
+
logger.info(f"📊 Получаем статистику по стадиям для бота '{current_bot_id}'")
|
|
400
|
+
|
|
401
|
+
# Получаем статистику по стадиям с user_id для подсчета уникальных пользователей
|
|
402
|
+
stats_query = (
|
|
403
|
+
supabase_client.client.table("sales_chat_sessions")
|
|
404
|
+
.select("user_id, current_stage, created_at")
|
|
405
|
+
.eq("status", "active")
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Фильтруем по bot_id если указан
|
|
409
|
+
if current_bot_id:
|
|
410
|
+
stats_query = stats_query.eq("bot_id", current_bot_id)
|
|
411
|
+
|
|
412
|
+
# Сортируем по дате создания (последние сначала)
|
|
413
|
+
stats_query = stats_query.order("created_at", desc=True)
|
|
414
|
+
|
|
415
|
+
sessions_data = stats_query.execute()
|
|
416
|
+
|
|
417
|
+
# Подсчитываем уникальных пользователей по стадиям (берем последнюю сессию каждого пользователя)
|
|
418
|
+
user_stages = {} # {user_id: stage}
|
|
419
|
+
|
|
420
|
+
for session in sessions_data.data:
|
|
421
|
+
user_id = session["user_id"]
|
|
422
|
+
stage = session["current_stage"] or "unknown"
|
|
423
|
+
|
|
424
|
+
# Если пользователь еще не добавлен, добавляем его стадию (первая встреченная - самая последняя)
|
|
425
|
+
if user_id not in user_stages:
|
|
426
|
+
user_stages[user_id] = stage
|
|
427
|
+
|
|
428
|
+
# Подсчитываем количество пользователей по стадиям
|
|
429
|
+
stage_stats = {}
|
|
430
|
+
for stage in user_stages.values():
|
|
431
|
+
stage_stats[stage] = stage_stats.get(stage, 0) + 1
|
|
432
|
+
|
|
433
|
+
total_users = len(user_stages)
|
|
434
|
+
|
|
435
|
+
# Сортируем по количеству пользователей (по убыванию)
|
|
436
|
+
sorted_stages = sorted(stage_stats.items(), key=lambda x: x[1], reverse=True)
|
|
437
|
+
|
|
438
|
+
result = {
|
|
439
|
+
"status": "success",
|
|
440
|
+
"bot_id": current_bot_id,
|
|
441
|
+
"total_active_users": total_users,
|
|
442
|
+
"stages": dict(sorted_stages),
|
|
443
|
+
"stages_list": sorted_stages,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
logger.info(f"📊 Статистика по стадиям: {total_users} активных пользователей")
|
|
447
|
+
for stage, count in sorted_stages:
|
|
448
|
+
logger.info(f" {stage}: {count} пользователей")
|
|
449
|
+
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.error(f"❌ Ошибка в get_users_by_stage_stats: {e}")
|
|
454
|
+
return {"status": "error", "error": str(e), "bot_id": bot_id}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
async def send_message(
|
|
458
|
+
message,
|
|
459
|
+
text: str,
|
|
460
|
+
supabase_client,
|
|
461
|
+
files_list: list = [],
|
|
462
|
+
directories_list: list = [],
|
|
463
|
+
parse_mode: str = "Markdown",
|
|
464
|
+
**kwargs,
|
|
465
|
+
):
|
|
466
|
+
"""
|
|
467
|
+
Пользовательская функция для отправки сообщений с файлами и кнопками
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
message: Message объект от aiogram
|
|
471
|
+
text: Текст сообщения
|
|
472
|
+
supabase_client: SupabaseClient для работы с БД
|
|
473
|
+
files_list: Список файлов для отправки
|
|
474
|
+
directories_list: Список каталогов (отправятся все файлы)
|
|
475
|
+
parse_mode: Режим парсинга ('Markdown', 'HTML' или None)
|
|
476
|
+
**kwargs: Дополнительные параметры (reply_markup и т.д.)
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Message объект отправленного сообщения или None
|
|
480
|
+
|
|
481
|
+
Example:
|
|
482
|
+
from smart_bot_factory.message import send_message
|
|
483
|
+
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|
484
|
+
|
|
485
|
+
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
486
|
+
[InlineKeyboardButton(text="Кнопка", callback_data="action")]
|
|
487
|
+
])
|
|
488
|
+
|
|
489
|
+
await send_message(
|
|
490
|
+
message=message,
|
|
491
|
+
text="Привет!",
|
|
492
|
+
supabase_client=supabase_client,
|
|
493
|
+
files_list=["file.pdf"],
|
|
494
|
+
parse_mode="Markdown",
|
|
495
|
+
reply_markup=keyboard
|
|
496
|
+
)
|
|
497
|
+
"""
|
|
498
|
+
from pathlib import Path
|
|
499
|
+
|
|
500
|
+
from aiogram.types import FSInputFile
|
|
501
|
+
from aiogram.utils.media_group import MediaGroupBuilder
|
|
502
|
+
|
|
503
|
+
logger.info("📤 send_message вызвана:")
|
|
504
|
+
logger.info(f" 👤 Пользователь: {message.from_user.id}")
|
|
505
|
+
logger.info(f" 📝 Длина текста: {len(text)} символов")
|
|
506
|
+
logger.info(f" 🔧 Parse mode: {parse_mode}")
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
user_id = message.from_user.id
|
|
510
|
+
|
|
511
|
+
# Устанавливаем parse_mode (None если передана строка 'None')
|
|
512
|
+
actual_parse_mode = None if parse_mode == "None" else parse_mode
|
|
513
|
+
|
|
514
|
+
# Текст уже готов, используем как есть
|
|
515
|
+
final_text = text
|
|
516
|
+
|
|
517
|
+
# Работаем с переданными файлами и каталогами
|
|
518
|
+
logger.info(f" 📦 Передано файлов: {files_list}")
|
|
519
|
+
logger.info(f" 📂 Передано каталогов: {directories_list}")
|
|
520
|
+
|
|
521
|
+
# Получаем список уже отправленных файлов и каталогов
|
|
522
|
+
sent_files = await supabase_client.get_sent_files(user_id)
|
|
523
|
+
sent_directories = await supabase_client.get_sent_directories(user_id)
|
|
524
|
+
|
|
525
|
+
logger.info(f" 📋 Уже отправлено файлов: {sent_files}")
|
|
526
|
+
logger.info(f" 📋 Уже отправлено каталогов: {sent_directories}")
|
|
527
|
+
|
|
528
|
+
# Фильтруем файлы и каталоги, которые уже отправлялись
|
|
529
|
+
actual_files_list = [f for f in files_list if f not in sent_files]
|
|
530
|
+
actual_directories_list = [
|
|
531
|
+
d for d in directories_list if str(d) not in sent_directories
|
|
532
|
+
]
|
|
533
|
+
|
|
534
|
+
logger.info(f" 🆕 После фильтрации файлов: {actual_files_list}")
|
|
535
|
+
logger.info(f" 🆕 После фильтрации каталогов: {actual_directories_list}")
|
|
536
|
+
|
|
537
|
+
# Проверяем, что есть что отправлять
|
|
538
|
+
if not final_text or not final_text.strip():
|
|
539
|
+
logger.error("❌ КРИТИЧЕСКАЯ ОШИБКА: final_text пуст после обработки!")
|
|
540
|
+
logger.error(f" Исходный text: '{text[:200]}...'")
|
|
541
|
+
final_text = "Ошибка формирования ответа. Попробуйте еще раз."
|
|
542
|
+
|
|
543
|
+
logger.info(f"📱 Подготовка сообщения: {len(final_text)} символов")
|
|
544
|
+
logger.info(f" 📦 Файлов для обработки: {actual_files_list}")
|
|
545
|
+
logger.info(f" 📂 Каталогов для обработки: {actual_directories_list}")
|
|
546
|
+
|
|
547
|
+
# Проверяем наличие файлов для отправки
|
|
548
|
+
if actual_files_list or actual_directories_list:
|
|
549
|
+
# Функция определения типа медиа по расширению
|
|
550
|
+
def get_media_type(file_path: str) -> str:
|
|
551
|
+
ext = Path(file_path).suffix.lower()
|
|
552
|
+
if ext in {".jpg", ".jpeg", ".png"}:
|
|
553
|
+
return "photo"
|
|
554
|
+
elif ext in {".mp4", ".mov"}:
|
|
555
|
+
return "video"
|
|
556
|
+
else:
|
|
557
|
+
return "document"
|
|
558
|
+
|
|
559
|
+
# Создаем списки для разных типов файлов
|
|
560
|
+
video_files = []
|
|
561
|
+
photo_files = []
|
|
562
|
+
document_files = []
|
|
563
|
+
|
|
564
|
+
# Функция обработки файла
|
|
565
|
+
def process_file(file_path: Path, source: str = ""):
|
|
566
|
+
if file_path.is_file():
|
|
567
|
+
media_type = get_media_type(str(file_path))
|
|
568
|
+
if media_type == "video":
|
|
569
|
+
video_files.append(file_path)
|
|
570
|
+
logger.info(
|
|
571
|
+
f" 🎥 Добавлено видео{f' из {source}' if source else ''}: {file_path.name}"
|
|
572
|
+
)
|
|
573
|
+
elif media_type == "photo":
|
|
574
|
+
photo_files.append(file_path)
|
|
575
|
+
logger.info(
|
|
576
|
+
f" 📸 Добавлено фото{f' из {source}' if source else ''}: {file_path.name}"
|
|
577
|
+
)
|
|
578
|
+
else:
|
|
579
|
+
document_files.append(file_path)
|
|
580
|
+
logger.info(
|
|
581
|
+
f" 📄 Добавлен документ{f' из {source}' if source else ''}: {file_path.name}"
|
|
582
|
+
)
|
|
583
|
+
else:
|
|
584
|
+
logger.warning(f" ⚠️ Файл не найден: {file_path}")
|
|
585
|
+
|
|
586
|
+
# Обрабатываем прямые файлы
|
|
587
|
+
for file_name in actual_files_list:
|
|
588
|
+
try:
|
|
589
|
+
process_file(Path(f"files/{file_name}"))
|
|
590
|
+
except Exception as e:
|
|
591
|
+
logger.error(f" ❌ Ошибка обработки файла {file_name}: {e}")
|
|
592
|
+
|
|
593
|
+
# Обрабатываем файлы из каталогов
|
|
594
|
+
for dir_name in actual_directories_list:
|
|
595
|
+
dir_name = Path(dir_name)
|
|
596
|
+
try:
|
|
597
|
+
if dir_name.is_dir():
|
|
598
|
+
for file_path in dir_name.iterdir():
|
|
599
|
+
try:
|
|
600
|
+
process_file(file_path, dir_name)
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logger.error(
|
|
603
|
+
f" ❌ Ошибка обработки файла {file_path}: {e}"
|
|
604
|
+
)
|
|
605
|
+
else:
|
|
606
|
+
logger.warning(f" ⚠️ Каталог не найден: {dir_name}")
|
|
607
|
+
except Exception as e:
|
|
608
|
+
logger.error(f" ❌ Ошибка обработки каталога {dir_name}: {e}")
|
|
609
|
+
|
|
610
|
+
# Списки для отслеживания реально отправленных файлов
|
|
611
|
+
sent_files_to_save = []
|
|
612
|
+
sent_dirs_to_save = []
|
|
613
|
+
|
|
614
|
+
# 1. Отправляем видео (если есть)
|
|
615
|
+
if video_files:
|
|
616
|
+
video_group = MediaGroupBuilder()
|
|
617
|
+
for file_path in video_files:
|
|
618
|
+
video_group.add_video(media=FSInputFile(str(file_path)))
|
|
619
|
+
|
|
620
|
+
videos = video_group.build()
|
|
621
|
+
if videos:
|
|
622
|
+
await message.answer_media_group(media=videos)
|
|
623
|
+
logger.info(f" ✅ Отправлено {len(videos)} видео")
|
|
624
|
+
|
|
625
|
+
# 2. Отправляем фото (если есть)
|
|
626
|
+
if photo_files:
|
|
627
|
+
photo_group = MediaGroupBuilder()
|
|
628
|
+
for file_path in photo_files:
|
|
629
|
+
photo_group.add_photo(media=FSInputFile(str(file_path)))
|
|
630
|
+
|
|
631
|
+
photos = photo_group.build()
|
|
632
|
+
if photos:
|
|
633
|
+
await message.answer_media_group(media=photos)
|
|
634
|
+
logger.info(f" ✅ Отправлено {len(photos)} фото")
|
|
635
|
+
|
|
636
|
+
# 3. Отправляем текст
|
|
637
|
+
result = await message.answer(
|
|
638
|
+
final_text, parse_mode=actual_parse_mode, **kwargs
|
|
639
|
+
)
|
|
640
|
+
logger.info(" ✅ Отправлен текст сообщения")
|
|
641
|
+
|
|
642
|
+
# 4. Отправляем документы (если есть)
|
|
643
|
+
if document_files:
|
|
644
|
+
doc_group = MediaGroupBuilder()
|
|
645
|
+
for file_path in document_files:
|
|
646
|
+
doc_group.add_document(media=FSInputFile(str(file_path)))
|
|
647
|
+
|
|
648
|
+
docs = doc_group.build()
|
|
649
|
+
if docs:
|
|
650
|
+
await message.answer_media_group(media=docs)
|
|
651
|
+
logger.info(f" ✅ Отправлено {len(docs)} документов")
|
|
652
|
+
|
|
653
|
+
# 5. Собираем список реально отправленных файлов и каталогов
|
|
654
|
+
if video_files or photo_files or document_files:
|
|
655
|
+
sent_files_to_save.extend(actual_files_list)
|
|
656
|
+
logger.info(
|
|
657
|
+
f" 📝 Добавляем в список для сохранения файлы: {actual_files_list}"
|
|
658
|
+
)
|
|
659
|
+
sent_dirs_to_save.extend([str(d) for d in actual_directories_list])
|
|
660
|
+
logger.info(
|
|
661
|
+
f" 📝 Добавляем в список для сохранения каталоги: {actual_directories_list}"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
# 6. Обновляем информацию в БД
|
|
665
|
+
if sent_files_to_save or sent_dirs_to_save:
|
|
666
|
+
try:
|
|
667
|
+
if sent_files_to_save:
|
|
668
|
+
logger.info(f" 💾 Сохраняем файлы в БД: {sent_files_to_save}")
|
|
669
|
+
await supabase_client.add_sent_files(
|
|
670
|
+
user_id, sent_files_to_save
|
|
671
|
+
)
|
|
672
|
+
if sent_dirs_to_save:
|
|
673
|
+
logger.info(
|
|
674
|
+
f" 💾 Сохраняем каталоги в БД: {sent_dirs_to_save}"
|
|
675
|
+
)
|
|
676
|
+
await supabase_client.add_sent_directories(
|
|
677
|
+
user_id, sent_dirs_to_save
|
|
678
|
+
)
|
|
679
|
+
logger.info(
|
|
680
|
+
" ✅ Обновлена информация о отправленных файлах в БД"
|
|
681
|
+
)
|
|
682
|
+
except Exception as e:
|
|
683
|
+
logger.error(
|
|
684
|
+
f" ❌ Ошибка обновления информации о файлах в БД: {e}"
|
|
685
|
+
)
|
|
686
|
+
else:
|
|
687
|
+
logger.info(" ℹ️ Нет новых файлов для сохранения в БД")
|
|
688
|
+
|
|
689
|
+
return result
|
|
690
|
+
else:
|
|
691
|
+
# Если нет файлов, отправляем просто текст
|
|
692
|
+
logger.info(" ⚠️ Нет файлов для отправки, отправляем как текст")
|
|
693
|
+
result = await message.answer(
|
|
694
|
+
final_text, parse_mode=actual_parse_mode, **kwargs
|
|
695
|
+
)
|
|
696
|
+
return result
|
|
697
|
+
|
|
698
|
+
except Exception as e:
|
|
699
|
+
# Проверяем, является ли ошибка блокировкой бота
|
|
700
|
+
if "Forbidden: bot was blocked by the user" in str(e):
|
|
701
|
+
logger.warning(f"🚫 Бот заблокирован пользователем {user_id}")
|
|
702
|
+
return None
|
|
703
|
+
elif "TelegramForbiddenError" in str(type(e).__name__):
|
|
704
|
+
logger.warning(f"🚫 Бот заблокирован пользователем {user_id}")
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
logger.error(f"❌ ОШИБКА в send_message: {e}")
|
|
708
|
+
logger.exception("Полный стек ошибки send_message:")
|
|
709
|
+
|
|
710
|
+
# Пытаемся отправить простое сообщение без форматирования
|
|
711
|
+
try:
|
|
712
|
+
fallback_text = "Произошла ошибка при отправке ответа. Попробуйте еще раз."
|
|
713
|
+
result = await message.answer(fallback_text)
|
|
714
|
+
logger.info("✅ Запасное сообщение отправлено")
|
|
715
|
+
return result
|
|
716
|
+
except Exception as e2:
|
|
717
|
+
if "Forbidden: bot was blocked by the user" in str(e2):
|
|
718
|
+
logger.warning(
|
|
719
|
+
f"🚫 Бот заблокирован пользователем {user_id} (fallback)"
|
|
720
|
+
)
|
|
721
|
+
return None
|
|
722
|
+
elif "TelegramForbiddenError" in str(type(e2).__name__):
|
|
723
|
+
logger.warning(
|
|
724
|
+
f"🚫 Бот заблокирован пользователем {user_id} (fallback)"
|
|
725
|
+
)
|
|
726
|
+
return None
|
|
727
|
+
|
|
728
|
+
logger.error(f"❌ Даже запасное сообщение не отправилось: {e2}")
|
|
729
|
+
raise
|