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,1678 @@
|
|
|
1
|
+
# Обновленный supabase_client.py с поддержкой bot_id и обратной совместимостью
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from postgrest.exceptions import APIError
|
|
8
|
+
from supabase import Client, create_client
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SupabaseClient:
|
|
14
|
+
"""Клиент для работы с Supabase с поддержкой bot_id для мультиботовой архитектуры"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, url: str, key: str, bot_id: str = None):
|
|
17
|
+
"""
|
|
18
|
+
Инициализация клиента Supabase
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
url: URL Supabase проекта
|
|
22
|
+
key: API ключ Supabase
|
|
23
|
+
bot_id: Идентификатор бота для изоляции данных (опционально для обратной совместимости)
|
|
24
|
+
"""
|
|
25
|
+
self.url = url
|
|
26
|
+
self.key = key
|
|
27
|
+
self.bot_id = bot_id # 🆕 Теперь опционально!
|
|
28
|
+
self.client: Optional[Client] = None
|
|
29
|
+
|
|
30
|
+
if self.bot_id:
|
|
31
|
+
logger.info(f"Инициализация SupabaseClient для bot_id: {self.bot_id}")
|
|
32
|
+
else:
|
|
33
|
+
logger.warning(
|
|
34
|
+
"SupabaseClient инициализирован БЕЗ bot_id - мультиботовая изоляция отключена"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async def initialize(self):
|
|
38
|
+
"""Инициализация клиента Supabase"""
|
|
39
|
+
try:
|
|
40
|
+
self.client = create_client(self.url, self.key)
|
|
41
|
+
logger.info(
|
|
42
|
+
f"Supabase client инициализирован{f' для bot_id: {self.bot_id}' if self.bot_id else ''}"
|
|
43
|
+
)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error(f"Ошибка инициализации Supabase client: {e}")
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
async def create_or_get_user(self, user_data: Dict[str, Any]) -> int:
|
|
49
|
+
"""Создает или получает пользователя с учетом bot_id (если указан)"""
|
|
50
|
+
try:
|
|
51
|
+
# 🆕 Если bot_id указан, фильтруем по нему
|
|
52
|
+
query = (
|
|
53
|
+
self.client.table("sales_users")
|
|
54
|
+
.select("telegram_id")
|
|
55
|
+
.eq("telegram_id", user_data["telegram_id"])
|
|
56
|
+
)
|
|
57
|
+
if self.bot_id:
|
|
58
|
+
query = query.eq("bot_id", self.bot_id)
|
|
59
|
+
|
|
60
|
+
response = query.execute()
|
|
61
|
+
|
|
62
|
+
if response.data:
|
|
63
|
+
# Получаем текущие данные пользователя для мержинга UTM и сегментов
|
|
64
|
+
existing_user_query = (
|
|
65
|
+
self.client.table("sales_users")
|
|
66
|
+
.select(
|
|
67
|
+
"source", "medium", "campaign", "content", "term", "segments"
|
|
68
|
+
)
|
|
69
|
+
.eq("telegram_id", user_data["telegram_id"])
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if self.bot_id:
|
|
73
|
+
existing_user_query = existing_user_query.eq("bot_id", self.bot_id)
|
|
74
|
+
|
|
75
|
+
existing_response = existing_user_query.execute()
|
|
76
|
+
existing_utm = (
|
|
77
|
+
existing_response.data[0] if existing_response.data else {}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Формируем данные для обновления
|
|
81
|
+
update_data = {
|
|
82
|
+
"username": user_data.get("username"),
|
|
83
|
+
"first_name": user_data.get("first_name"),
|
|
84
|
+
"last_name": user_data.get("last_name"),
|
|
85
|
+
"language_code": user_data.get("language_code"),
|
|
86
|
+
"updated_at": datetime.now().isoformat(),
|
|
87
|
+
"is_active": True,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Мержим UTM данные: обновляем только если новое значение не None
|
|
91
|
+
utm_fields = ["source", "medium", "campaign", "content", "term"]
|
|
92
|
+
for field in utm_fields:
|
|
93
|
+
new_value = user_data.get(field)
|
|
94
|
+
if new_value is not None:
|
|
95
|
+
# Есть новое значение - обновляем
|
|
96
|
+
update_data[field] = new_value
|
|
97
|
+
if existing_utm.get(field) != new_value:
|
|
98
|
+
logger.info(
|
|
99
|
+
f"📊 UTM обновление: {field} = '{new_value}' (было: '{existing_utm.get(field)}')"
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
# Нового значения нет - сохраняем старое
|
|
103
|
+
update_data[field] = existing_utm.get(field)
|
|
104
|
+
|
|
105
|
+
# Обрабатываем сегменты с накоплением через запятую
|
|
106
|
+
new_segment = user_data.get("segment")
|
|
107
|
+
if new_segment:
|
|
108
|
+
existing_segments = existing_utm.get("segments", "") or ""
|
|
109
|
+
if existing_segments:
|
|
110
|
+
# Разбираем существующие сегменты
|
|
111
|
+
segments_list = [
|
|
112
|
+
s.strip() for s in existing_segments.split(",") if s.strip()
|
|
113
|
+
]
|
|
114
|
+
# Добавляем новый сегмент, если его еще нет
|
|
115
|
+
if new_segment not in segments_list:
|
|
116
|
+
segments_list.append(new_segment)
|
|
117
|
+
update_data["segments"] = ", ".join(segments_list)
|
|
118
|
+
logger.info(
|
|
119
|
+
f"📊 Сегмент добавлен: '{new_segment}' (было: '{existing_segments}')"
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
update_data["segments"] = existing_segments
|
|
123
|
+
logger.info(f"📊 Сегмент '{new_segment}' уже существует")
|
|
124
|
+
else:
|
|
125
|
+
# Первый сегмент
|
|
126
|
+
update_data["segments"] = new_segment
|
|
127
|
+
logger.info(f"📊 Первый сегмент добавлен: '{new_segment}'")
|
|
128
|
+
else:
|
|
129
|
+
# Нового сегмента нет - сохраняем старое значение
|
|
130
|
+
update_data["segments"] = existing_utm.get("segments")
|
|
131
|
+
|
|
132
|
+
# Обновляем пользователя
|
|
133
|
+
update_query = (
|
|
134
|
+
self.client.table("sales_users")
|
|
135
|
+
.update(update_data)
|
|
136
|
+
.eq("telegram_id", user_data["telegram_id"])
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if self.bot_id:
|
|
140
|
+
update_query = update_query.eq("bot_id", self.bot_id)
|
|
141
|
+
|
|
142
|
+
update_query.execute()
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
f"Обновлен пользователь {user_data['telegram_id']}{f' для bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
146
|
+
)
|
|
147
|
+
return user_data["telegram_id"]
|
|
148
|
+
else:
|
|
149
|
+
# 🆕 Создаем нового пользователя с bot_id (если указан)
|
|
150
|
+
user_insert_data = {
|
|
151
|
+
"telegram_id": user_data["telegram_id"],
|
|
152
|
+
"username": user_data.get("username"),
|
|
153
|
+
"first_name": user_data.get("first_name"),
|
|
154
|
+
"last_name": user_data.get("last_name"),
|
|
155
|
+
"language_code": user_data.get("language_code"),
|
|
156
|
+
"is_active": True,
|
|
157
|
+
"source": user_data.get("source"),
|
|
158
|
+
"medium": user_data.get("medium"),
|
|
159
|
+
"campaign": user_data.get("campaign"),
|
|
160
|
+
"content": user_data.get("content"),
|
|
161
|
+
"term": user_data.get("term"),
|
|
162
|
+
"segments": user_data.get("segment"), # Первый сегмент при создании
|
|
163
|
+
}
|
|
164
|
+
if self.bot_id:
|
|
165
|
+
user_insert_data["bot_id"] = self.bot_id
|
|
166
|
+
|
|
167
|
+
response = (
|
|
168
|
+
self.client.table("sales_users").insert(user_insert_data).execute()
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if user_data.get("segment"):
|
|
172
|
+
logger.info(
|
|
173
|
+
f"Создан новый пользователь {user_data['telegram_id']} с сегментом '{user_data.get('segment')}'{f' для bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
logger.info(
|
|
177
|
+
f"Создан новый пользователь {user_data['telegram_id']}{f' для bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
178
|
+
)
|
|
179
|
+
return user_data["telegram_id"]
|
|
180
|
+
|
|
181
|
+
except APIError as e:
|
|
182
|
+
logger.error(f"Ошибка при работе с пользователем: {e}")
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
async def create_chat_session(
|
|
186
|
+
self, user_data: Dict[str, Any], system_prompt: str
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Создает новую сессию чата с учетом bot_id (если указан)"""
|
|
189
|
+
try:
|
|
190
|
+
# Создаем или обновляем пользователя
|
|
191
|
+
user_id = await self.create_or_get_user(user_data)
|
|
192
|
+
|
|
193
|
+
# 🆕 Завершаем активные сессии пользователя (с учетом bot_id)
|
|
194
|
+
await self.close_active_sessions(user_id)
|
|
195
|
+
|
|
196
|
+
# 🆕 Создаем новую сессию с bot_id (если указан)
|
|
197
|
+
session_data = {
|
|
198
|
+
"user_id": user_id,
|
|
199
|
+
"system_prompt": system_prompt,
|
|
200
|
+
"status": "active",
|
|
201
|
+
"current_stage": "introduction",
|
|
202
|
+
"lead_quality_score": 5,
|
|
203
|
+
"metadata": {
|
|
204
|
+
"user_agent": user_data.get("user_agent", ""),
|
|
205
|
+
"start_timestamp": datetime.now().isoformat(),
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
if self.bot_id:
|
|
209
|
+
session_data["bot_id"] = self.bot_id
|
|
210
|
+
session_data["metadata"]["bot_id"] = self.bot_id
|
|
211
|
+
|
|
212
|
+
response = (
|
|
213
|
+
self.client.table("sales_chat_sessions").insert(session_data).execute()
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
session_id = response.data[0]["id"]
|
|
217
|
+
|
|
218
|
+
# Создаем запись аналитики
|
|
219
|
+
await self.create_session_analytics(session_id)
|
|
220
|
+
|
|
221
|
+
logger.info(
|
|
222
|
+
f"Создана новая сессия {session_id} для пользователя {user_id}{f', bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
223
|
+
)
|
|
224
|
+
return session_id
|
|
225
|
+
|
|
226
|
+
except APIError as e:
|
|
227
|
+
logger.error(f"Ошибка при создании сессии: {e}")
|
|
228
|
+
raise
|
|
229
|
+
|
|
230
|
+
async def close_active_sessions(self, user_id: int):
|
|
231
|
+
"""Закрывает активные сессии пользователя с учетом bot_id (если указан)"""
|
|
232
|
+
try:
|
|
233
|
+
# 🆕 Закрываем только сессии этого бота (если bot_id указан)
|
|
234
|
+
query = (
|
|
235
|
+
self.client.table("sales_chat_sessions")
|
|
236
|
+
.update(
|
|
237
|
+
{"status": "completed", "updated_at": datetime.now().isoformat()}
|
|
238
|
+
)
|
|
239
|
+
.eq("user_id", user_id)
|
|
240
|
+
.eq("status", "active")
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if self.bot_id:
|
|
244
|
+
query = query.eq("bot_id", self.bot_id)
|
|
245
|
+
|
|
246
|
+
query.execute()
|
|
247
|
+
|
|
248
|
+
logger.info(
|
|
249
|
+
f"Закрыты активные сессии для пользователя {user_id}{f', bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
except APIError as e:
|
|
253
|
+
logger.error(f"Ошибка при закрытии сессий: {e}")
|
|
254
|
+
raise
|
|
255
|
+
|
|
256
|
+
async def get_active_session(self, telegram_id: int) -> Optional[Dict[str, Any]]:
|
|
257
|
+
"""Получает активную сессию пользователя с учетом bot_id (если указан)"""
|
|
258
|
+
try:
|
|
259
|
+
# 🆕 Ищем активную сессию с учетом bot_id (если указан)
|
|
260
|
+
query = (
|
|
261
|
+
self.client.table("sales_chat_sessions")
|
|
262
|
+
.select(
|
|
263
|
+
"id",
|
|
264
|
+
"system_prompt",
|
|
265
|
+
"created_at",
|
|
266
|
+
"current_stage",
|
|
267
|
+
"lead_quality_score",
|
|
268
|
+
)
|
|
269
|
+
.eq("user_id", telegram_id)
|
|
270
|
+
.eq("status", "active")
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if self.bot_id:
|
|
274
|
+
query = query.eq("bot_id", self.bot_id)
|
|
275
|
+
|
|
276
|
+
response = query.execute()
|
|
277
|
+
|
|
278
|
+
if response.data:
|
|
279
|
+
session_info = response.data[0]
|
|
280
|
+
logger.info(
|
|
281
|
+
f"Найдена активная сессия {session_info['id']} для пользователя {telegram_id}{f', bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
282
|
+
)
|
|
283
|
+
return session_info
|
|
284
|
+
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
except APIError as e:
|
|
288
|
+
logger.error(f"Ошибка при поиске активной сессии: {e}")
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
async def create_session_analytics(self, session_id: str):
|
|
292
|
+
"""Создает запись аналитики для сессии"""
|
|
293
|
+
try:
|
|
294
|
+
self.client.table("sales_session_analytics").insert(
|
|
295
|
+
{
|
|
296
|
+
"session_id": session_id,
|
|
297
|
+
"total_messages": 0,
|
|
298
|
+
"total_tokens": 0,
|
|
299
|
+
"average_response_time_ms": 0,
|
|
300
|
+
"conversion_stage": "initial",
|
|
301
|
+
"lead_quality_score": 5,
|
|
302
|
+
}
|
|
303
|
+
).execute()
|
|
304
|
+
|
|
305
|
+
logger.debug(f"Создана аналитика для сессии {session_id}")
|
|
306
|
+
|
|
307
|
+
except APIError as e:
|
|
308
|
+
logger.error(f"Ошибка при создании аналитики: {e}")
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
async def add_message(
|
|
312
|
+
self,
|
|
313
|
+
session_id: str,
|
|
314
|
+
role: str,
|
|
315
|
+
content: str,
|
|
316
|
+
message_type: str = "text",
|
|
317
|
+
tokens_used: int = 0,
|
|
318
|
+
processing_time_ms: int = 0,
|
|
319
|
+
metadata: Dict[str, Any] = None,
|
|
320
|
+
ai_metadata: Dict[str, Any] = None,
|
|
321
|
+
) -> int:
|
|
322
|
+
"""Добавляет сообщение в базу данных"""
|
|
323
|
+
try:
|
|
324
|
+
response = (
|
|
325
|
+
self.client.table("sales_messages")
|
|
326
|
+
.insert(
|
|
327
|
+
{
|
|
328
|
+
"session_id": session_id,
|
|
329
|
+
"role": role,
|
|
330
|
+
"content": content,
|
|
331
|
+
"message_type": message_type,
|
|
332
|
+
"tokens_used": tokens_used,
|
|
333
|
+
"processing_time_ms": processing_time_ms,
|
|
334
|
+
"metadata": metadata or {},
|
|
335
|
+
"ai_metadata": ai_metadata or {},
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
.execute()
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
message_id = response.data[0]["id"]
|
|
342
|
+
|
|
343
|
+
# Обновляем аналитику сессии
|
|
344
|
+
await self.update_session_analytics(
|
|
345
|
+
session_id, tokens_used, processing_time_ms
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
logger.debug(f"Добавлено сообщение {message_id} в сессию {session_id}")
|
|
349
|
+
return message_id
|
|
350
|
+
|
|
351
|
+
except APIError as e:
|
|
352
|
+
logger.error(f"Ошибка при добавлении сообщения: {e}")
|
|
353
|
+
raise
|
|
354
|
+
|
|
355
|
+
async def get_chat_history(
|
|
356
|
+
self, session_id: str, limit: int = 50
|
|
357
|
+
) -> List[Dict[str, Any]]:
|
|
358
|
+
"""Получает историю сообщений для сессии"""
|
|
359
|
+
try:
|
|
360
|
+
response = (
|
|
361
|
+
self.client.table("sales_messages")
|
|
362
|
+
.select(
|
|
363
|
+
"id",
|
|
364
|
+
"role",
|
|
365
|
+
"content",
|
|
366
|
+
"message_type",
|
|
367
|
+
"created_at",
|
|
368
|
+
"metadata",
|
|
369
|
+
"ai_metadata",
|
|
370
|
+
)
|
|
371
|
+
.eq("session_id", session_id)
|
|
372
|
+
.order("created_at", desc=True)
|
|
373
|
+
.limit(limit)
|
|
374
|
+
.execute()
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Фильтруем системные сообщения из истории
|
|
378
|
+
messages = [msg for msg in response.data if msg["role"] != "system"]
|
|
379
|
+
|
|
380
|
+
# Переворачиваем в хронологический порядок (старые -> новые)
|
|
381
|
+
messages.reverse()
|
|
382
|
+
|
|
383
|
+
logger.debug(f"Получено {len(messages)} сообщений для сессии {session_id}")
|
|
384
|
+
return messages
|
|
385
|
+
|
|
386
|
+
except APIError as e:
|
|
387
|
+
logger.error(f"Ошибка при получении истории: {e}")
|
|
388
|
+
raise
|
|
389
|
+
|
|
390
|
+
async def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
391
|
+
"""Получает информацию о сессии с проверкой bot_id (если указан)"""
|
|
392
|
+
try:
|
|
393
|
+
response = (
|
|
394
|
+
self.client.table("sales_chat_sessions")
|
|
395
|
+
.select(
|
|
396
|
+
"id",
|
|
397
|
+
"user_id",
|
|
398
|
+
"bot_id",
|
|
399
|
+
"system_prompt",
|
|
400
|
+
"status",
|
|
401
|
+
"created_at",
|
|
402
|
+
"metadata",
|
|
403
|
+
"current_stage",
|
|
404
|
+
"lead_quality_score",
|
|
405
|
+
'summary',
|
|
406
|
+
'messages_len'
|
|
407
|
+
)
|
|
408
|
+
.eq("id", session_id)
|
|
409
|
+
.execute()
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
if response.data:
|
|
413
|
+
session = response.data[0]
|
|
414
|
+
# 🆕 Дополнительная проверка bot_id для безопасности (если указан)
|
|
415
|
+
if self.bot_id and session.get("bot_id") != self.bot_id:
|
|
416
|
+
logger.warning(
|
|
417
|
+
f"Попытка доступа к сессии {session_id} другого бота: {session.get('bot_id')} != {self.bot_id}"
|
|
418
|
+
)
|
|
419
|
+
return None
|
|
420
|
+
return session
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
except APIError as e:
|
|
424
|
+
logger.error(f"Ошибка при получении информации о сессии: {e}")
|
|
425
|
+
raise
|
|
426
|
+
|
|
427
|
+
async def update_session(
|
|
428
|
+
self, session_id: str, updates: Dict[str, Any]
|
|
429
|
+
) -> Optional[Dict[str, Any]]:
|
|
430
|
+
"""Обновляет данные сессии с учетом bot_id (если указан)"""
|
|
431
|
+
if not updates:
|
|
432
|
+
logger.warning(f"Пустое обновление для сессии {session_id} пропущено")
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
payload = updates.copy()
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
query = (
|
|
439
|
+
self.client.table("sales_chat_sessions")
|
|
440
|
+
.update(payload)
|
|
441
|
+
.eq("id", session_id)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if self.bot_id:
|
|
445
|
+
query = query.eq("bot_id", self.bot_id)
|
|
446
|
+
|
|
447
|
+
response = query.execute()
|
|
448
|
+
|
|
449
|
+
if not response.data:
|
|
450
|
+
logger.warning(
|
|
451
|
+
f"Не удалось обновить сессию {session_id}{f' для bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
452
|
+
)
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
return response.data[0]
|
|
456
|
+
|
|
457
|
+
except APIError as e:
|
|
458
|
+
logger.error(f"Ошибка при обновлении сессии {session_id}: {e}")
|
|
459
|
+
raise
|
|
460
|
+
|
|
461
|
+
async def update_session_stage(
|
|
462
|
+
self, session_id: str, stage: str = None, quality_score: int = None
|
|
463
|
+
):
|
|
464
|
+
"""Обновляет этап сессии и качество лида"""
|
|
465
|
+
try:
|
|
466
|
+
update_data = {"updated_at": datetime.now().isoformat()}
|
|
467
|
+
|
|
468
|
+
if stage:
|
|
469
|
+
update_data["current_stage"] = stage
|
|
470
|
+
if quality_score is not None:
|
|
471
|
+
update_data["lead_quality_score"] = quality_score
|
|
472
|
+
|
|
473
|
+
# 🆕 Дополнительная проверка bot_id при обновлении (если указан)
|
|
474
|
+
if self.bot_id:
|
|
475
|
+
response = (
|
|
476
|
+
self.client.table("sales_chat_sessions")
|
|
477
|
+
.select("bot_id")
|
|
478
|
+
.eq("id", session_id)
|
|
479
|
+
.execute()
|
|
480
|
+
)
|
|
481
|
+
if response.data and response.data[0].get("bot_id") != self.bot_id:
|
|
482
|
+
logger.warning(
|
|
483
|
+
f"Попытка обновления сессии {session_id} другого бота"
|
|
484
|
+
)
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
self.client.table("sales_chat_sessions").update(update_data).eq(
|
|
488
|
+
"id", session_id
|
|
489
|
+
).execute()
|
|
490
|
+
|
|
491
|
+
logger.debug(
|
|
492
|
+
f"Обновлен этап сессии {session_id}: stage={stage}, quality={quality_score}"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
except APIError as e:
|
|
496
|
+
logger.error(f"Ошибка при обновлении этапа сессии: {e}")
|
|
497
|
+
raise
|
|
498
|
+
|
|
499
|
+
async def get_user_sessions(self, telegram_id: int) -> List[Dict[str, Any]]:
|
|
500
|
+
"""Получает все сессии пользователя с учетом bot_id (если указан)"""
|
|
501
|
+
try:
|
|
502
|
+
# 🆕 Получаем только сессии этого бота (если bot_id указан)
|
|
503
|
+
query = (
|
|
504
|
+
self.client.table("sales_chat_sessions")
|
|
505
|
+
.select(
|
|
506
|
+
"id",
|
|
507
|
+
"status",
|
|
508
|
+
"created_at",
|
|
509
|
+
"updated_at",
|
|
510
|
+
"current_stage",
|
|
511
|
+
"lead_quality_score",
|
|
512
|
+
)
|
|
513
|
+
.eq("user_id", telegram_id)
|
|
514
|
+
.order("created_at", desc=True)
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
if self.bot_id:
|
|
518
|
+
query = query.eq("bot_id", self.bot_id)
|
|
519
|
+
|
|
520
|
+
response = query.execute()
|
|
521
|
+
return response.data
|
|
522
|
+
|
|
523
|
+
except APIError as e:
|
|
524
|
+
logger.error(f"Ошибка при получении сессий пользователя: {e}")
|
|
525
|
+
raise
|
|
526
|
+
|
|
527
|
+
# 🆕 Новые методы для админской системы с поддержкой bot_id
|
|
528
|
+
|
|
529
|
+
async def add_session_event(
|
|
530
|
+
self, session_id: str, event_type: str, event_info: str
|
|
531
|
+
) -> int:
|
|
532
|
+
"""Добавляет событие в сессию"""
|
|
533
|
+
try:
|
|
534
|
+
response = (
|
|
535
|
+
self.client.table("session_events")
|
|
536
|
+
.insert(
|
|
537
|
+
{
|
|
538
|
+
"session_id": session_id,
|
|
539
|
+
"event_type": event_type,
|
|
540
|
+
"event_info": event_info,
|
|
541
|
+
"notified_admins": [],
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
.execute()
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
event_id = response.data[0]["id"]
|
|
548
|
+
logger.info(f"Добавлено событие {event_type} для сессии {session_id}")
|
|
549
|
+
return event_id
|
|
550
|
+
|
|
551
|
+
except APIError as e:
|
|
552
|
+
logger.error(f"Ошибка при добавлении события: {e}")
|
|
553
|
+
raise
|
|
554
|
+
|
|
555
|
+
async def sync_admin(self, admin_data: Dict[str, Any]):
|
|
556
|
+
"""Синхронизирует админа в БД (админы общие для всех ботов)"""
|
|
557
|
+
try:
|
|
558
|
+
# Проверяем существует ли админ
|
|
559
|
+
response = (
|
|
560
|
+
self.client.table("sales_admins")
|
|
561
|
+
.select("telegram_id")
|
|
562
|
+
.eq(
|
|
563
|
+
"telegram_id",
|
|
564
|
+
admin_data["telegram_id"],
|
|
565
|
+
)
|
|
566
|
+
.eq("bot_id", self.bot_id)
|
|
567
|
+
.execute()
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if response.data:
|
|
571
|
+
# Обновляем существующего
|
|
572
|
+
self.client.table("sales_admins").update(
|
|
573
|
+
{
|
|
574
|
+
"username": admin_data.get("username"),
|
|
575
|
+
"first_name": admin_data.get("first_name"),
|
|
576
|
+
"last_name": admin_data.get("last_name"),
|
|
577
|
+
"is_active": True,
|
|
578
|
+
}
|
|
579
|
+
).eq("telegram_id", admin_data["telegram_id"]).eq(
|
|
580
|
+
"bot_id", self.bot_id
|
|
581
|
+
).execute()
|
|
582
|
+
|
|
583
|
+
logger.debug(f"Обновлен админ {admin_data['telegram_id']}")
|
|
584
|
+
else:
|
|
585
|
+
# Создаем нового
|
|
586
|
+
self.client.table("sales_admins").insert(
|
|
587
|
+
{
|
|
588
|
+
"telegram_id": admin_data["telegram_id"],
|
|
589
|
+
"bot_id": self.bot_id,
|
|
590
|
+
"username": admin_data.get("username"),
|
|
591
|
+
"first_name": admin_data.get("first_name"),
|
|
592
|
+
"last_name": admin_data.get("last_name"),
|
|
593
|
+
"role": "admin",
|
|
594
|
+
"is_active": True,
|
|
595
|
+
}
|
|
596
|
+
).execute()
|
|
597
|
+
|
|
598
|
+
logger.info(f"Создан новый админ {admin_data['telegram_id']}")
|
|
599
|
+
|
|
600
|
+
except APIError as e:
|
|
601
|
+
logger.error(f"Ошибка при синхронизации админа: {e}")
|
|
602
|
+
raise
|
|
603
|
+
|
|
604
|
+
async def start_admin_conversation(
|
|
605
|
+
self, admin_id: int, user_id: int, session_id: str
|
|
606
|
+
) -> int:
|
|
607
|
+
"""Начинает диалог между админом и пользователем"""
|
|
608
|
+
try:
|
|
609
|
+
# Проверяем существование пользователя с правильным bot_id
|
|
610
|
+
user_query = self.client.table("sales_users").select("telegram_id").eq("telegram_id", user_id)
|
|
611
|
+
if self.bot_id:
|
|
612
|
+
user_query = user_query.eq("bot_id", self.bot_id)
|
|
613
|
+
|
|
614
|
+
user_response = user_query.execute()
|
|
615
|
+
if not user_response.data:
|
|
616
|
+
logger.error(f"❌ Пользователь {user_id} не найден в sales_users{f' для bot_id {self.bot_id}' if self.bot_id else ''}")
|
|
617
|
+
raise APIError("User not found in sales_users")
|
|
618
|
+
|
|
619
|
+
# Завершаем активные диалоги этого админа
|
|
620
|
+
await self.end_admin_conversations(admin_id)
|
|
621
|
+
|
|
622
|
+
# Готовим данные для записи
|
|
623
|
+
conversation_data = {
|
|
624
|
+
"admin_id": admin_id,
|
|
625
|
+
"user_id": user_id,
|
|
626
|
+
"session_id": session_id,
|
|
627
|
+
"status": "active",
|
|
628
|
+
"auto_end_at": (
|
|
629
|
+
datetime.now(timezone.utc) + timedelta(minutes=30)
|
|
630
|
+
).isoformat(),
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
# Добавляем bot_id если он указан
|
|
634
|
+
if self.bot_id:
|
|
635
|
+
conversation_data["bot_id"] = self.bot_id
|
|
636
|
+
|
|
637
|
+
response = (
|
|
638
|
+
self.client.table("admin_user_conversations")
|
|
639
|
+
.insert(conversation_data)
|
|
640
|
+
.execute()
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
conversation_id = response.data[0]["id"]
|
|
644
|
+
logger.info(
|
|
645
|
+
f"Начат диалог {conversation_id}: админ {admin_id} с пользователем {user_id}"
|
|
646
|
+
)
|
|
647
|
+
return conversation_id
|
|
648
|
+
|
|
649
|
+
except APIError as e:
|
|
650
|
+
logger.error(f"Ошибка при начале диалога: {e}")
|
|
651
|
+
raise
|
|
652
|
+
|
|
653
|
+
async def end_admin_conversations(
|
|
654
|
+
self, admin_id: int = None, user_id: int = None
|
|
655
|
+
) -> int:
|
|
656
|
+
"""Завершает активные диалоги админа или пользователя"""
|
|
657
|
+
try:
|
|
658
|
+
query = (
|
|
659
|
+
self.client.table("admin_user_conversations")
|
|
660
|
+
.update(
|
|
661
|
+
{
|
|
662
|
+
"status": "completed", # Используем 'completed' вместо 'ended'
|
|
663
|
+
"ended_at": datetime.now(timezone.utc).isoformat(),
|
|
664
|
+
}
|
|
665
|
+
)
|
|
666
|
+
.eq("status", "active")
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
if admin_id:
|
|
670
|
+
query = query.eq("admin_id", admin_id)
|
|
671
|
+
if user_id:
|
|
672
|
+
query = query.eq("user_id", user_id)
|
|
673
|
+
# Добавляем фильтр по bot_id если он указан
|
|
674
|
+
if self.bot_id:
|
|
675
|
+
query = query.eq("bot_id", self.bot_id)
|
|
676
|
+
|
|
677
|
+
response = query.execute()
|
|
678
|
+
ended_count = len(response.data)
|
|
679
|
+
|
|
680
|
+
if ended_count > 0:
|
|
681
|
+
logger.info(f"Завершено {ended_count} активных диалогов")
|
|
682
|
+
|
|
683
|
+
return ended_count
|
|
684
|
+
|
|
685
|
+
except APIError as e:
|
|
686
|
+
logger.error(f"Ошибка при завершении диалогов: {e}")
|
|
687
|
+
return 0
|
|
688
|
+
|
|
689
|
+
async def get_admin_active_conversation(
|
|
690
|
+
self, admin_id: int
|
|
691
|
+
) -> Optional[Dict[str, Any]]:
|
|
692
|
+
"""Получает активный диалог админа"""
|
|
693
|
+
try:
|
|
694
|
+
response = (
|
|
695
|
+
self.client.table("admin_user_conversations")
|
|
696
|
+
.select("id", "user_id", "session_id", "started_at", "auto_end_at")
|
|
697
|
+
.eq("admin_id", admin_id)
|
|
698
|
+
.eq("status", "active")
|
|
699
|
+
.execute()
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
return response.data[0] if response.data else None
|
|
703
|
+
|
|
704
|
+
except APIError as e:
|
|
705
|
+
logger.error(f"Ошибка при получении диалога админа: {e}")
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
async def get_user_conversation(self, user_id: int) -> Optional[Dict[str, Any]]:
|
|
709
|
+
"""Получает активный диалог пользователя"""
|
|
710
|
+
try:
|
|
711
|
+
response = (
|
|
712
|
+
self.client.table("admin_user_conversations")
|
|
713
|
+
.select("id", "admin_id", "session_id", "started_at", "auto_end_at")
|
|
714
|
+
.eq("user_id", user_id)
|
|
715
|
+
.eq("status", "active")
|
|
716
|
+
.execute()
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
return response.data[0] if response.data else None
|
|
720
|
+
|
|
721
|
+
except APIError as e:
|
|
722
|
+
logger.error(f"Ошибка при получении диалога пользователя: {e}")
|
|
723
|
+
return None
|
|
724
|
+
|
|
725
|
+
# 🆕 Методы совместимости - добавляем недостающие методы из старого кода
|
|
726
|
+
|
|
727
|
+
async def cleanup_expired_conversations(self) -> int:
|
|
728
|
+
"""Завершает просроченные диалоги админов"""
|
|
729
|
+
try:
|
|
730
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
731
|
+
|
|
732
|
+
response = (
|
|
733
|
+
self.client.table("admin_user_conversations")
|
|
734
|
+
.update({"status": "expired", "ended_at": now})
|
|
735
|
+
.eq("status", "active")
|
|
736
|
+
.lt("auto_end_at", now)
|
|
737
|
+
.execute()
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
ended_count = len(response.data)
|
|
741
|
+
if ended_count > 0:
|
|
742
|
+
logger.info(
|
|
743
|
+
f"Автоматически завершено {ended_count} просроченных диалогов"
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return ended_count
|
|
747
|
+
|
|
748
|
+
except APIError as e:
|
|
749
|
+
logger.error(f"Ошибка при завершении просроченных диалогов: {e}")
|
|
750
|
+
return 0
|
|
751
|
+
|
|
752
|
+
async def end_expired_conversations(self) -> int:
|
|
753
|
+
"""Алиас для cleanup_expired_conversations для обратной совместимости"""
|
|
754
|
+
return await self.cleanup_expired_conversations()
|
|
755
|
+
|
|
756
|
+
async def get_user_admin_conversation(
|
|
757
|
+
self, user_id: int
|
|
758
|
+
) -> Optional[Dict[str, Any]]:
|
|
759
|
+
"""Проверяет, ведется ли диалог с пользователем (для совместимости)"""
|
|
760
|
+
return await self.get_user_conversation(user_id)
|
|
761
|
+
|
|
762
|
+
# 🆕 Методы аналитики с фильтрацией по bot_id
|
|
763
|
+
|
|
764
|
+
async def get_analytics_summary(self, days: int = 7) -> Dict[str, Any]:
|
|
765
|
+
"""Получает сводку аналитики за последние дни с учетом bot_id (если указан)"""
|
|
766
|
+
try:
|
|
767
|
+
cutoff_date = datetime.now() - timedelta(days=days)
|
|
768
|
+
|
|
769
|
+
# 🆕 Получаем сессии с учетом bot_id (если указан)
|
|
770
|
+
query = (
|
|
771
|
+
self.client.table("sales_chat_sessions")
|
|
772
|
+
.select("id", "current_stage", "lead_quality_score", "created_at")
|
|
773
|
+
.gte("created_at", cutoff_date.isoformat())
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
if self.bot_id:
|
|
777
|
+
query = query.eq("bot_id", self.bot_id)
|
|
778
|
+
|
|
779
|
+
sessions_response = query.execute()
|
|
780
|
+
|
|
781
|
+
sessions = sessions_response.data
|
|
782
|
+
total_sessions = len(sessions)
|
|
783
|
+
|
|
784
|
+
# Группировка по этапам
|
|
785
|
+
stages = {}
|
|
786
|
+
quality_scores = []
|
|
787
|
+
|
|
788
|
+
for session in sessions:
|
|
789
|
+
stage = session.get("current_stage", "unknown")
|
|
790
|
+
stages[stage] = stages.get(stage, 0) + 1
|
|
791
|
+
|
|
792
|
+
score = session.get("lead_quality_score", 5)
|
|
793
|
+
if score:
|
|
794
|
+
quality_scores.append(score)
|
|
795
|
+
|
|
796
|
+
avg_quality = (
|
|
797
|
+
sum(quality_scores) / len(quality_scores) if quality_scores else 5
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
"bot_id": self.bot_id,
|
|
802
|
+
"period_days": days,
|
|
803
|
+
"total_sessions": total_sessions,
|
|
804
|
+
"stages": stages,
|
|
805
|
+
"average_lead_quality": round(avg_quality, 1),
|
|
806
|
+
"generated_at": datetime.now().isoformat(),
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
except APIError as e:
|
|
810
|
+
logger.error(f"Ошибка при получении аналитики: {e}")
|
|
811
|
+
return {
|
|
812
|
+
"bot_id": self.bot_id,
|
|
813
|
+
"error": str(e),
|
|
814
|
+
"generated_at": datetime.now().isoformat(),
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async def update_session_analytics(
|
|
818
|
+
self, session_id: str, tokens_used: int = 0, processing_time_ms: int = 0
|
|
819
|
+
):
|
|
820
|
+
"""Обновляет аналитику сессии"""
|
|
821
|
+
try:
|
|
822
|
+
# Получаем текущую аналитику
|
|
823
|
+
response = (
|
|
824
|
+
self.client.table("sales_session_analytics")
|
|
825
|
+
.select("total_messages", "total_tokens", "average_response_time_ms")
|
|
826
|
+
.eq("session_id", session_id)
|
|
827
|
+
.execute()
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
if response.data:
|
|
831
|
+
current = response.data[0]
|
|
832
|
+
new_total_messages = current["total_messages"] + 1
|
|
833
|
+
new_total_tokens = current["total_tokens"] + tokens_used
|
|
834
|
+
|
|
835
|
+
# Вычисляем среднее время ответа
|
|
836
|
+
if processing_time_ms > 0:
|
|
837
|
+
current_avg = current["average_response_time_ms"]
|
|
838
|
+
new_avg = (
|
|
839
|
+
(current_avg * (new_total_messages - 1)) + processing_time_ms
|
|
840
|
+
) / new_total_messages
|
|
841
|
+
else:
|
|
842
|
+
new_avg = current["average_response_time_ms"]
|
|
843
|
+
|
|
844
|
+
# Обновляем аналитику
|
|
845
|
+
self.client.table("sales_session_analytics").update(
|
|
846
|
+
{
|
|
847
|
+
"total_messages": new_total_messages,
|
|
848
|
+
"total_tokens": new_total_tokens,
|
|
849
|
+
"average_response_time_ms": int(new_avg),
|
|
850
|
+
"updated_at": datetime.now().isoformat(),
|
|
851
|
+
}
|
|
852
|
+
).eq("session_id", session_id).execute()
|
|
853
|
+
|
|
854
|
+
except APIError as e:
|
|
855
|
+
logger.error(f"Ошибка при обновлении аналитики: {e}")
|
|
856
|
+
# Не прерываем выполнение, аналитика не критична
|
|
857
|
+
|
|
858
|
+
# Методы совместимости
|
|
859
|
+
async def update_conversion_stage(
|
|
860
|
+
self, session_id: str, stage: str, quality_score: int = None
|
|
861
|
+
):
|
|
862
|
+
"""Обновляет этап конверсии и качество лида (для совместимости)"""
|
|
863
|
+
await self.update_session_stage(session_id, stage, quality_score)
|
|
864
|
+
|
|
865
|
+
async def archive_old_sessions(self, days: int = 7):
|
|
866
|
+
"""Архивирует старые завершенные сессии с учетом bot_id (если указан)"""
|
|
867
|
+
try:
|
|
868
|
+
cutoff_date = datetime.now() - timedelta(days=days)
|
|
869
|
+
|
|
870
|
+
# 🆕 Архивируем только сессии этого бота (если bot_id указан)
|
|
871
|
+
query = (
|
|
872
|
+
self.client.table("sales_chat_sessions")
|
|
873
|
+
.update({"status": "archived"})
|
|
874
|
+
.eq("status", "completed")
|
|
875
|
+
.lt("updated_at", cutoff_date.isoformat())
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
if self.bot_id:
|
|
879
|
+
query = query.eq("bot_id", self.bot_id)
|
|
880
|
+
|
|
881
|
+
query.execute()
|
|
882
|
+
|
|
883
|
+
logger.info(
|
|
884
|
+
f"Архивированы сессии старше {days} дней{f' для bot_id {self.bot_id}' if self.bot_id else ''}"
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
except APIError as e:
|
|
888
|
+
logger.error(f"Ошибка при архивировании сессий: {e}")
|
|
889
|
+
raise
|
|
890
|
+
|
|
891
|
+
async def get_sent_files(self, user_id: int) -> List[str]:
|
|
892
|
+
"""Получает список отправленных файлов для пользователя
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
user_id: Telegram ID пользователя
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
List[str]: Список имен файлов, разделенных запятой
|
|
899
|
+
"""
|
|
900
|
+
try:
|
|
901
|
+
query = (
|
|
902
|
+
self.client.table("sales_users")
|
|
903
|
+
.select("files")
|
|
904
|
+
.eq("telegram_id", user_id)
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
if self.bot_id:
|
|
908
|
+
query = query.eq("bot_id", self.bot_id)
|
|
909
|
+
|
|
910
|
+
response = query.execute()
|
|
911
|
+
|
|
912
|
+
if response.data and response.data[0].get("files"):
|
|
913
|
+
files_str = response.data[0]["files"]
|
|
914
|
+
return [f.strip() for f in files_str.split(",") if f.strip()]
|
|
915
|
+
|
|
916
|
+
return []
|
|
917
|
+
|
|
918
|
+
except Exception as e:
|
|
919
|
+
logger.error(
|
|
920
|
+
f"Ошибка получения отправленных файлов для пользователя {user_id}: {e}"
|
|
921
|
+
)
|
|
922
|
+
return []
|
|
923
|
+
|
|
924
|
+
async def get_sent_directories(self, user_id: int) -> List[str]:
|
|
925
|
+
"""Получает список отправленных каталогов для пользователя
|
|
926
|
+
|
|
927
|
+
Args:
|
|
928
|
+
user_id: Telegram ID пользователя
|
|
929
|
+
|
|
930
|
+
Returns:
|
|
931
|
+
List[str]: Список путей каталогов, разделенных запятой
|
|
932
|
+
"""
|
|
933
|
+
try:
|
|
934
|
+
query = (
|
|
935
|
+
self.client.table("sales_users")
|
|
936
|
+
.select("directories")
|
|
937
|
+
.eq("telegram_id", user_id)
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
if self.bot_id:
|
|
941
|
+
query = query.eq("bot_id", self.bot_id)
|
|
942
|
+
|
|
943
|
+
response = query.execute()
|
|
944
|
+
|
|
945
|
+
if response.data and response.data[0].get("directories"):
|
|
946
|
+
dirs_str = response.data[0]["directories"]
|
|
947
|
+
return [d.strip() for d in dirs_str.split(",") if d.strip()]
|
|
948
|
+
|
|
949
|
+
return []
|
|
950
|
+
|
|
951
|
+
except Exception as e:
|
|
952
|
+
logger.error(
|
|
953
|
+
f"Ошибка получения отправленных каталогов для пользователя {user_id}: {e}"
|
|
954
|
+
)
|
|
955
|
+
return []
|
|
956
|
+
|
|
957
|
+
async def add_sent_files(self, user_id: int, files_list: List[str]):
|
|
958
|
+
"""Добавляет файлы в список отправленных для пользователя
|
|
959
|
+
|
|
960
|
+
Args:
|
|
961
|
+
user_id: Telegram ID пользователя
|
|
962
|
+
files_list: Список имен файлов для добавления
|
|
963
|
+
"""
|
|
964
|
+
try:
|
|
965
|
+
logger.info(f"Добавление файлов для пользователя {user_id}: {files_list}")
|
|
966
|
+
|
|
967
|
+
# Получаем текущий список
|
|
968
|
+
current_files = await self.get_sent_files(user_id)
|
|
969
|
+
logger.info(f"Текущие файлы в БД: {current_files}")
|
|
970
|
+
|
|
971
|
+
# Объединяем с новыми файлами (без дубликатов)
|
|
972
|
+
all_files = list(set(current_files + files_list))
|
|
973
|
+
logger.info(f"Объединенный список файлов: {all_files}")
|
|
974
|
+
|
|
975
|
+
# Сохраняем обратно
|
|
976
|
+
files_str = ", ".join(all_files)
|
|
977
|
+
logger.info(f"Сохраняем строку: {files_str}")
|
|
978
|
+
|
|
979
|
+
query = (
|
|
980
|
+
self.client.table("sales_users")
|
|
981
|
+
.update({"files": files_str})
|
|
982
|
+
.eq("telegram_id", user_id)
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
if self.bot_id:
|
|
986
|
+
query = query.eq("bot_id", self.bot_id)
|
|
987
|
+
logger.info(f"Фильтр по bot_id: {self.bot_id}")
|
|
988
|
+
|
|
989
|
+
response = query.execute()
|
|
990
|
+
logger.info(f"Ответ от БД: {response.data}")
|
|
991
|
+
|
|
992
|
+
logger.info(
|
|
993
|
+
f"✅ Добавлено {len(files_list)} файлов для пользователя {user_id}"
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
except Exception as e:
|
|
997
|
+
logger.error(
|
|
998
|
+
f"❌ Ошибка добавления отправленных файлов для пользователя {user_id}: {e}"
|
|
999
|
+
)
|
|
1000
|
+
logger.exception("Полный стек ошибки:")
|
|
1001
|
+
|
|
1002
|
+
async def add_sent_directories(self, user_id: int, dirs_list: List[str]):
|
|
1003
|
+
"""Добавляет каталоги в список отправленных для пользователя
|
|
1004
|
+
|
|
1005
|
+
Args:
|
|
1006
|
+
user_id: Telegram ID пользователя
|
|
1007
|
+
dirs_list: Список путей каталогов для добавления
|
|
1008
|
+
"""
|
|
1009
|
+
try:
|
|
1010
|
+
logger.info(f"Добавление каталогов для пользователя {user_id}: {dirs_list}")
|
|
1011
|
+
|
|
1012
|
+
# Получаем текущий список
|
|
1013
|
+
current_dirs = await self.get_sent_directories(user_id)
|
|
1014
|
+
logger.info(f"Текущие каталоги в БД: {current_dirs}")
|
|
1015
|
+
|
|
1016
|
+
# Объединяем с новыми каталогами (без дубликатов)
|
|
1017
|
+
all_dirs = list(set(current_dirs + dirs_list))
|
|
1018
|
+
logger.info(f"Объединенный список каталогов: {all_dirs}")
|
|
1019
|
+
|
|
1020
|
+
# Сохраняем обратно
|
|
1021
|
+
dirs_str = ", ".join(all_dirs)
|
|
1022
|
+
logger.info(f"Сохраняем строку: {dirs_str}")
|
|
1023
|
+
|
|
1024
|
+
query = (
|
|
1025
|
+
self.client.table("sales_users")
|
|
1026
|
+
.update({"directories": dirs_str})
|
|
1027
|
+
.eq("telegram_id", user_id)
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
if self.bot_id:
|
|
1031
|
+
query = query.eq("bot_id", self.bot_id)
|
|
1032
|
+
logger.info(f"Фильтр по bot_id: {self.bot_id}")
|
|
1033
|
+
|
|
1034
|
+
response = query.execute()
|
|
1035
|
+
logger.info(f"Ответ от БД: {response.data}")
|
|
1036
|
+
|
|
1037
|
+
logger.info(
|
|
1038
|
+
f"✅ Добавлено {len(dirs_list)} каталогов для пользователя {user_id}"
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
logger.error(
|
|
1043
|
+
f"❌ Ошибка добавления отправленных каталогов для пользователя {user_id}: {e}"
|
|
1044
|
+
)
|
|
1045
|
+
logger.exception("Полный стек ошибки:")
|
|
1046
|
+
|
|
1047
|
+
# =============================================================================
|
|
1048
|
+
# МЕТОДЫ ДЛЯ АНАЛИТИКИ
|
|
1049
|
+
# =============================================================================
|
|
1050
|
+
|
|
1051
|
+
async def get_funnel_stats(self, days: int = 7) -> Dict[str, Any]:
|
|
1052
|
+
"""Получает статистику воронки продаж"""
|
|
1053
|
+
try:
|
|
1054
|
+
cutoff_date = datetime.now() - timedelta(days=days)
|
|
1055
|
+
|
|
1056
|
+
# Получаем ВСЕ уникальные пользователи из sales_users с фильтром по bot_id
|
|
1057
|
+
users_query = self.client.table("sales_users").select("telegram_id")
|
|
1058
|
+
|
|
1059
|
+
if self.bot_id:
|
|
1060
|
+
users_query = users_query.eq("bot_id", self.bot_id)
|
|
1061
|
+
|
|
1062
|
+
# Исключаем тестовых пользователей
|
|
1063
|
+
users_query = users_query.neq("username", "test_user")
|
|
1064
|
+
|
|
1065
|
+
users_response = users_query.execute()
|
|
1066
|
+
total_unique_users = len(users_response.data) if users_response.data else 0
|
|
1067
|
+
|
|
1068
|
+
# Получаем сессии с учетом bot_id за период
|
|
1069
|
+
sessions_query = (
|
|
1070
|
+
self.client.table("sales_chat_sessions")
|
|
1071
|
+
.select(
|
|
1072
|
+
"id", "user_id", "current_stage", "lead_quality_score", "created_at"
|
|
1073
|
+
)
|
|
1074
|
+
.gte("created_at", cutoff_date.isoformat())
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
if self.bot_id:
|
|
1078
|
+
sessions_query = sessions_query.eq("bot_id", self.bot_id)
|
|
1079
|
+
|
|
1080
|
+
sessions_response = sessions_query.execute()
|
|
1081
|
+
sessions = sessions_response.data
|
|
1082
|
+
|
|
1083
|
+
# Исключаем сессии тестовых пользователей
|
|
1084
|
+
if sessions:
|
|
1085
|
+
# Получаем telegram_id тестовых пользователей
|
|
1086
|
+
test_users_query = (
|
|
1087
|
+
self.client.table("sales_users")
|
|
1088
|
+
.select("telegram_id")
|
|
1089
|
+
.eq("username", "test_user")
|
|
1090
|
+
)
|
|
1091
|
+
if self.bot_id:
|
|
1092
|
+
test_users_query = test_users_query.eq("bot_id", self.bot_id)
|
|
1093
|
+
|
|
1094
|
+
test_users_response = test_users_query.execute()
|
|
1095
|
+
test_user_ids = (
|
|
1096
|
+
{user["telegram_id"] for user in test_users_response.data}
|
|
1097
|
+
if test_users_response.data
|
|
1098
|
+
else set()
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
# Фильтруем сессии
|
|
1102
|
+
sessions = [s for s in sessions if s["user_id"] not in test_user_ids]
|
|
1103
|
+
|
|
1104
|
+
total_sessions = len(sessions)
|
|
1105
|
+
|
|
1106
|
+
# Группировка по этапам
|
|
1107
|
+
|
|
1108
|
+
# Группировка по этапам
|
|
1109
|
+
stages = {}
|
|
1110
|
+
quality_scores = []
|
|
1111
|
+
|
|
1112
|
+
for session in sessions:
|
|
1113
|
+
stage = session.get("current_stage", "unknown")
|
|
1114
|
+
stages[stage] = stages.get(stage, 0) + 1
|
|
1115
|
+
|
|
1116
|
+
score = session.get("lead_quality_score", 5)
|
|
1117
|
+
if score:
|
|
1118
|
+
quality_scores.append(score)
|
|
1119
|
+
|
|
1120
|
+
avg_quality = (
|
|
1121
|
+
sum(quality_scores) / len(quality_scores) if quality_scores else 5
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
return {
|
|
1125
|
+
"total_sessions": total_sessions,
|
|
1126
|
+
"total_unique_users": total_unique_users, # ✅ ВСЕ уникальные пользователи бота
|
|
1127
|
+
"stages": stages,
|
|
1128
|
+
"avg_quality": round(avg_quality, 1),
|
|
1129
|
+
"period_days": days,
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
except APIError as e:
|
|
1133
|
+
logger.error(f"Ошибка получения статистики воронки: {e}")
|
|
1134
|
+
return {
|
|
1135
|
+
"total_sessions": 0,
|
|
1136
|
+
"stages": {},
|
|
1137
|
+
"avg_quality": 0,
|
|
1138
|
+
"period_days": days,
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async def get_events_stats(self, days: int = 7) -> Dict[str, int]:
|
|
1142
|
+
"""Получает статистику событий"""
|
|
1143
|
+
try:
|
|
1144
|
+
cutoff_date = datetime.now() - timedelta(days=days)
|
|
1145
|
+
|
|
1146
|
+
# Получаем события с учетом bot_id через сессии
|
|
1147
|
+
query = (
|
|
1148
|
+
self.client.table("session_events")
|
|
1149
|
+
.select("event_type", "session_id")
|
|
1150
|
+
.gte("created_at", cutoff_date.isoformat())
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
events_response = query.execute()
|
|
1154
|
+
events = events_response.data if events_response.data else []
|
|
1155
|
+
|
|
1156
|
+
# Фильтруем события по bot_id через сессии
|
|
1157
|
+
if self.bot_id and events:
|
|
1158
|
+
# Получаем ID сессий этого бота
|
|
1159
|
+
sessions_query = (
|
|
1160
|
+
self.client.table("sales_chat_sessions")
|
|
1161
|
+
.select("id", "user_id")
|
|
1162
|
+
.eq("bot_id", self.bot_id)
|
|
1163
|
+
)
|
|
1164
|
+
sessions_response = sessions_query.execute()
|
|
1165
|
+
|
|
1166
|
+
# Исключаем сессии тестовых пользователей
|
|
1167
|
+
if sessions_response.data:
|
|
1168
|
+
# Получаем telegram_id тестовых пользователей
|
|
1169
|
+
test_users_query = (
|
|
1170
|
+
self.client.table("sales_users")
|
|
1171
|
+
.select("telegram_id")
|
|
1172
|
+
.eq("username", "test_user")
|
|
1173
|
+
)
|
|
1174
|
+
if self.bot_id:
|
|
1175
|
+
test_users_query = test_users_query.eq("bot_id", self.bot_id)
|
|
1176
|
+
|
|
1177
|
+
test_users_response = test_users_query.execute()
|
|
1178
|
+
test_user_ids = (
|
|
1179
|
+
{user["telegram_id"] for user in test_users_response.data}
|
|
1180
|
+
if test_users_response.data
|
|
1181
|
+
else set()
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
# Фильтруем сессии: только не тестовые
|
|
1185
|
+
bot_sessions = [
|
|
1186
|
+
s
|
|
1187
|
+
for s in sessions_response.data
|
|
1188
|
+
if s["user_id"] not in test_user_ids
|
|
1189
|
+
]
|
|
1190
|
+
bot_session_ids = {session["id"] for session in bot_sessions}
|
|
1191
|
+
else:
|
|
1192
|
+
bot_session_ids = set()
|
|
1193
|
+
|
|
1194
|
+
# Фильтруем события
|
|
1195
|
+
events = [
|
|
1196
|
+
event for event in events if event["session_id"] in bot_session_ids
|
|
1197
|
+
]
|
|
1198
|
+
|
|
1199
|
+
# Группируем по типам событий
|
|
1200
|
+
event_counts = {}
|
|
1201
|
+
for event in events:
|
|
1202
|
+
event_type = event.get("event_type", "unknown")
|
|
1203
|
+
event_counts[event_type] = event_counts.get(event_type, 0) + 1
|
|
1204
|
+
|
|
1205
|
+
return event_counts
|
|
1206
|
+
|
|
1207
|
+
except APIError as e:
|
|
1208
|
+
logger.error(f"Ошибка получения статистики событий: {e}")
|
|
1209
|
+
return {}
|
|
1210
|
+
|
|
1211
|
+
async def get_user_last_message_info(
|
|
1212
|
+
self, user_id: int
|
|
1213
|
+
) -> Optional[Dict[str, Any]]:
|
|
1214
|
+
"""Получает информацию о последней активности пользователя из сессии"""
|
|
1215
|
+
try:
|
|
1216
|
+
# Получаем последнюю сессию пользователя
|
|
1217
|
+
response = (
|
|
1218
|
+
self.client.table("sales_chat_sessions")
|
|
1219
|
+
.select("id", "current_stage", "created_at", "updated_at")
|
|
1220
|
+
.eq("user_id", user_id)
|
|
1221
|
+
.order("updated_at", desc=True)
|
|
1222
|
+
.limit(1)
|
|
1223
|
+
.execute()
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
if not response.data:
|
|
1227
|
+
return None
|
|
1228
|
+
|
|
1229
|
+
session = response.data[0]
|
|
1230
|
+
|
|
1231
|
+
return {
|
|
1232
|
+
"last_message_at": session["updated_at"],
|
|
1233
|
+
"session_id": session["id"],
|
|
1234
|
+
"current_stage": session["current_stage"],
|
|
1235
|
+
"session_updated_at": session["updated_at"],
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
except Exception as e:
|
|
1239
|
+
logger.error(
|
|
1240
|
+
f"Ошибка получения информации о последнем сообщении пользователя {user_id}: {e}"
|
|
1241
|
+
)
|
|
1242
|
+
return None
|
|
1243
|
+
|
|
1244
|
+
async def check_user_stage_changed(
|
|
1245
|
+
self, user_id: int, original_session_id: str
|
|
1246
|
+
) -> bool:
|
|
1247
|
+
"""Проверяет, изменился ли этап пользователя с момента планирования события"""
|
|
1248
|
+
try:
|
|
1249
|
+
# Получаем текущую информацию о сессии
|
|
1250
|
+
query = (
|
|
1251
|
+
self.client.table("sales_chat_sessions")
|
|
1252
|
+
.select("id", "current_stage")
|
|
1253
|
+
.eq("user_id", user_id)
|
|
1254
|
+
.order("created_at", desc=True)
|
|
1255
|
+
.limit(1)
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
# Добавляем фильтр по bot_id если он указан
|
|
1259
|
+
if self.bot_id:
|
|
1260
|
+
query = query.eq("bot_id", self.bot_id)
|
|
1261
|
+
|
|
1262
|
+
current_response = query.execute()
|
|
1263
|
+
|
|
1264
|
+
if not current_response.data:
|
|
1265
|
+
return False
|
|
1266
|
+
|
|
1267
|
+
current_session = current_response.data[0]
|
|
1268
|
+
|
|
1269
|
+
# Если сессия изменилась - этап точно изменился
|
|
1270
|
+
if current_session["id"] != original_session_id:
|
|
1271
|
+
return True
|
|
1272
|
+
|
|
1273
|
+
# Если сессия та же, получаем оригинальный этап из scheduled_events
|
|
1274
|
+
# и сравниваем с текущим
|
|
1275
|
+
original_response = (
|
|
1276
|
+
self.client.table("sales_chat_sessions")
|
|
1277
|
+
.select("current_stage")
|
|
1278
|
+
.eq("id", original_session_id)
|
|
1279
|
+
.execute()
|
|
1280
|
+
)
|
|
1281
|
+
|
|
1282
|
+
if not original_response.data:
|
|
1283
|
+
# Если не нашли оригинальную сессию, считаем что этап не изменился
|
|
1284
|
+
return False
|
|
1285
|
+
|
|
1286
|
+
original_stage = original_response.data[0]["current_stage"]
|
|
1287
|
+
current_stage = current_session["current_stage"]
|
|
1288
|
+
|
|
1289
|
+
# Проверяем, изменился ли этап внутри той же сессии
|
|
1290
|
+
if original_stage != current_stage:
|
|
1291
|
+
logger.info(
|
|
1292
|
+
f"🔄 Этап изменился: {original_stage} -> {current_stage} (сессия {original_session_id})"
|
|
1293
|
+
)
|
|
1294
|
+
return True
|
|
1295
|
+
|
|
1296
|
+
return False
|
|
1297
|
+
|
|
1298
|
+
except Exception as e:
|
|
1299
|
+
logger.error(f"Ошибка проверки изменения этапа пользователя {user_id}: {e}")
|
|
1300
|
+
return False
|
|
1301
|
+
|
|
1302
|
+
async def get_last_event_info_by_user_and_type(
|
|
1303
|
+
self, user_id: int, event_type: str
|
|
1304
|
+
) -> Optional[str]:
|
|
1305
|
+
"""
|
|
1306
|
+
Получает event_info последнего события определенного типа для пользователя
|
|
1307
|
+
|
|
1308
|
+
Args:
|
|
1309
|
+
user_id: Telegram ID пользователя
|
|
1310
|
+
event_type: Тип события для поиска
|
|
1311
|
+
|
|
1312
|
+
Returns:
|
|
1313
|
+
str: event_info последнего найденного события или None если не найдено
|
|
1314
|
+
"""
|
|
1315
|
+
try:
|
|
1316
|
+
# 1. Получаем последнюю сессию пользователя
|
|
1317
|
+
sessions_query = (
|
|
1318
|
+
self.client.table("sales_chat_sessions")
|
|
1319
|
+
.select("id")
|
|
1320
|
+
.eq("user_id", user_id)
|
|
1321
|
+
.order("created_at", desc=True)
|
|
1322
|
+
.limit(1)
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
# Фильтруем по bot_id если указан
|
|
1326
|
+
if self.bot_id:
|
|
1327
|
+
sessions_query = sessions_query.eq("bot_id", self.bot_id)
|
|
1328
|
+
|
|
1329
|
+
sessions_response = sessions_query.execute()
|
|
1330
|
+
|
|
1331
|
+
if not sessions_response.data:
|
|
1332
|
+
logger.info(f"Пользователь {user_id} не найден в сессиях")
|
|
1333
|
+
return None
|
|
1334
|
+
|
|
1335
|
+
session_id = sessions_response.data[0]["id"]
|
|
1336
|
+
logger.info(
|
|
1337
|
+
f"Найдена последняя сессия {session_id} для пользователя {user_id}"
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
# 2. Ищем последнее событие с этим session_id и event_type
|
|
1341
|
+
events_response = (
|
|
1342
|
+
self.client.table("session_events")
|
|
1343
|
+
.select("event_info", "created_at")
|
|
1344
|
+
.eq("session_id", session_id)
|
|
1345
|
+
.eq("event_type", event_type)
|
|
1346
|
+
.order("created_at", desc=True)
|
|
1347
|
+
.limit(1)
|
|
1348
|
+
.execute()
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
if not events_response.data:
|
|
1352
|
+
logger.info(
|
|
1353
|
+
f"События типа '{event_type}' не найдены для сессии {session_id}"
|
|
1354
|
+
)
|
|
1355
|
+
return None
|
|
1356
|
+
|
|
1357
|
+
event_info = events_response.data[0]["event_info"]
|
|
1358
|
+
created_at = events_response.data[0]["created_at"]
|
|
1359
|
+
|
|
1360
|
+
logger.info(
|
|
1361
|
+
f"Найдено последнее событие '{event_type}' для пользователя {user_id}: {event_info[:50]}... (создано: {created_at})"
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
return event_info
|
|
1365
|
+
|
|
1366
|
+
except Exception as e:
|
|
1367
|
+
logger.error(
|
|
1368
|
+
f"Ошибка получения последнего события для пользователя {user_id}, тип '{event_type}': {e}"
|
|
1369
|
+
)
|
|
1370
|
+
return None
|
|
1371
|
+
|
|
1372
|
+
async def get_all_segments(self) -> List[str]:
|
|
1373
|
+
"""
|
|
1374
|
+
Получает все уникальные сегменты из таблицы sales_users
|
|
1375
|
+
|
|
1376
|
+
Returns:
|
|
1377
|
+
List[str]: Список уникальных сегментов
|
|
1378
|
+
"""
|
|
1379
|
+
try:
|
|
1380
|
+
# Запрос всех непустых сегментов
|
|
1381
|
+
query = (
|
|
1382
|
+
self.client.table("sales_users").select("segments").neq("segments", "")
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
if self.bot_id:
|
|
1386
|
+
query = query.eq("bot_id", self.bot_id)
|
|
1387
|
+
|
|
1388
|
+
response = query.execute()
|
|
1389
|
+
|
|
1390
|
+
# Собираем все уникальные сегменты
|
|
1391
|
+
all_segments = set()
|
|
1392
|
+
for row in response.data:
|
|
1393
|
+
segments_str = row.get("segments", "")
|
|
1394
|
+
if segments_str:
|
|
1395
|
+
# Разбираем сегменты через запятую
|
|
1396
|
+
segments = [s.strip() for s in segments_str.split(",") if s.strip()]
|
|
1397
|
+
all_segments.update(segments)
|
|
1398
|
+
|
|
1399
|
+
segments_list = sorted(list(all_segments))
|
|
1400
|
+
logger.info(f"Найдено {len(segments_list)} уникальных сегментов")
|
|
1401
|
+
|
|
1402
|
+
return segments_list
|
|
1403
|
+
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
logger.error(f"Ошибка получения сегментов: {e}")
|
|
1406
|
+
return []
|
|
1407
|
+
|
|
1408
|
+
async def get_users_by_segment(self, segment: str = None) -> List[Dict[str, Any]]:
|
|
1409
|
+
"""
|
|
1410
|
+
Получает пользователей по сегменту или всех пользователей
|
|
1411
|
+
|
|
1412
|
+
Args:
|
|
1413
|
+
segment: Название сегмента (если None - возвращает всех)
|
|
1414
|
+
|
|
1415
|
+
Returns:
|
|
1416
|
+
List[Dict]: Список пользователей с telegram_id
|
|
1417
|
+
"""
|
|
1418
|
+
try:
|
|
1419
|
+
query = self.client.table("sales_users").select("telegram_id, segments")
|
|
1420
|
+
|
|
1421
|
+
if self.bot_id:
|
|
1422
|
+
query = query.eq("bot_id", self.bot_id)
|
|
1423
|
+
|
|
1424
|
+
response = query.execute()
|
|
1425
|
+
|
|
1426
|
+
if segment is None:
|
|
1427
|
+
# Все пользователи
|
|
1428
|
+
logger.info(f"Получено {len(response.data)} всех пользователей")
|
|
1429
|
+
return response.data
|
|
1430
|
+
|
|
1431
|
+
# Фильтруем по сегменту
|
|
1432
|
+
users = []
|
|
1433
|
+
for row in response.data:
|
|
1434
|
+
segments_str = row.get("segments", "")
|
|
1435
|
+
if segments_str:
|
|
1436
|
+
segments = [s.strip() for s in segments_str.split(",") if s.strip()]
|
|
1437
|
+
if segment in segments:
|
|
1438
|
+
users.append(row)
|
|
1439
|
+
|
|
1440
|
+
logger.info(f"Найдено {len(users)} пользователей с сегментом '{segment}'")
|
|
1441
|
+
return users
|
|
1442
|
+
|
|
1443
|
+
except Exception as e:
|
|
1444
|
+
logger.error(f"Ошибка получения пользователей по сегменту '{segment}': {e}")
|
|
1445
|
+
return []
|
|
1446
|
+
|
|
1447
|
+
# =============================================================================
|
|
1448
|
+
# МЕТОДЫ ДЛЯ РАБОТЫ С ФАЙЛАМИ СОБЫТИЙ В SUPABASE STORAGE
|
|
1449
|
+
# =============================================================================
|
|
1450
|
+
|
|
1451
|
+
async def upload_event_file(
|
|
1452
|
+
self, event_id: str, file_data: bytes, original_name: str, file_id: str
|
|
1453
|
+
) -> Dict[str, str]:
|
|
1454
|
+
"""
|
|
1455
|
+
Загружает файл события в Supabase Storage
|
|
1456
|
+
|
|
1457
|
+
Args:
|
|
1458
|
+
event_id: ID события из БД (используется как папка)
|
|
1459
|
+
file_data: Байты файла
|
|
1460
|
+
original_name: Оригинальное имя файла (для метаданных)
|
|
1461
|
+
file_id: Уникальный ID файла для хранения
|
|
1462
|
+
|
|
1463
|
+
Returns:
|
|
1464
|
+
Dict с storage_path и original_name
|
|
1465
|
+
"""
|
|
1466
|
+
try:
|
|
1467
|
+
bucket_name = "admin-events"
|
|
1468
|
+
|
|
1469
|
+
# Формируем путь: admin-events/event_id/file_id.ext
|
|
1470
|
+
extension = original_name.split(".")[-1] if "." in original_name else ""
|
|
1471
|
+
storage_name = f"{file_id}.{extension}" if extension else file_id
|
|
1472
|
+
storage_path = f"events/{event_id}/files/{storage_name}"
|
|
1473
|
+
|
|
1474
|
+
# Определяем MIME-type по оригинальному имени файла
|
|
1475
|
+
import mimetypes
|
|
1476
|
+
|
|
1477
|
+
content_type, _ = mimetypes.guess_type(original_name)
|
|
1478
|
+
if not content_type:
|
|
1479
|
+
content_type = "application/octet-stream"
|
|
1480
|
+
|
|
1481
|
+
# Загружаем в Storage
|
|
1482
|
+
self.client.storage.from_(bucket_name).upload(
|
|
1483
|
+
storage_path, file_data, file_options={"content-type": content_type}
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
logger.info(f"✅ Файл загружен в Storage: {storage_path}")
|
|
1487
|
+
|
|
1488
|
+
return {"storage_path": storage_path}
|
|
1489
|
+
|
|
1490
|
+
except Exception as e:
|
|
1491
|
+
logger.error(f"❌ Ошибка загрузки файла в Storage: {e}")
|
|
1492
|
+
raise
|
|
1493
|
+
|
|
1494
|
+
async def download_event_file(self, event_id: str, storage_path: str) -> bytes:
|
|
1495
|
+
"""
|
|
1496
|
+
Скачивает файл события из Supabase Storage
|
|
1497
|
+
|
|
1498
|
+
Args:
|
|
1499
|
+
event_id: ID события
|
|
1500
|
+
storage_path: Полный путь к файлу в Storage
|
|
1501
|
+
|
|
1502
|
+
Returns:
|
|
1503
|
+
bytes: Содержимое файла
|
|
1504
|
+
"""
|
|
1505
|
+
try:
|
|
1506
|
+
bucket_name = "admin-events"
|
|
1507
|
+
|
|
1508
|
+
# Скачиваем файл
|
|
1509
|
+
file_data = self.client.storage.from_(bucket_name).download(storage_path)
|
|
1510
|
+
|
|
1511
|
+
logger.info(f"✅ Файл скачан из Storage: {storage_path}")
|
|
1512
|
+
return file_data
|
|
1513
|
+
|
|
1514
|
+
except Exception as e:
|
|
1515
|
+
logger.error(f"❌ Ошибка скачивания файла из Storage: {e}")
|
|
1516
|
+
raise
|
|
1517
|
+
|
|
1518
|
+
async def delete_event_files(self, event_id: str):
|
|
1519
|
+
"""
|
|
1520
|
+
Удаляет ВСЕ файлы события из Supabase Storage
|
|
1521
|
+
|
|
1522
|
+
Args:
|
|
1523
|
+
event_id: ID события
|
|
1524
|
+
"""
|
|
1525
|
+
try:
|
|
1526
|
+
bucket_name = "admin-events"
|
|
1527
|
+
event_path = f"events/{event_id}/files"
|
|
1528
|
+
|
|
1529
|
+
# Получаем список всех файлов в папке события
|
|
1530
|
+
files_list = self.client.storage.from_(bucket_name).list(event_path)
|
|
1531
|
+
|
|
1532
|
+
if not files_list:
|
|
1533
|
+
logger.info(f"ℹ️ Нет файлов для удаления в событии '{event_id}'")
|
|
1534
|
+
return
|
|
1535
|
+
|
|
1536
|
+
# Формируем пути для удаления
|
|
1537
|
+
file_paths = [f"{event_path}/{file['name']}" for file in files_list]
|
|
1538
|
+
|
|
1539
|
+
# Удаляем файлы
|
|
1540
|
+
self.client.storage.from_(bucket_name).remove(file_paths)
|
|
1541
|
+
|
|
1542
|
+
logger.info(
|
|
1543
|
+
f"✅ Удалено {len(file_paths)} файлов события '{event_id}' из Storage"
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
except Exception as e:
|
|
1547
|
+
logger.error(f"❌ Ошибка удаления файлов события из Storage: {e}")
|
|
1548
|
+
# Не прерываем выполнение, только логируем
|
|
1549
|
+
|
|
1550
|
+
async def save_admin_event(
|
|
1551
|
+
self, event_name: str, event_data: Dict[str, Any], scheduled_datetime: datetime
|
|
1552
|
+
) -> Dict[str, Any]:
|
|
1553
|
+
"""
|
|
1554
|
+
Сохраняет админское событие в таблицу scheduled_events
|
|
1555
|
+
|
|
1556
|
+
Args:
|
|
1557
|
+
event_name: Название события
|
|
1558
|
+
event_data: Данные события (сегмент, сообщение, файлы)
|
|
1559
|
+
scheduled_datetime: Дата и время отправки (должно быть в UTC с timezone info)
|
|
1560
|
+
|
|
1561
|
+
Returns:
|
|
1562
|
+
Dict[str, Any]: {'id': str, 'event_type': str, ...} - все данные созданного события
|
|
1563
|
+
"""
|
|
1564
|
+
try:
|
|
1565
|
+
import json
|
|
1566
|
+
|
|
1567
|
+
# Убеждаемся что datetime в правильном формате для PostgreSQL
|
|
1568
|
+
# Если есть timezone info - используем, иначе предполагаем что это UTC
|
|
1569
|
+
if scheduled_datetime.tzinfo is None:
|
|
1570
|
+
logger.warning(
|
|
1571
|
+
"⚠️ scheduled_datetime без timezone info, предполагаем UTC"
|
|
1572
|
+
)
|
|
1573
|
+
from datetime import timezone
|
|
1574
|
+
|
|
1575
|
+
scheduled_datetime = scheduled_datetime.replace(tzinfo=timezone.utc)
|
|
1576
|
+
|
|
1577
|
+
# Готовим данные для записи
|
|
1578
|
+
event_record = {
|
|
1579
|
+
"event_type": event_name,
|
|
1580
|
+
"event_category": "admin_event",
|
|
1581
|
+
"user_id": None, # Для всех пользователей
|
|
1582
|
+
"event_data": json.dumps(event_data, ensure_ascii=False),
|
|
1583
|
+
"scheduled_at": scheduled_datetime.isoformat(),
|
|
1584
|
+
"status": "pending",
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
# Добавляем bot_id если он указан
|
|
1588
|
+
if self.bot_id:
|
|
1589
|
+
event_record["bot_id"] = self.bot_id
|
|
1590
|
+
logger.info(f"📝 Добавлен bot_id: {self.bot_id} для админского события")
|
|
1591
|
+
|
|
1592
|
+
response = (
|
|
1593
|
+
self.client.table("scheduled_events").insert(event_record).execute()
|
|
1594
|
+
)
|
|
1595
|
+
event = response.data[0]
|
|
1596
|
+
|
|
1597
|
+
logger.info(
|
|
1598
|
+
f"💾 Админское событие '{event_name}' сохранено в БД: {event['id']} на {scheduled_datetime.isoformat()}"
|
|
1599
|
+
)
|
|
1600
|
+
return event
|
|
1601
|
+
|
|
1602
|
+
except Exception as e:
|
|
1603
|
+
logger.error(f"❌ Ошибка сохранения админского события: {e}")
|
|
1604
|
+
raise
|
|
1605
|
+
|
|
1606
|
+
async def get_admin_events(self, status: str = None) -> List[Dict[str, Any]]:
|
|
1607
|
+
"""
|
|
1608
|
+
Получает админские события
|
|
1609
|
+
|
|
1610
|
+
Args:
|
|
1611
|
+
status: Фильтр по статусу (pending, completed, cancelled)
|
|
1612
|
+
|
|
1613
|
+
Returns:
|
|
1614
|
+
List[Dict]: Список админских событий
|
|
1615
|
+
"""
|
|
1616
|
+
try:
|
|
1617
|
+
# Строим базовый запрос
|
|
1618
|
+
query = (
|
|
1619
|
+
self.client.table("scheduled_events")
|
|
1620
|
+
.select("*")
|
|
1621
|
+
.eq("event_category", "admin_event")
|
|
1622
|
+
)
|
|
1623
|
+
|
|
1624
|
+
# Добавляем фильтры
|
|
1625
|
+
if status:
|
|
1626
|
+
query = query.eq("status", status)
|
|
1627
|
+
|
|
1628
|
+
# Фильтруем по bot_id если он указан
|
|
1629
|
+
if self.bot_id:
|
|
1630
|
+
query = query.eq("bot_id", self.bot_id)
|
|
1631
|
+
logger.info(f"🔍 Фильтруем админские события по bot_id: {self.bot_id}")
|
|
1632
|
+
|
|
1633
|
+
response = query.order("scheduled_at", desc=False).execute()
|
|
1634
|
+
|
|
1635
|
+
logger.info(f"Найдено {len(response.data)} админских событий")
|
|
1636
|
+
return response.data
|
|
1637
|
+
|
|
1638
|
+
except Exception as e:
|
|
1639
|
+
logger.error(f"Ошибка получения админских событий: {e}")
|
|
1640
|
+
return []
|
|
1641
|
+
|
|
1642
|
+
async def check_event_name_exists(self, event_name: str) -> bool:
|
|
1643
|
+
"""
|
|
1644
|
+
Проверяет существует ли активное событие с таким названием
|
|
1645
|
+
|
|
1646
|
+
Args:
|
|
1647
|
+
event_name: Название события для проверки
|
|
1648
|
+
|
|
1649
|
+
Returns:
|
|
1650
|
+
bool: True если активное событие с таким именем существует
|
|
1651
|
+
"""
|
|
1652
|
+
try:
|
|
1653
|
+
# Строим запрос
|
|
1654
|
+
query = (
|
|
1655
|
+
self.client.table("scheduled_events")
|
|
1656
|
+
.select("id", "event_type", "status")
|
|
1657
|
+
.eq("event_category", "admin_event")
|
|
1658
|
+
.eq("event_type", event_name)
|
|
1659
|
+
.eq("status", "pending")
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
# Фильтруем по bot_id если он указан
|
|
1663
|
+
if self.bot_id:
|
|
1664
|
+
query = query.eq("bot_id", self.bot_id)
|
|
1665
|
+
logger.info(f"🔍 Проверка названия события с фильтром по bot_id: {self.bot_id}")
|
|
1666
|
+
|
|
1667
|
+
response = query.execute()
|
|
1668
|
+
|
|
1669
|
+
exists = len(response.data) > 0
|
|
1670
|
+
|
|
1671
|
+
if exists:
|
|
1672
|
+
logger.info(f"⚠️ Найдено активное событие с названием '{event_name}'")
|
|
1673
|
+
|
|
1674
|
+
return exists
|
|
1675
|
+
|
|
1676
|
+
except Exception as e:
|
|
1677
|
+
logger.error(f"Ошибка проверки названия события: {e}")
|
|
1678
|
+
return False
|