Initial commit: TrueConf Chatbot КЛЕВЕР
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Установка системных зависимостей
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libgomp1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копирование зависимостей
|
||||
COPY requirements.txt .
|
||||
|
||||
# Установка Python-зависимостей
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir \
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu \
|
||||
-r requirements.txt
|
||||
|
||||
# Директории для логов (создаются при запуске через volumes)
|
||||
# Код проекта монтируется через volumes в docker-compose.yml
|
||||
|
||||
# Переменные окружения
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Команда запуска
|
||||
CMD ["python3", "main.py"]
|
||||
+1099
File diff suppressed because it is too large
Load Diff
+14
@@ -0,0 +1,14 @@
|
||||
# --- TRUECONF ---
|
||||
TC_SERVER=tcs.sibcem.ru
|
||||
|
||||
|
||||
# --- УВЕДОМЛЕНИЯ (через запятую) ---
|
||||
ALERTS_DISABLED_USERS=ds.krivochenko@sibcem.ru
|
||||
ALERTS_ALL_TICKETS=ds.krivochenko@sibcem.ru
|
||||
|
||||
|
||||
|
||||
PW_ID_TC=bot_tcs
|
||||
PW_ID_SD=sdesktoken
|
||||
PW_ID_AD=AD
|
||||
PW_ID_SQL=boss_base
|
||||
@@ -0,0 +1,219 @@
|
||||
# /opt/trueconf_bot/config/config.py
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Автоматическое определение корневой директории проекта (на уровень выше папки config)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# =========================================================
|
||||
# ФУНКЦИЯ ЧТЕНИЯ .env
|
||||
# =========================================================
|
||||
# Загружает переменные окружения из локального файла .env (если он существует)
|
||||
ENV_PATH = os.path.join(os.path.dirname(__file__), ".env")
|
||||
|
||||
def load_env(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
os.environ[key.strip()] = value.strip(' "\'\r\n')
|
||||
except FileNotFoundError:
|
||||
print(f"⚠️ Файл {filepath} не найден. Используются системные переменные.")
|
||||
|
||||
load_env(ENV_PATH)
|
||||
|
||||
# Карта соответствия: Имя переменной в боте -> Название карточки в сейфе (из локального .env)
|
||||
# ИСПРАВЛЕНО: Перенесено выше для корректной передачи списка в модуль ретраев
|
||||
CREDENTIALS_MAP = {
|
||||
"TC_PASSWORD": os.getenv("PW_ID_TC", "").strip(' "\'\r\n'), # Получит "bot_tcs"
|
||||
"SD_TOKEN": os.getenv("PW_ID_SD", "").strip(' "\'\r\n'), # Получит "sdesktoken"
|
||||
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'), # Получит "AD"
|
||||
"DB_PASSWORD": os.getenv("PW_ID_SQL", "").strip(' "\'\r\n'), # Получит "boss_base"
|
||||
}
|
||||
|
||||
# =========================================================
|
||||
# ИНТЕГРАЦИЯ С PASSWORK (Динамическая загрузка паролей)
|
||||
# =========================================================
|
||||
PASSWORK_DIR = "/opt/passwork"
|
||||
if PASSWORK_DIR not in sys.path:
|
||||
sys.path.insert(0, PASSWORK_DIR)
|
||||
|
||||
try:
|
||||
from passwork import get_passwork_secrets
|
||||
print("🔐 Обращение к модулю Passwork и выгрузка сейфа...")
|
||||
|
||||
# ИСПРАВЛЕНО: Пробрасываем список целевых карточек для потокобезопасного ожидания сессии
|
||||
required_to_wait = [name for name in CREDENTIALS_MAP.values() if name]
|
||||
passwork_pool = get_passwork_secrets(required_cards=required_to_wait)
|
||||
except Exception as e:
|
||||
print(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Не удалось подключить модуль Passwork: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print("🔐 Распределение секретов и логинов по модулям бота...")
|
||||
for var_name, card_name in CREDENTIALS_MAP.items():
|
||||
if not card_name:
|
||||
print(f"⚠️ Внимание: Название карточки для {var_name} не найдено в .env")
|
||||
continue
|
||||
|
||||
clean_card_name = card_name.strip(' "\'\r\n')
|
||||
card_data = passwork_pool.get(clean_card_name)
|
||||
|
||||
if card_data is None:
|
||||
print(f"❌ КРИТИЧЕСКАЯ ОШИБКА: Карточка с именем '{clean_card_name}' не найдена в сейфе Passwork!")
|
||||
sys.exit(1)
|
||||
|
||||
# Динамически заполняем пароль/токен в глобальную область видимости config.py
|
||||
globals()[var_name] = card_data["password"].strip(' "\'\r\n')
|
||||
|
||||
# ИСПРАВЛЕНО: Переведено на жесткое globals() назначение для исключения NameError во внешних модулях
|
||||
if var_name == "TC_PASSWORD":
|
||||
globals()["TC_LOGIN"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||
elif var_name == "AD_PASSWORD":
|
||||
globals()["AD_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||
elif var_name == "DB_PASSWORD":
|
||||
globals()["DB_USER"] = card_data.get("login", "").strip(' "\'\r\n')
|
||||
|
||||
print(f" ✓ {var_name} и логин успешно загружены из карточки '{clean_card_name}'")
|
||||
|
||||
# =========================================================
|
||||
# 1. ГЛОБАЛЬНЫЕ ФЛАГИ МОДУЛЕЙ
|
||||
# =========================================================
|
||||
ENABLE_SERVICE_DESK = True # Модуль создания заявок в техподдержку
|
||||
ENABLE_PHOTO_BOT = True # Модуль обработки фото/сканов
|
||||
ENABLE_TRANSCRIPTION = True # Модуль расшифровки голосовых сообщений (Whisper)
|
||||
ENABLE_SEARCH_BOT = True # Модуль интеллектуального поиска по регламентам (RAG)
|
||||
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
|
||||
|
||||
# =========================================================
|
||||
# 2. КОНТРОЛЬ ДОСТУПА
|
||||
# =========================================================
|
||||
ENABLE_WHITELIST = True # Включить режим закрытого бета-тестирования
|
||||
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
|
||||
"ds.krivochenko@tcs.sibcem.ru",
|
||||
"mm.norenberg@tcs.sibcem.ru",
|
||||
"a.kirpichev@tcs.sibcem.ru"
|
||||
]
|
||||
|
||||
# =========================================================
|
||||
# 3. НАСТРОЙКИ СВЯЗИ С КОРПОРАТИВНЫМИ СИСТЕМАМИ
|
||||
# =========================================================
|
||||
TC_SERVER = os.getenv("TC_SERVER", "") # Адрес сервера TrueConf
|
||||
|
||||
AD_SERVER = "ldap://172.16.20.20" # Адрес контроллера домена Active Directory
|
||||
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru" # Базовый путь поиска пользователей в AD
|
||||
|
||||
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru" # Сервер MS SQL для работы Личного кабинета
|
||||
SQL_DB_NAME = "BossCopy" # База данных с информацией по кадрам и ЗП
|
||||
|
||||
SD_URL = "https://servicedesk.sibcem.ru:8080" # URL портала ManageEngine ServiceDesk Plus
|
||||
DEFAULT_REQUESTER = "ai@sibcem.ru" # E-mail по умолчанию для заявок
|
||||
|
||||
SMTP_SERVER = "srvkem-mail.sibcem.ru" # Corporate почтовый сервер
|
||||
SMTP_PORT = 25 # Порт SMTP
|
||||
DEFAULT_EMAIL_FROM = "ai@sibcem.ru" # Адрес отправителя системных писем бота
|
||||
EMAIL_COOLDOWN_SEC = 10.0 # Кулдаун отправки критических алертов администратору
|
||||
|
||||
# Списки рассылок для системных уведомлений (берутся из .env, разделяются запятыми)
|
||||
ALERTS_SUPPORT_EMAILS = [e.strip() for e in os.getenv("ALERTS_SUPPORT_EMAILS", "ds.krivochenko@sibcem.ru").split(",") if e.strip()]
|
||||
ALERTS_DISABLED_USERS = [e.strip() for e in os.getenv("ALERTS_DISABLED_USERS", "").split(",") if e.strip()]
|
||||
ALERTS_ALL_TICKETS = [e.strip() for e in os.getenv("ALERTS_ALL_TICKETS", "").split(",") if e.strip()]
|
||||
|
||||
# =========================================================
|
||||
# 4. ВНЕШНИЕ МИКРОСЕРВИСЫ И ТАЙМАУТЫ СЕТИ
|
||||
# =========================================================
|
||||
TRASH_API_URL = os.getenv("TRASH_API_URL", "http://127.0.0.1:8000/upload")
|
||||
TRANSCRIPTION_API_URL = "http://172.16.80.250:8000/"
|
||||
API_HTTP_TIMEOUT = 30.0
|
||||
|
||||
# =========================================================
|
||||
# 5. ИИ: ЯЗЫКОВАЯ МОДЕЛЬ (Генерация текста)
|
||||
# =========================================================
|
||||
# ИСПРАВЛЕНО: Добавлен суффикс .gguf для однозначной адресации модели в llama-server
|
||||
OLLAMA_URL = "http://172.16.80.47:8080/v1/chat/completions"
|
||||
OLLAMA_MODEL = "gemma-4-E4B-it-Q4_K_M.gguf"
|
||||
AI_HTTP_TIMEOUT = 180.0
|
||||
OLLAMA_TEMPERATURE = 0.0
|
||||
OLLAMA_NUM_CTX = 8192
|
||||
OLLAMA_NUM_PREDICT = 1500
|
||||
|
||||
# --- Настройки для генерации темы (Lightweight model) ---
|
||||
OLLAMA_SUBJECT_URL = "http://172.16.80.47:8080/v1/chat/completions"
|
||||
OLLAMA_SUBJECT_MODEL = "gemma-4-E4B-it-Q4_K_M"
|
||||
|
||||
# =========================================================
|
||||
# 6. ИИ: RAG И ВЕКТОРНЫЙ ПОИСК (Работа с документами)
|
||||
# =========================================================
|
||||
QDRANT_HOST = "localhost"
|
||||
QDRANT_PORT = 6333
|
||||
QDRANT_COLLECTION = "sibcem_enterprise_docs"
|
||||
|
||||
# --- НАСТРОЙКИ ВНЕШНЕГО EMBEDDING API (Векторизация) ---
|
||||
EMBEDDING_API_URL = "http://127.0.0.1:8010/embed"
|
||||
EMBEDDING_MODEL_NAME = "bge-m3"
|
||||
EMBEDDING_API_TOKEN = os.getenv("EMBEDDING_API_TOKEN", "").strip(' "\'\r\n')
|
||||
|
||||
# Пути к ресурсам, формируемые динамически от корня папки бота
|
||||
SEARCH_DOCS_PATH = os.path.join(BASE_DIR, "enterprise_docs")
|
||||
LOGS_PATH = os.path.join(BASE_DIR, "search_bot", "logs")
|
||||
|
||||
# --- НАСТРОЙКИ РЕРАНКЕРА (Кросс-энкодер для переоценки релевантности) ---
|
||||
ENABLE_RERANKER = True
|
||||
RERANKER_MODE = "REMOTE_LLAMACPP"
|
||||
|
||||
# Настройки для режима "REMOTE_LLAMACPP"
|
||||
# ИСПРАВЛЕНО: Добавлен суффикс .gguf для точного роутинга на бэкенде llama-server (порт 8081)
|
||||
RERANKER_API_URL = "http://172.16.80.47:8081/v1/rerank"
|
||||
RERANKER_MODEL_NAME = "bge-reranker-v2-m3-Q8_0.gguf"
|
||||
|
||||
# Настройки для режима "LOCAL" (в оперативной памяти)
|
||||
RERANK_MODEL_PATH = "/opt/models/bge-reranker"
|
||||
|
||||
# --- НАСТРОЙКИ ФИЛЬТРАЦИИ И ЛИМИТОВ (Поиск) ---
|
||||
QDRANT_LIMIT = 10
|
||||
RERANK_TOP_K = 10
|
||||
MAX_AI_CONTEXTS = 6
|
||||
MAX_CONTEXT_LENGTH = 15000
|
||||
MAX_SINGLE_CHUNK_LENGTH = 1500
|
||||
|
||||
QDRANT_SCORE_THRESHOLD = 0.45
|
||||
RERANK_SCORE_THRESHOLD = -3.5
|
||||
TABLE_SCORE_THRESHOLD = 0.35
|
||||
QDRANT_CHUNK_WINDOW = 2
|
||||
QDRANT_SCROLL_LIMIT = 50
|
||||
|
||||
# =========================================================
|
||||
# 7. НАСТРОЙКИ СПРАВОК И ТЕСТИРОВАНИЯ (Личный кабинет)
|
||||
# =========================================================
|
||||
DOCUMENTS_TEST_MODE = False
|
||||
DOCUMENTS_TARGET_EMAIL = "ds.krivochenko@sibcem.ru"
|
||||
|
||||
# =========================================================
|
||||
# 8. ОТЛАДКА И АНАЛИТИКА
|
||||
# =========================================================
|
||||
ENABLE_DEEP_PROFILING = True
|
||||
ENABLE_QDRANT_DEBUG = True
|
||||
LOG_RAW_CHUNKS = True
|
||||
DEBUG_TEXT_LIMIT = 250
|
||||
|
||||
# =========================================================
|
||||
# 9. ПОДСИСТЕМА МОНИТОРИНГА И СТАТУСА БОТА (HEARTBEAT)
|
||||
# =========================================================
|
||||
ADMIN_CHAT_ID = "744bad93e58c4c498e15468562c6a2da3b610e9c"
|
||||
ENABLE_HEARTBEAT_MONITOR = True
|
||||
HEARTBEAT_CHECK_INTERVAL_SEC = 10.0
|
||||
HEARTBEAT_MAX_FAILURES = 3
|
||||
|
||||
# =========================================================
|
||||
# 10. БЕЗОПАСНОСТЬ И ДВУХФАКТОРНАЯ АВТОРИЗАЦИЯ (2FA / OTP)
|
||||
# =========================================================
|
||||
# Срок действия одноразового OTP-кода (180 секунд = 3 минуты)
|
||||
OTP_TTL_SEC = 180
|
||||
# Максимальное количество попыток ввода OTP-кода до срабатывания защиты
|
||||
OTP_MAX_ATTEMPTS = 3
|
||||
# Время полной аппаратной блокировки меню расчетных листов (1800 секунд = 30 минут)
|
||||
OTP_BLOCK_DURATION_SEC = 1800
|
||||
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
|
||||
SESSION_TIMEOUT = 1800
|
||||
@@ -0,0 +1,42 @@
|
||||
# /opt/trueconf_bot/config/test_pw.py
|
||||
import sys
|
||||
import os
|
||||
|
||||
PASSWORK_MODULE_PATH = "/opt/passwork"
|
||||
if PASSWORK_MODULE_PATH not in sys.path:
|
||||
sys.path.insert(0, PASSWORK_MODULE_PATH)
|
||||
|
||||
try:
|
||||
from passwork import get_passwork_secrets
|
||||
print("✅ Модуль Пассворка успешно обнаружен и импортирован!")
|
||||
except ImportError as e:
|
||||
print(f"❌ Не удалось импортировать модуль. Ошибка: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def run_test():
|
||||
print("\n📡 Запрашиваю актуальный пул секретов из текущего сейфа...")
|
||||
|
||||
# Модуль возвращает динамический словарь всего сейфа
|
||||
secrets = get_passwork_secrets()
|
||||
|
||||
if not secrets:
|
||||
print("❌ Сейф пуст или доступы в /opt/passwork/.env невалидны.")
|
||||
return
|
||||
|
||||
print("🎉 Связь установлена! Данные из текущего сейфа успешно собраны.")
|
||||
print("========================================================================")
|
||||
print(" 🔍 ТЕКУЩЕЕ СОДЕРЖИМОЕ СЕЙФА (ДИНАМИЧЕСКИЙ ВЫВОД):")
|
||||
print("========================================================================")
|
||||
|
||||
# Никакого хардкода: перебираем то, что реально пришло из Пассворка
|
||||
for index, (card_name, credentials) in enumerate(secrets.items(), 1):
|
||||
print(f"📦 [Запись {index}]")
|
||||
print(f" Название карточки: {card_name}")
|
||||
print(f" Логин в системе: {credentials['login']}")
|
||||
print(f" Актуальный пароль: {credentials['password']}")
|
||||
print("------------------------------------------------------------------------")
|
||||
|
||||
print(f"💡 Всего в текущем сейфе обнаружено карточек: {len(secrets)}\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_test()
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
bot:
|
||||
image: trueconf-bot:latest
|
||||
container_name: trueconf-bot
|
||||
volumes:
|
||||
# Код проекта (покрывает всё внутри)
|
||||
- .:/app:rw
|
||||
# Конфигурация
|
||||
- ./.env:/app/.env:rw
|
||||
# Passwork (секреты)
|
||||
- /opt/passwork:/opt/passwork:rw
|
||||
network_mode: host
|
||||
restart: always
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '2'
|
||||
@@ -0,0 +1,115 @@
|
||||
# /opt/trueconf_bot/lk/documents_service.py
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from datetime import datetime
|
||||
|
||||
import config.config as config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_user_info_for_docs(cursor, card_id: str) -> dict:
|
||||
"""Извлекает кадровые данные сотрудника по CARD_ID для оформления бланков справок"""
|
||||
current_date = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
query = """
|
||||
select e.TAB_N, a.NAME, d.NAME, hr.FULL_NAME, hr.EMAIL, f.SNAME, pr.EMAIL as workBookEmail
|
||||
from (select e.TAB_N, e.FIRM_ID, c.F_NAME, c.L_NAME, e.ID, c.M_NAME
|
||||
from UOV_SELFSERVICE_PR_EMP e
|
||||
join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID
|
||||
where c.ID = %s
|
||||
group by e.ID, e.FIRM_ID, c.F_NAME, c.L_NAME, c.M_NAME, e.TAB_N ) as e
|
||||
join UOV_SELFSERVICE_PR_TRANS t on t.EMP_ID = e.ID
|
||||
join UOV_SELFSERVICE_ST_APPOINT a on a.ID = t.APPOINT_ID
|
||||
join UOV_SELFSERVICE_HR_DEPT d on d.ID = t.DEPT_ID
|
||||
join UOV_SELFSERVICE_HR_S_USER_SL hr on hr.FIRM_ID = e.FIRM_ID
|
||||
join UOV_SELFSERVICE_HR_FIRM f on e.FIRM_ID=f.ID
|
||||
left join UOV_SELFSERVICE_HR_S_USER_PR pr on (pr.DEPT_ISOL_ID = d.DEPT_ISOL_ID or pr.FIRM_ID = f.ID)
|
||||
where ((t.D_FROM <= %s and t.D_TO >= %s)
|
||||
or (t.D_FROM <= %s and t.D_TO = '1999-12-31'))
|
||||
and e.FIRM_ID = f.ID
|
||||
"""
|
||||
cursor.execute(query, (card_id, current_date, current_date, current_date))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
raise Exception("Не удалось найти кадровые данные сотрудника для заказа справки.")
|
||||
|
||||
return {
|
||||
'tab_n': str(row[0]) if row[0] else "Не указан",
|
||||
'job': str(row[1]) if row[1] else "",
|
||||
'dept': str(row[2]) if row[2] else "",
|
||||
'hr_fio': str(row[3]) if row[3] else "",
|
||||
'hr_email': str(row[4]) if row[4] else "",
|
||||
'firm': str(row[5]) if row[5] else "",
|
||||
'wb_email': str(row[6]) if row[6] else ""
|
||||
}
|
||||
|
||||
def generate_mail_message(doc_name: str, quantity: int, user_info: dict, period: str = "", attachment_path: str = "") -> bool:
|
||||
"""Формирует и отправляет HTML-письмо заявки на справку в отдел кадров СибЦема"""
|
||||
|
||||
# Извлекаем оригинальные адреса кадровиков из структуры БД
|
||||
orig_to = user_info.get('hr_email', '').strip()
|
||||
# Копия уходит на ящик трудовых книжек только если заказывают пункт 7
|
||||
orig_cc = user_info.get('wb_email', '').strip() if doc_name == "Копия трудовой книжки" else ""
|
||||
|
||||
to_email = orig_to
|
||||
cc_list = [orig_cc] if orig_cc else []
|
||||
|
||||
# Если включен DOCUMENTS_TEST_MODE, жестко перехватываем поток писем
|
||||
if getattr(config, 'DOCUMENTS_TEST_MODE', False):
|
||||
test_email = getattr(config, 'DOCUMENTS_TARGET_EMAIL', 'mailonline@sibcem.ru')
|
||||
to_email = test_email
|
||||
cc_list = []
|
||||
|
||||
subject = f"Запрос документа {doc_name} из Личного кабинета"
|
||||
period_str = f" {period}" if period else ""
|
||||
|
||||
messageBody = (
|
||||
f"Необходимо оформить документ {doc_name} в количестве {quantity} шт. "
|
||||
f"на сотрудника предприятия {user_info['firm']} с табельным номером - "
|
||||
f"\"{user_info['tab_n']}\"{period_str}.<br/><br/>"
|
||||
f"Данное письмо отправлено автоматически, отвечать на него не стоит."
|
||||
)
|
||||
|
||||
message = MIMEMultipart()
|
||||
message['From'] = getattr(config, 'DEFAULT_EMAIL_FROM', 'mailonline@sibcem.ru')
|
||||
message['To'] = to_email
|
||||
message['Subject'] = subject
|
||||
|
||||
if cc_list:
|
||||
message['Cc'] = ", ".join(cc_list)
|
||||
|
||||
message.attach(MIMEText(messageBody, 'html', 'utf-8'))
|
||||
|
||||
if attachment_path:
|
||||
try:
|
||||
with open(attachment_path, 'rb') as attachment:
|
||||
part = MIMEBase("application", "octet-stream")
|
||||
part.set_payload(attachment.read())
|
||||
encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", f"attachment; filename={attachment_path}")
|
||||
message.attach(part)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось прикрепить файл к письму: {e}")
|
||||
|
||||
recipients = [to_email] + cc_list
|
||||
smtp_server = getattr(config, 'SMTP_SERVER', 'srvkem-mail.sibcem.ru')
|
||||
smtp_port = int(getattr(config, 'SMTP_PORT', 25))
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP(smtp_server, smtp_port)
|
||||
server.starttls()
|
||||
# Если в конфигурации Passwork лежат логин/пароль для SMTP — авторизуемся
|
||||
if getattr(config, 'SMTP_USER', None):
|
||||
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
|
||||
|
||||
server.sendmail(message['From'], recipients, message.as_string())
|
||||
server.quit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка SMTP при отправке письма: {e}")
|
||||
return False
|
||||
+682
@@ -0,0 +1,682 @@
|
||||
from utils.texts import *
|
||||
# /opt/trueconf_bot/lk/handlers.py
|
||||
import re
|
||||
import os
|
||||
import csv
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
import smtplib
|
||||
from datetime import datetime, timedelta
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from ldap3 import Server, Connection, ALL
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from trueconf import Router, Message
|
||||
from trueconf.types import FSInputFile
|
||||
|
||||
import config.config as config
|
||||
import lk.payslip_service as ps
|
||||
import lk.documents_service as ds
|
||||
from utils.states import get_state, set_state, clear_state
|
||||
|
||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||
from utils.stats_logger import log_menu_stats
|
||||
|
||||
# 🔌 Импортируем функции безопасности из изолированного утилитного модуля 2FA
|
||||
from utils.otp_service import generate_otp_code, send_otp_via_api, get_user_phone_from_ad
|
||||
|
||||
# 🔌 Импортируем вынесенную бизнес-логику запросов по CARD_ID
|
||||
from lk.info_service import (
|
||||
get_vacation_records,
|
||||
get_rewards_text,
|
||||
get_sick_leaves_text,
|
||||
get_tabel_report_text,
|
||||
get_lk_profile_by_card_id,
|
||||
get_missions_records,
|
||||
get_transfers_records
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =========================================================
|
||||
# ГЛОБАЛЬНЫЙ МАРШРУТИЗАТОР ЦЕЛЬНЫХ VK-ЭМОДЗИ И СЕССИЙ ПАГИНАЦИИ
|
||||
# =========================================================
|
||||
EMOJI_DIGITS = {
|
||||
"0": "0⃣",
|
||||
"1": "1⃣",
|
||||
"2": "2⃣",
|
||||
"3": "3⃣",
|
||||
"4": "4⃣",
|
||||
"5": "5⃣",
|
||||
"6": "6⃣",
|
||||
"7": "7⃣",
|
||||
"8": "8⃣",
|
||||
"9": "9⃣",
|
||||
"10": "🔟"
|
||||
}
|
||||
|
||||
# Список из 7 справок строго по структуру портала
|
||||
DOC_TYPES = {
|
||||
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
||||
"2": "Справка об удержаниях за ДМС",
|
||||
"3": "Справка о средней заработной плате",
|
||||
"4": "Справка о получении единовременного пособия по рождению ребенка",
|
||||
"5": "Справка о получении ежемесячного пособия и назначении отпуска по уходу за ребенком",
|
||||
"6": "Справка об удержаниях по исполнительным листам",
|
||||
"7": "Копия трудовой книжки"
|
||||
}
|
||||
|
||||
LK_DOCS_MENU_TEXT = (
|
||||
"📄 <b>Выберите тип документа:</b>\n\n" +
|
||||
"\n".join([f"{EMOJI_DIGITS[k]} — {v}" for k, v in DOC_TYPES.items()]) +
|
||||
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
USER_COOLDOWN_SEC = 1.5
|
||||
EMAIL_COOLDOWN_SEC = 10.0
|
||||
|
||||
user_last_request = {}
|
||||
last_email_time = 0.0
|
||||
user_docs_session = {}
|
||||
user_page_sessions = {}
|
||||
|
||||
# 🔒 Репозиторий временных блокировок 2FA (хранит user_id: timestamp_разблокировки)
|
||||
user_payslip_blocks = {}
|
||||
|
||||
router = Router()
|
||||
|
||||
# =========================================================
|
||||
# СИСТЕМНЫЕ ФУНКЦИИ И ОШИБКИ
|
||||
# =========================================================
|
||||
def log_and_notify_error(user_id: str, action: str, error_details: str):
|
||||
global last_email_time
|
||||
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lk", "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, "lk_errors.log")
|
||||
current_time = time.time()
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_entry = f"[{timestamp}] User: {user_id} | Action: {action} | Error: {error_details}\n"
|
||||
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(log_entry)
|
||||
|
||||
if current_time - last_email_time < EMAIL_COOLDOWN_SEC:
|
||||
return
|
||||
last_email_time = current_time
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = str(getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com'))
|
||||
to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', 'admin@example.com')
|
||||
msg['To'] = ", ".join(to_emails) if isinstance(to_emails, list) else str(to_emails)
|
||||
msg['Subject'] = f"TrueConf Bot Error: ЛК - {action}"
|
||||
body = f"Обнаружена ошибка в ЛК.\nВремя: {timestamp}\nПользователь: {user_id}\nДействие: {action}\nДетали:\n{error_details}"
|
||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||
import lk.info_service as info_service
|
||||
with smtplib.SMTP(getattr(config, 'SMTP_SERVER', 'localhost'), int(getattr(config, 'SMTP_PORT', 25))) as server:
|
||||
server.send_message(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def handle_lk_error(msg: Message, user_id: str, action: str, error_details: str):
|
||||
await asyncio.to_thread(log_and_notify_error, user_id, action, error_details)
|
||||
error_msg = lk_error_handling(EMOJI_DIGITS)
|
||||
await msg.answer(error_msg, parse_mode="html")
|
||||
|
||||
# =========================================================
|
||||
# ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2
|
||||
# =========================================================
|
||||
def get_card_id_from_ad(user_id: str) -> str:
|
||||
try:
|
||||
server = Server(config.AD_SERVER, get_info=ALL)
|
||||
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
|
||||
safe_user_id = escape_filter_chars(user_id)
|
||||
short_username = user_id.split('@')[0]
|
||||
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
|
||||
|
||||
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
|
||||
|
||||
if conn.entries and 'extensionAttribute2' in conn.entries[0] and conn.entries[0].extensionAttribute2.value:
|
||||
return str(conn.entries[0].extensionAttribute2.value).strip()
|
||||
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
||||
except Exception as e: raise Exception(f"AD Card Query Error: {e}")
|
||||
|
||||
# =========================================================
|
||||
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
|
||||
# =========================================================
|
||||
async def send_paginated_page(msg: Message, user_id: str):
|
||||
session = user_page_sessions.get(user_id)
|
||||
if not session: return
|
||||
|
||||
current_page = session["current_page"]
|
||||
per_page = session["per_page"]
|
||||
items = session["items"]
|
||||
|
||||
total_items = len(items)
|
||||
total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1
|
||||
|
||||
start_idx = current_page * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_items = items[start_idx:end_idx]
|
||||
|
||||
if session["type"] == "vacation":
|
||||
body_text = session["static_header"] + "\n📜 <b>История отпусков (полный архив):</b>\n"
|
||||
body_text += "\n".join(page_items) + "\n" if page_items else "• <i>История отпусков пуста</i>\n"
|
||||
elif session["type"] == "missions":
|
||||
body_text = "🧳 <b>Информация о командировках (архив):</b>\n\n"
|
||||
body_text += "\n\n".join(page_items) + "\n" if page_items else "• <i>Записи о командировках отсутствуют</i>\n"
|
||||
elif session["type"] == "transfers":
|
||||
body_text = "🔄 <b>История кадровых перемещений:</b>\n\n"
|
||||
body_text += "\n\n".join(page_items) + "\n" if page_items else "• <i>Записи о перемещениях отсутствуют</i>\n"
|
||||
|
||||
body_text += f"\n📄 <b>Страница {current_page + 1} из {total_pages}</b>\n\n"
|
||||
|
||||
nav_lines = []
|
||||
if current_page > 0: nav_lines.append(f"{EMOJI_DIGITS['7']} — Предыдущая страница")
|
||||
if current_page < total_pages - 1: nav_lines.append(f"{EMOJI_DIGITS['8']} — Следующая страница")
|
||||
nav_lines.append(f"{EMOJI_DIGITS['9']} — Назад")
|
||||
nav_lines.append(f"{EMOJI_DIGITS['0']} — В главное меню")
|
||||
|
||||
body_text += "\n".join(nav_lines)
|
||||
await msg.answer(body_text, parse_mode="html")
|
||||
|
||||
# =========================================================
|
||||
# ДИНАМИЧЕСКАЯ СБОРКА ГЛАВНОГО МЕНЮ ЛК
|
||||
# =========================================================
|
||||
async def send_dynamic_lk_main_menu(msg: Message, user_id: str):
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
profile = await asyncio.to_thread(get_lk_profile_by_card_id, card_id)
|
||||
|
||||
header = (
|
||||
f"👤 <b>Личный кабинет</b>\n\n"
|
||||
f"🏢 <b>Компания:</b> {profile['company']}\n"
|
||||
f"📂 <b>Отдел:</b> {profile['dept']}\n"
|
||||
f"💼 <b>Должность:</b> {profile['job']}\n"
|
||||
f"⏳ <b>Страховой стаж:</b> {profile['stag_o']}\n"
|
||||
f"📈 <b>Стаж на предприятии:</b> {profile['stag_p']}\n\n"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
|
||||
header = "👤 <b>Личный кабинет</b>\n\n"
|
||||
|
||||
menu_text = lk_main_menu_text(header, EMOJI_DIGITS)
|
||||
await msg.answer(menu_text, parse_mode="html")
|
||||
|
||||
async def send_tabel_months_menu(msg: Message, user_id: str):
|
||||
now = datetime(2026, 6, 3)
|
||||
prev_1 = now.replace(day=1) - timedelta(days=1)
|
||||
prev_2 = prev_1.replace(day=1) - timedelta(days=1)
|
||||
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
|
||||
|
||||
menu_text = lk_tabel_months_menu_text(prev_1, prev_2, months_ru, EMOJI_DIGITS)
|
||||
set_state(user_id, "LK_TABEL_MONTHS")
|
||||
await msg.answer(menu_text, parse_mode="html")
|
||||
|
||||
def wrapper_get_months(card_id: str) -> list:
|
||||
import lk.info_service as info_service
|
||||
conn = info_service.get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
try: return ps.get_latest_payslip_months(cursor, card_id)
|
||||
finally: conn.close()
|
||||
|
||||
def wrapper_get_payslip(card_id: str, target_cmonth: int):
|
||||
import lk.info_service as info_service
|
||||
conn = info_service.get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
try: return ps.build_payslip_data(cursor, card_id, target_cmonth)
|
||||
except Exception as e: raise Exception(f"Payslip Build Error: {e}")
|
||||
finally: conn.close()
|
||||
|
||||
async def send_payslip_months_menu(msg: Message, user_id: str, card_id: str):
|
||||
try:
|
||||
months_data = await asyncio.to_thread(wrapper_get_months, card_id)
|
||||
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
|
||||
menu_text = lk_payslip_months_menu_text(months_data, months_ru, EMOJI_DIGITS)
|
||||
set_state(user_id, "LK_PAYSLIP_MONTHS")
|
||||
await msg.answer(menu_text, parse_mode="html")
|
||||
except Exception as e: raise Exception(str(e))
|
||||
|
||||
async def build_confirmation_text(user_id: str, doc: dict) -> str:
|
||||
confirm_txt = f"📝 <b>Подтверждение заказа:</b>\n\nДокумент: {doc['doc_name']}\nКоличество: {doc['quantity']} шт."
|
||||
if doc.get('period'): confirm_txt += f"\nУточнение: {doc['period']}"
|
||||
if getattr(config, 'DOCUMENTS_TEST_MODE', False):
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
import lk.info_service as info_service
|
||||
with info_service.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
user_info = await asyncio.to_thread(ds.get_user_info_for_docs, cursor, card_id)
|
||||
|
||||
orig_to = user_info.get('hr_email', '').strip()
|
||||
orig_cc = user_info.get('wb_email', '').strip() if doc['doc_id'] == "7" else ""
|
||||
requested_recipients = orig_to
|
||||
if orig_cc: requested_recipients += f" (Копия: {orig_cc})"
|
||||
test_email = getattr(config, 'DOCUMENTS_TARGET_EMAIL', 'mailonline@sibcem.ru')
|
||||
confirm_txt += f"\n\n⚠️ <b>Тест-режим.</b>\nКому: <code>{requested_recipients}</code>\nЗамена: <code>{test_email}</code>"
|
||||
except Exception as e: confirm_txt += f"\n\n⚠️ <b>Тест-режим.</b>\n(Ошибка БД: {e})"
|
||||
confirm_txt += f"\n\n<i>{EMOJI_DIGITS['1']} — Да, отправить\n{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||
return confirm_txt
|
||||
|
||||
# =========================================================
|
||||
# ОСНОВНОЙ ОБРАБОТЧИК МАРШРУТОВ ЛК
|
||||
# =========================================================
|
||||
@router.message()
|
||||
async def lk_router_handler(msg: Message):
|
||||
user_id = msg.from_user.id
|
||||
current_time = time.time()
|
||||
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return
|
||||
user_last_request[user_id] = current_time
|
||||
|
||||
current_state = get_state(user_id)
|
||||
nav_text = lk_nav_text(EMOJI_DIGITS)
|
||||
|
||||
if not msg.text: return
|
||||
cmd = msg.text.strip().lower()
|
||||
|
||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||
if cmd == emoji_num:
|
||||
cmd = raw_num
|
||||
break
|
||||
|
||||
if current_state is None:
|
||||
from utils.menu import MENU_MAP
|
||||
if MENU_MAP.get(cmd) == "LK":
|
||||
msg.handled = True
|
||||
set_state(user_id, "LK_MODE")
|
||||
await send_dynamic_lk_main_menu(msg, user_id)
|
||||
return
|
||||
return
|
||||
|
||||
if current_state == "LK_MODE":
|
||||
msg.handled = True
|
||||
if cmd == "0":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Выход в главное меню")
|
||||
clear_state(user_id)
|
||||
from utils.menu import MENU_TEXT
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "1":
|
||||
block_expiry = user_payslip_blocks.get(user_id, 0)
|
||||
if time.time() < block_expiry:
|
||||
rem_minutes = int((block_expiry - time.time()) // 60) + 1
|
||||
await msg.answer(
|
||||
f"❌ <b>Доступ временно заблокирован.</b>\n"
|
||||
f"Вы трижды ввели неверный код подтверждения.\n"
|
||||
f"Доступ к расчетным листам ограничен на {int(getattr(config, 'OTP_BLOCK_DURATION_SEC', 1800) // 60)} минут.\n\n"
|
||||
f"⏱ Осталось подождать: <b>{rem_minutes} мин.</b>{nav_text}",
|
||||
parse_mode="html"
|
||||
)
|
||||
return
|
||||
|
||||
log_menu_stats(user_id, "Личный кабинет", "Расчётный лист")
|
||||
await msg.answer(lk_payslip_loading(EMOJI_DIGITS), parse_mode="html")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
await send_payslip_months_menu(msg, user_id, card_id)
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Меню расчетных листков", str(e))
|
||||
elif cmd == "2":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Информация о днях отпуска")
|
||||
await msg.answer(lk_vacation_loading(EMOJI_DIGITS), parse_mode="html")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
static_header, history_lines = await asyncio.to_thread(get_vacation_records, card_id)
|
||||
user_page_sessions[user_id] = {"type": "vacation", "current_page": 0, "per_page": 5, "static_header": static_header, "items": history_lines}
|
||||
set_state(user_id, "LK_VACATION_VIEW")
|
||||
await send_paginated_page(msg, user_id)
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Справка по отпускам", str(e))
|
||||
elif cmd == "3":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Больничные")
|
||||
await msg.answer(lk_sick_loading(EMOJI_DIGITS), parse_mode="html")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
sick_info = await asyncio.to_thread(get_sick_leaves_text, card_id)
|
||||
set_state(user_id, "LK_SICK_VIEW")
|
||||
await msg.answer(sick_info + nav_text, parse_mode="html")
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Просмотр больничных", str(e))
|
||||
elif cmd == "4":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Командировки")
|
||||
await msg.answer(lk_missions_loading(EMOJI_DIGITS), parse_mode="html")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
mission_lines = await asyncio.to_thread(get_missions_records, card_id)
|
||||
user_page_sessions[user_id] = {"type": "missions", "current_page": 0, "per_page": 4, "items": mission_lines}
|
||||
set_state(user_id, "LK_MISSIONS_VIEW")
|
||||
await send_paginated_page(msg, user_id)
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Просмотр командировок", str(e))
|
||||
elif cmd == "5":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Награды")
|
||||
await msg.answer(lk_rewards_loading(EMOJI_DIGITS), parse_mode="html")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
rewards_info = await asyncio.to_thread(get_rewards_text, card_id)
|
||||
set_state(user_id, "LK_REWARDS_VIEW")
|
||||
await msg.answer(rewards_info + nav_text, parse_mode="html")
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Запрос наград", str(e))
|
||||
elif cmd == "6":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Переводы")
|
||||
await msg.answer(lk_transfers_loading(EMOJI_DIGITS), parse_mode="html")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
transfer_lines = await asyncio.to_thread(get_transfers_records, card_id)
|
||||
user_page_sessions[user_id] = {"type": "transfers", "current_page": 0, "per_page": 4, "items": transfer_lines}
|
||||
set_state(user_id, "LK_TRANSFERS_VIEW")
|
||||
await send_paginated_page(msg, user_id)
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Просмотр переводов", str(e))
|
||||
elif cmd == "7":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Табель учёта рабочего времени")
|
||||
await send_tabel_months_menu(msg, user_id)
|
||||
elif cmd == "8":
|
||||
log_menu_stats(user_id, "Личный кабинет", "Справки")
|
||||
set_state(user_id, "LK_DOCS_MENU")
|
||||
await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
||||
else:
|
||||
# 🎯 ИСПРАВЛЕНО: Вместо спама копии меню ЛК выводим строгое предупреждение
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_TABEL_MONTHS":
|
||||
msg.handled = True
|
||||
if cmd == "0":
|
||||
clear_state(user_id)
|
||||
from utils.menu import MENU_TEXT
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9":
|
||||
set_state(user_id, "LK_MODE")
|
||||
await send_dynamic_lk_main_menu(msg, user_id)
|
||||
elif cmd in ["1", "2"]:
|
||||
log_menu_stats(user_id, "Личный кабинет", "Просмотр табеля за месяц")
|
||||
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
now = datetime(2026, 6, 3)
|
||||
target_date = now.replace(day=1) - timedelta(days=1) if cmd == "1" else (now.replace(day=1) - timedelta(days=1)).replace(day=1) - timedelta(days=1)
|
||||
tabel_report = await asyncio.to_thread(get_tabel_report_text, card_id, target_date.year, target_date.month)
|
||||
set_state(user_id, "LK_TABEL_VIEW")
|
||||
await msg.answer(tabel_report + nav_text, parse_mode="html")
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Формирование табеля", str(e))
|
||||
else:
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state in ["LK_VACATION_VIEW", "LK_MISSIONS_VIEW", "LK_TRANSFERS_VIEW"]:
|
||||
msg.handled = True
|
||||
session = user_page_sessions.get(user_id)
|
||||
if cmd == "0":
|
||||
user_page_sessions.pop(user_id, None)
|
||||
clear_state(user_id)
|
||||
from utils.menu import MENU_TEXT
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9":
|
||||
user_page_sessions.pop(user_id, None)
|
||||
set_state(user_id, "LK_MODE")
|
||||
await send_dynamic_lk_main_menu(msg, user_id)
|
||||
elif cmd == "7" and session and session["current_page"] > 0:
|
||||
session["current_page"] -= 1
|
||||
await send_paginated_page(msg, user_id)
|
||||
elif cmd == "8" and session:
|
||||
if session["current_page"] < (len(session["items"]) + session["per_page"] - 1) // session["per_page"] - 1:
|
||||
session["current_page"] += 1
|
||||
await send_paginated_page(msg, user_id)
|
||||
else:
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_PAYSLIP_AUTH":
|
||||
msg.handled = True
|
||||
session = user_page_sessions.get(user_id)
|
||||
|
||||
if cmd == "0":
|
||||
user_page_sessions.pop(user_id, None)
|
||||
clear_state(user_id)
|
||||
from utils.menu import MENU_TEXT
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
elif cmd == "9":
|
||||
user_page_sessions.pop(user_id, None)
|
||||
set_state(user_id, "LK_PAYSLIP_MONTHS")
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
await send_payslip_months_menu(msg, user_id, card_id)
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Возврат к меню месяцев", str(e))
|
||||
return
|
||||
|
||||
if not session:
|
||||
await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
|
||||
set_state(user_id, "LK_MODE")
|
||||
await send_dynamic_lk_main_menu(msg, user_id)
|
||||
return
|
||||
|
||||
if time.time() > session.get("expires_at", 0):
|
||||
log_menu_stats(user_id, "Личный кабинет", "Ошибка 2FA: Истекло время жизни кода")
|
||||
user_page_sessions.pop(user_id, None)
|
||||
set_state(user_id, "LK_MODE")
|
||||
await msg.answer(
|
||||
f"⏱ <b>Время действия кода подтверждения изменилось или истекло.</b>\n"
|
||||
f"Пожалуйста, запросите расчетный лист заново.{nav_text}",
|
||||
parse_mode="html"
|
||||
)
|
||||
await send_dynamic_lk_main_menu(msg, user_id)
|
||||
return
|
||||
|
||||
if cmd == session.get("correct_code"):
|
||||
log_menu_stats(user_id, "Личный кабинет", "Успешная проверка 2FA расчетного листа")
|
||||
await msg.answer(lk_auth_success(EMOJI_DIGITS), parse_mode="html")
|
||||
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
target_cmonth = session["target_cmonth"]
|
||||
|
||||
m_name = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
|
||||
tmonth = (target_cmonth - 1) % 12 + 1
|
||||
tyear = (target_cmonth - 1) // 12
|
||||
|
||||
payslip_info, pdf_path = await asyncio.to_thread(wrapper_get_payslip, card_id, target_cmonth)
|
||||
|
||||
set_state(user_id, "LK_PAYSLIP_VIEW")
|
||||
await msg.answer_document(
|
||||
FSInputFile(pdf_path, filename=f"Расчетный_лист_{m_name.get(tmonth, '00')}_{tyear}.pdf"),
|
||||
caption=payslip_info + f"\n\n<i>{EMOJI_DIGITS['9']} — Назад к выбору месяца\n{EMOJI_DIGITS['0']} — В главное меню</i>",
|
||||
parse_mode="html"
|
||||
)
|
||||
if os.path.exists(pdf_path): os.remove(pdf_path)
|
||||
except Exception as e:
|
||||
await handle_lk_error(msg, user_id, "Просмотр детализации после 2FA", str(e))
|
||||
finally:
|
||||
user_page_sessions.pop(user_id, None)
|
||||
else:
|
||||
session["attempts_left"] -= 1
|
||||
if session["attempts_left"] <= 0:
|
||||
log_menu_stats(user_id, "Личный кабинет", "Ошибка 2FA: Превышен лимит попыток ввода, бан")
|
||||
|
||||
block_duration = int(getattr(config, 'OTP_BLOCK_DURATION_SEC', 1800))
|
||||
user_payslip_blocks[user_id] = time.time() + block_duration
|
||||
|
||||
user_page_sessions.pop(user_id, None)
|
||||
set_state(user_id, "LK_MODE")
|
||||
await msg.answer(
|
||||
f"❌ <b>Превышено максимальное количество попыток ввода кода.</b>\n"
|
||||
f"Доступ к финансовым документам заблокирован на <b>{block_duration // 60} минут</b>.{nav_text}",
|
||||
parse_mode="html"
|
||||
)
|
||||
await send_dynamic_lk_main_menu(msg, user_id)
|
||||
else:
|
||||
await msg.answer(
|
||||
f"⚠️ <b>Неверный код подтверждения.</b>\n"
|
||||
f"Осталось попыток: {session['attempts_left']}. Попробуйте еще раз или вернитесь назад.{nav_text}",
|
||||
parse_mode="html"
|
||||
)
|
||||
|
||||
elif current_state in ["LK_REWARDS_VIEW", "LK_PAYSLIP_MONTHS", "LK_SICK_VIEW", "LK_TABEL_VIEW", "LK_STUB_VIEW"]:
|
||||
msg.handled = True
|
||||
if cmd == "0":
|
||||
clear_state(user_id)
|
||||
from utils.menu import MENU_TEXT
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9":
|
||||
if current_state == "LK_TABEL_VIEW": await send_tabel_months_menu(msg, user_id)
|
||||
else: set_state(user_id, "LK_MODE"); await send_dynamic_lk_main_menu(msg, user_id)
|
||||
elif current_state == "LK_PAYSLIP_MONTHS" and cmd.isdigit():
|
||||
try:
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
months_data = await asyncio.to_thread(wrapper_get_months, card_id)
|
||||
idx = int(cmd) - 1
|
||||
|
||||
if 0 <= idx < len(months_data):
|
||||
target_cmonth = months_data[idx]
|
||||
|
||||
await msg.answer(lk_auth_processing(EMOJI_DIGITS), parse_mode="html")
|
||||
user_phone = await asyncio.to_thread(get_user_phone_from_ad, user_id)
|
||||
|
||||
otp_code = generate_otp_code(4)
|
||||
sms_success, channel_info = await send_otp_via_api(user_phone, otp_code)
|
||||
|
||||
if sms_success:
|
||||
log_menu_stats(user_id, "Личный кабинет", f"Запрос кода 2FA для расчетного листа ({channel_info})")
|
||||
masked_phone = f"{user_phone[:5]}***{user_phone[-4:]}" if len(user_phone) > 8 else user_phone
|
||||
|
||||
otp_ttl = int(getattr(config, 'OTP_TTL_SEC', 180))
|
||||
max_attempts = int(getattr(config, 'OTP_MAX_ATTEMPTS', 3))
|
||||
|
||||
user_page_sessions[user_id] = {
|
||||
"correct_code": otp_code,
|
||||
"target_cmonth": target_cmonth,
|
||||
"expires_at": time.time() + otp_ttl,
|
||||
"attempts_left": max_attempts
|
||||
}
|
||||
|
||||
set_state(user_id, "LK_PAYSLIP_AUTH")
|
||||
await msg.answer(
|
||||
f"🔐 <b>Двухфакторная авторизация</b>\n\n"
|
||||
f"Код подтверждения отправлен <b>{channel_info}</b> на номер <code>{masked_phone}</code>.\n"
|
||||
f"Пожалуйста, введите 4-значный код из сообщения в ответ:\n\n"
|
||||
f"<i>Код действителен в течение {otp_ttl // 60} минут. У вас есть {max_attempts} попытки.</i>{nav_text}",
|
||||
parse_mode="html"
|
||||
)
|
||||
else:
|
||||
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
|
||||
else:
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Инициализация 2FA расчетного листа", str(e))
|
||||
else:
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_PAYSLIP_VIEW":
|
||||
msg.handled = True
|
||||
if cmd == "0": clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9":
|
||||
try: card_id = await asyncio.to_thread(get_card_id_from_ad, user_id); await send_payslip_months_menu(msg, user_id, card_id)
|
||||
except Exception as e: await handle_lk_error(msg, user_id, "Возврат в меню месяцев", str(e))
|
||||
else:
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_DOCS_MENU":
|
||||
msg.handled = True
|
||||
if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9": user_docs_session.pop(user_id, None); set_state(user_id, "LK_MODE"); await send_dynamic_lk_main_menu(msg, user_id)
|
||||
elif cmd in DOC_TYPES:
|
||||
user_docs_session[user_id] = {'doc_id': cmd, 'doc_name': DOC_TYPES[cmd]}
|
||||
log_menu_stats(user_id, "Личный кабинет", f"Выбор справки: {DOC_TYPES[cmd]}")
|
||||
set_state(user_id, "LK_DOCS_QUANTITY")
|
||||
await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||
else:
|
||||
# 🎯 ИСПРАВЛЕНО: Заменен спам списка документов на чистую ошибку ввода
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_DOCS_QUANTITY":
|
||||
msg.handled = True
|
||||
if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9": set_state(user_id, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
||||
elif cmd.isdigit() and 0 < int(cmd) <= 5:
|
||||
user_docs_session[user_id]['quantity'] = int(cmd)
|
||||
doc_id = user_docs_session[user_id]['doc_id']
|
||||
|
||||
if doc_id in ["1", "2", "6"]:
|
||||
set_state(user_id, "LK_DOCS_PERIOD")
|
||||
current_year = datetime.now().year
|
||||
years_text = lk_years_menu(current_year, EMOJI_DIGITS)
|
||||
await msg.answer(years_text, parse_mode="html")
|
||||
elif doc_id == "3":
|
||||
set_state(user_id, "LK_DOCS_PERIOD")
|
||||
await msg.answer(lk_docs_avg_salary_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||
elif doc_id == "5":
|
||||
set_state(user_id, "LK_DOCS_PERIOD")
|
||||
await msg.answer(lk_docs_child_age_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||
else:
|
||||
user_docs_session[user_id]['period'] = ""
|
||||
set_state(user_id, "LK_DOCS_CONFIRM")
|
||||
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
|
||||
await msg.answer(confirm_txt, parse_mode="html")
|
||||
else:
|
||||
# 🎯 ИСПРАВЛЕНО: Вместо кастомной подсказки выводим единый стандарт
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_DOCS_PERIOD":
|
||||
msg.handled = True
|
||||
if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9": set_state(user_id, "LK_DOCS_QUANTITY"); await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||
else:
|
||||
doc_id = user_docs_session[user_id]['doc_id']
|
||||
if doc_id in ["1", "2", "6"]:
|
||||
if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]:
|
||||
user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год"
|
||||
else:
|
||||
# 🎯 ИСПРАВЛЕНО: Убран спам меню годов при абракадабре
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
elif doc_id == "5":
|
||||
if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет"
|
||||
elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет"
|
||||
else:
|
||||
# 🎯 ИСПРАВЛЕНО: Убран спам меню возраста при некорректном вводе
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
elif doc_id == "3":
|
||||
user_docs_session[user_id]['period'] = f"за {cmd} мес."
|
||||
|
||||
set_state(user_id, "LK_DOCS_CONFIRM")
|
||||
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
|
||||
await msg.answer(confirm_txt, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_DOCS_CONFIRM":
|
||||
msg.handled = True
|
||||
if cmd == "0": user_docs_session.pop(user_id, None); clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9":
|
||||
doc_id = user_docs_session.get(user_id, {}).get('doc_id')
|
||||
if doc_id in ["1", "2", "3", "5", "6"]:
|
||||
set_state(user_id, "LK_DOCS_PERIOD")
|
||||
if doc_id in ["1", "2", "6"]:
|
||||
current_year = datetime.now().year
|
||||
years_text = lk_years_menu(current_year, EMOJI_DIGITS)
|
||||
await msg.answer(years_text, parse_mode="html")
|
||||
elif doc_id == "3": await msg.answer(lk_docs_avg_salary_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||
elif doc_id == "5": await msg.answer(lk_docs_child_age_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||
else: set_state(user_id, "LK_DOCS_QUANTITY"); await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
|
||||
elif cmd == "1":
|
||||
await msg.answer("⏳ <i>Отправка заявки...</i>", parse_mode="html")
|
||||
try:
|
||||
doc = user_docs_session[user_id]
|
||||
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
|
||||
|
||||
import lk.info_service as info_service
|
||||
with info_service.get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
user_info = await asyncio.to_thread(ds.get_user_info_for_docs, cursor, card_id)
|
||||
|
||||
success = await asyncio.to_thread(ds.generate_mail_message, doc['doc_name'], doc['quantity'], user_info, doc.get('period', ''))
|
||||
if success:
|
||||
log_menu_stats(user_id, "Личный кабинет", f"Заказ справки: {doc['doc_name']}")
|
||||
await msg.answer(f"✅ <b>Ваша заявка успешно отправлена!</b>\nСпециалисты свяжутся с вами по готовности документа.\n\n<i>{EMOJI_DIGITS['9']} — Назад в список справок\n{EMOJI_DIGITS['0']} — В главное меню</i>", parse_mode="html")
|
||||
set_state(user_id, "LK_DOCS_SUCCESS")
|
||||
else: raise Exception("Внутренняя ошибка SMTP при отправке письма")
|
||||
except Exception as e: await handle_lk_error(msg, user_id, f"Заказ справки {user_docs_session.get(user_id, {}).get('doc_id')}", str(e)); set_state(user_id, "LK_MODE")
|
||||
finally: user_docs_session.pop(user_id, None)
|
||||
else:
|
||||
# 🎯 ИСПРАВЛЕНО: Удален спам-текст подтверждения
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
|
||||
elif current_state == "LK_DOCS_SUCCESS":
|
||||
msg.handled = True
|
||||
if cmd == "0": clear_state(user_id); from utils.menu import MENU_TEXT; await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
elif cmd == "9": set_state(user_id, "LK_DOCS_MENU"); await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
|
||||
else:
|
||||
# 🎯 ИСПРАВЛЕНО: Унифицировано под общий шаблон ошибки
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
@@ -0,0 +1,378 @@
|
||||
# /opt/trueconf_bot/lk/info_service.py
|
||||
import re
|
||||
import logging
|
||||
import calendar
|
||||
import pymssql
|
||||
from datetime import datetime
|
||||
|
||||
import config.config as config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_db_connection():
|
||||
if not config.DB_USER or not config.DB_PASSWORD:
|
||||
raise Exception("Пароли БД не загружены из Passwork.")
|
||||
return pymssql.connect(
|
||||
server=config.SQL_SERVER,
|
||||
user=config.DB_USER,
|
||||
password=config.DB_PASSWORD,
|
||||
database=config.SQL_DB_NAME,
|
||||
charset='cp1251'
|
||||
)
|
||||
|
||||
def get_lk_profile_by_card_id(card_id: str) -> dict:
|
||||
"""Запрос 1: Получение информации о подразделении, должности и стаже по CARD_ID"""
|
||||
try:
|
||||
current_date = datetime.now().strftime('%Y-%m-%d')
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
query = """
|
||||
select f.SNAME [Компания], d.NAME [Отдел], appoint.NAME [Должность], rtrim(ltrim(e.STAGO)) [Страховой стаж], rtrim(ltrim(e.STAGP)) [Стаж на предприятии]
|
||||
from (select e.ID, e.FIRM_ID, st.STAGO, st.STAGP
|
||||
from UOV_SELFSERVICE_PR_EMP e
|
||||
join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID
|
||||
join UOV_SELFSERVICE_PR_STAG_CUR st on st.CARD_ID=c.ID
|
||||
where c.ID=%s
|
||||
group by e.ID , e.FIRM_ID, st.STAGO, st.STAGP) as e
|
||||
join UOV_SELFSERVICE_PR_TRANS t on t.EMP_ID = e.ID
|
||||
join UOV_SELFSERVICE_HR_FIRM f on f.ID = e.FIRM_ID
|
||||
join UOV_SELFSERVICE_HR_DEPT d on d.ID = t.DEPT_ID
|
||||
join UOV_SELFSERVICE_ST_APPOINT appoint on appoint.ID = t.APPOINT_ID
|
||||
where (t.D_FROM <= cast(%s as date)
|
||||
and (t.D_TO >= cast(%s as date)
|
||||
or t.D_TO = cast('1999-12-31' as date)))
|
||||
group by f.SNAME, d.NAME, appoint.NAME, rtrim(ltrim(e.STAGO)), rtrim(ltrim(e.STAGP))
|
||||
"""
|
||||
cursor.execute(query, (card_id, current_date, current_date))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if not row: raise Exception(f"Запись сотрудника по CARD_ID {card_id} не найдена в БД.")
|
||||
return {
|
||||
'company': str(row[0]).strip() if row[0] else "",
|
||||
'dept': str(row[1]).strip() if row[1] else "",
|
||||
'job': str(row[2]).strip() if row[2] else "",
|
||||
'stag_o': str(row[3]).strip() if row[3] else "0",
|
||||
'stag_p': str(row[4]).strip() if row[4] else "0"
|
||||
}
|
||||
except Exception as e: raise Exception(f"SQL Profile Error: {e}")
|
||||
|
||||
def get_vacation_records(card_id: str) -> tuple:
|
||||
"""Возвращает статический заголовок (баланс + планы) и список строк истории отпусков"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT TOP 1 e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return "🌴 <b>Информация об отпусках</b>\n\nСотрудник не найден в базе данных.", []
|
||||
emp_id = row[0]
|
||||
|
||||
# Балансы
|
||||
q_balance = """
|
||||
SELECT comp.LEAVE_NAME, comp.DAYS FROM UO_SELFSERVICE_LEAVE_COMP as comp
|
||||
INNER JOIN UOV_SELFSERVICE_PR_TRANS as trans ON trans.EMP_ID = comp.EMP_ID
|
||||
WHERE GETDATE() BETWEEN trans.D_FROM and trans.D_TO AND comp.EMP_ID = %s
|
||||
"""
|
||||
cursor.execute(q_balance, (emp_id,))
|
||||
balances = cursor.fetchall()
|
||||
|
||||
# Планы
|
||||
q_plans = """
|
||||
SELECT e.D_FROM, e.DAYS
|
||||
FROM UOV_SELFSERVICE_PR_LEAVE_PLAN e
|
||||
WHERE e.EMP_ID = %s AND e.D_FROM >= CAST(GETDATE() AS DATE)
|
||||
ORDER BY e.D_FROM ASC
|
||||
"""
|
||||
cursor.execute(q_plans, (emp_id,))
|
||||
plans = cursor.fetchall()
|
||||
|
||||
# Архив всей истории
|
||||
q_history = """
|
||||
SELECT e.D_FROM, e.D_TO, e.DAYS, CAST(e.NAME AS VARCHAR(250)), e.ADDITIONAL
|
||||
FROM UOV_SELFSERVICE_PR_LEAVE_ALL e
|
||||
WHERE e.EMP_ID = %s AND e.D_FROM < CAST(GETDATE() AS DATE)
|
||||
ORDER BY e.D_FROM DESC
|
||||
"""
|
||||
cursor.execute(q_history, (emp_id,))
|
||||
history = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
# Формируем неизменяемую верхнюю часть (шапку) справки
|
||||
static_header = "🌴 <b>Информация об отпусках</b>\n\n🟢 <b>Доступный остаток:</b>\n"
|
||||
if balances:
|
||||
for b in balances:
|
||||
b_name = str(b[0]).strip() if b[0] else "Ежегодный оплачиваемый отпуск"
|
||||
static_header += f"• <b>{int(b[1]) if b[1] else 0} дн.</b> <i>({b_name})</i>\n"
|
||||
else:
|
||||
static_header += "• <b>0 дн.</b>\n"
|
||||
|
||||
static_header += "\n⏳ <b>Предстоящие отпуска:</b>\n"
|
||||
if plans:
|
||||
for p in plans:
|
||||
d_from = p[0].strftime('%d.%m.%Y') if hasattr(p[0], 'strftime') else str(p[0])[:10]
|
||||
static_header += f"• <b>{d_from}</b> — <b>{int(p[1]) if p[1] else 0} дн.</b>\n"
|
||||
else:
|
||||
static_header += "• <i>Нет запланированных отпусков</i>\n"
|
||||
|
||||
# Нарезаем строки истории в плоский массив для пагинатора
|
||||
history_lines = []
|
||||
if history:
|
||||
for h in history:
|
||||
h_from = h[0].strftime('%d.%m.%Y') if hasattr(h[0], 'strftime') else str(h[0])[:10]
|
||||
h_to = h[1].strftime('%d.%m.%Y') if hasattr(h[1], 'strftime') else str(h[1])[:10]
|
||||
h_days = int(h[2]) if h[2] else 0
|
||||
|
||||
if h[3] and str(h[3]).strip():
|
||||
type_str = str(h[3]).strip()
|
||||
else:
|
||||
type_str = "Дополнительный" if h[4] == 1 else "Основной"
|
||||
|
||||
history_lines.append(f"• {h_from} — {h_to} : 🔹 <b>{h_days} дн.</b> <i>({type_str})</i>")
|
||||
|
||||
return static_header, history_lines
|
||||
except Exception as e:
|
||||
raise Exception(f"SQL Vacation Records Error: {e}")
|
||||
|
||||
def get_sick_leaves_text(card_id: str) -> str:
|
||||
"""Запрашивает историю больничных листов строго по CARD_ID"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return "📑 <b>Ваши больничные листы:</b>\n\nСотрудник не найден в базе данных."
|
||||
emp_id = row[0]
|
||||
|
||||
query = """
|
||||
SELECT e.D_FROM, e.D_TO
|
||||
FROM UOV_SELFSERVICE_PR_ILL e
|
||||
WHERE e.EMP_ID = %s
|
||||
ORDER BY e.D_FROM DESC
|
||||
"""
|
||||
cursor.execute(query, (emp_id,))
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not results:
|
||||
return "📑 <b>Ваши больничные листы:</b>\n\nНичего не найдено."
|
||||
|
||||
response = "📑 <b>Ваши больничные листы:</b>\n\n"
|
||||
for r in results:
|
||||
d_from = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
|
||||
d_to = r[1].strftime('%d.%m.%Y') if hasattr(r[1], 'strftime') else str(r[1])[:10]
|
||||
|
||||
days_count = ""
|
||||
if hasattr(r[0], 'strftime') and hasattr(r[1], 'strftime'):
|
||||
delta_days = (r[1] - r[0]).days + 1
|
||||
days_count = f" — <b>{delta_days} дн.</b>"
|
||||
|
||||
response += f"🩺 <code>{d_from}</code> по <code>{d_to}</code>{days_count}\n"
|
||||
return response
|
||||
except Exception as e:
|
||||
raise Exception(f"SQL Sick Leaves Error: {e}")
|
||||
|
||||
def get_missions_records(card_id: str) -> list:
|
||||
"""Возвращает структурированный список pre-formatted текстовых блоков командировок"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
query = """
|
||||
SELECT mp.D_FROM, mp.D_TO, CAST(mp.NAME AS VARCHAR(250)), CAST(mp.CITY AS VARCHAR(250))
|
||||
FROM UOV_SELFSERVICE_PR_EMP e
|
||||
JOIN UOV_SELFSERVICE_PR_MISSION m ON e.ID = m.EMP_ID
|
||||
JOIN UOV_SELFSERVICE_PR_MISSION_P mp ON mp.MISSION_ID = m.ID
|
||||
WHERE e.CARD_ID = %s
|
||||
ORDER BY mp.D_FROM DESC
|
||||
"""
|
||||
cursor.execute(query, (card_id,))
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
mission_blocks = []
|
||||
for r in results:
|
||||
d_from = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
|
||||
d_to = r[1].strftime('%d.%m.%Y') if hasattr(r[1], 'strftime') else str(r[1])[:10]
|
||||
firm_name = str(r[2]).strip() if r[2] else "Организация не указана"
|
||||
city = str(r[3]).strip() if r[3] else "Город не указан"
|
||||
|
||||
block = f"• 📅 <code>{d_from}</code> — <code>{d_to}</code>\n 📍 г. {city}, {firm_name}"
|
||||
mission_blocks.append(block)
|
||||
|
||||
return mission_blocks
|
||||
except Exception as e:
|
||||
raise Exception(f"SQL Missions Records Error: {e}")
|
||||
|
||||
def get_transfers_records(card_id: str) -> list:
|
||||
"""Возвращает структурированный список кадровых переходов сотрудника по его CARD_ID"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
query = """
|
||||
SELECT
|
||||
tr.D_FROM,
|
||||
CASE
|
||||
WHEN tr.D_TO = CAST('1999-12-31' AS DATE) THEN CAST('2099-12-31' AS DATE)
|
||||
ELSE tr.D_TO
|
||||
END as D_TO,
|
||||
CAST(d.NAME AS VARCHAR(250)) as DEPT,
|
||||
CAST(a.NAME AS VARCHAR(250)) as APPOINT
|
||||
FROM UOV_SELFSERVICE_PR_EMP e
|
||||
JOIN UOV_SELFSERVICE_PR_TRANS tr ON tr.EMP_ID = e.ID
|
||||
JOIN UOV_SELFSERVICE_HR_DEPT d ON d.ID = tr.DEPT_ID
|
||||
JOIN UOV_SELFSERVICE_ST_APPOINT a ON a.ID = tr.APPOINT_ID
|
||||
WHERE e.CARD_ID = %s
|
||||
ORDER BY tr.D_FROM DESC
|
||||
"""
|
||||
cursor.execute(query, (card_id,))
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
transfer_blocks = []
|
||||
for r in results:
|
||||
d_from = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
|
||||
if r[1] and (r[1].year == 2099 or r[1].year == 1999):
|
||||
d_to = "наст. время"
|
||||
else:
|
||||
d_to = r[1].strftime('%d.%m.%Y') if hasattr(r[1], 'strftime') else str(r[1])[:10]
|
||||
|
||||
dept = str(r[2]).strip() if r[2] else "Не указан"
|
||||
appoint = str(r[3]).strip() if r[3] else "Не указана"
|
||||
|
||||
block = f"• ⏳ <b>{d_from} — {d_to}</b>\n 💼 <b>Должность:</b> {appoint}\n 📂 <b>Отдел:</b> {dept}"
|
||||
transfer_blocks.append(block)
|
||||
|
||||
return transfer_blocks
|
||||
except Exception as e:
|
||||
raise Exception(f"SQL Transfers Records Error: {e}")
|
||||
|
||||
def get_rewards_text(card_id: str) -> str:
|
||||
"""Запрашивает историю наград по CARD_ID"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT e.D, CAST(e.NAME AS VARCHAR(1000))
|
||||
FROM UOV_SELFSERVICE_PR_REW_ENC e
|
||||
WHERE e.CARD_ID = %s
|
||||
ORDER BY e.D
|
||||
""", (card_id,))
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not results:
|
||||
return "🏆 <b>Ваши награды и поощрения:</b>\n\nНичего не найдено."
|
||||
|
||||
response = "🏆 <b>Ваши награды и поощрения:</b>\n\n"
|
||||
for r in results:
|
||||
date_str = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])[:10]
|
||||
response += f"🏅 <b>{date_str}</b> — {str(r[1]).strip()}\n"
|
||||
return response
|
||||
except Exception as e:
|
||||
raise Exception(f"SQL Rewards Error: {e}")
|
||||
|
||||
def get_tabel_report_text(card_id: str, year: int, month: int) -> str:
|
||||
"""Формирует отчет по табелю из UOV_SELFSERVICE_TB_TABEL на основе CARD_ID"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return "📋 <b>Рабочий табель:</b>\n\nСотрудник не найден в базе данных."
|
||||
emp_id = row[0]
|
||||
|
||||
query = """
|
||||
SELECT e.D, e.TDAY_ID, e.H
|
||||
FROM UOV_SELFSERVICE_TB_TABEL e
|
||||
WHERE e.EMP_ID = %s AND YEAR(e.D) = %s AND MONTH(e.D) = %s
|
||||
ORDER BY e.D ASC
|
||||
"""
|
||||
cursor.execute(query, (emp_id, year, month))
|
||||
records = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
if not records:
|
||||
return f"📋 <b>Рабочий табель за {month:02d}.{year} г.</b>\n\nДанные отсутствуют."
|
||||
|
||||
tabel_map = {r[0].day if hasattr(r[0], 'day') else int(str(r[0])[-2:]): (str(r[1]).strip(), float(r[2]) if r[2] is not None else 0.0) for r in records}
|
||||
|
||||
total_days = 0
|
||||
total_hours = 0.0
|
||||
for code, hours in tabel_map.values():
|
||||
if code == 'Я':
|
||||
total_days += 1
|
||||
total_hours += hours
|
||||
|
||||
months_names = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
|
||||
|
||||
report = (
|
||||
f"📅 <b>Рабочий табель за {months_names.get(month, 'Указанный месяц')} {year} г.</b>\n\n"
|
||||
f"📊 <b>Итого за месяц:</b>\n"
|
||||
f"• Отработано дней: <b>{total_days}</b>\n"
|
||||
f"• Всего часов: <b>{total_hours:.1f} ч.</b>\n"
|
||||
f"• Переработки / Недоработки: <b>0 ч.</b>\n"
|
||||
)
|
||||
|
||||
days_in_month = calendar.monthrange(year, month)[1]
|
||||
weekday_ru = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
current_week_days = []
|
||||
week_counter = 1
|
||||
|
||||
for day in range(1, days_in_month + 1):
|
||||
cur_date = datetime(year, month, day)
|
||||
wday_idx = cur_date.weekday()
|
||||
|
||||
code, hours = tabel_map.get(day, ('В', 0.0))
|
||||
current_week_days.append((day, weekday_ru[wday_idx], code, hours))
|
||||
|
||||
if wday_idx == 6 or day == days_in_month:
|
||||
w_start = f"{current_week_days[0][0]:02d}.{month:02d}"
|
||||
w_end = f"{current_week_days[-1][0]:02d}.{month:02d}"
|
||||
|
||||
report += f"\n🔹 <b>Неделя {week_counter} ({w_start} — {w_end}):</b>\n"
|
||||
|
||||
idx = 0
|
||||
while idx < len(current_week_days):
|
||||
d_num, d_name, c_code, c_hours = current_week_days[idx]
|
||||
|
||||
if c_code == 'Я' and c_hours == 8.2:
|
||||
run_start = d_name
|
||||
run_end = d_name
|
||||
count = 1
|
||||
while idx + 1 < len(current_week_days) and current_week_days[idx+1][2] == 'Я' and current_week_days[idx+1][3] == 8.2:
|
||||
idx += 1
|
||||
run_end = current_week_days[idx][1]
|
||||
count += 1
|
||||
range_str = f"{run_start} - {run_end}" if count > 1 else f"{run_start}"
|
||||
report += f"• {range_str}: 🛠 <b>Явка</b> ({count} дн. по 8.2 ч.)\n"
|
||||
|
||||
elif c_code == 'В' and c_hours == 0.0:
|
||||
run_start = d_name
|
||||
run_end = d_name
|
||||
count = 1
|
||||
while idx + 1 < len(current_week_days) and current_week_days[idx+1][2] == 'В' and current_week_days[idx+1][3] == 0.0:
|
||||
idx += 1
|
||||
run_end = current_week_days[idx][1]
|
||||
count += 1
|
||||
range_str = f"{run_start} - {run_end}" if count > 1 else f"{run_start}"
|
||||
report += f"• {range_str}: 🏖 <b>Выходные</b> (0 ч.)\n"
|
||||
|
||||
else:
|
||||
emoji = "🛠" if c_code == 'Я' else "⏱" if c_code in ['Б', 'БЛ'] else "🏖"
|
||||
label = "Явка" if c_code == 'Я' else "Больничный" if c_code in ['Б', 'БЛ'] else "Отпуск" if c_code == 'ОТ' else "Выходной"
|
||||
report += f"• {d_name}: {emoji} <b>{label}</b> ({c_hours} ч.)\n"
|
||||
|
||||
idx += 1
|
||||
|
||||
current_week_days = []
|
||||
week_counter += 1
|
||||
|
||||
return report
|
||||
except Exception as e:
|
||||
raise Exception(f"SQL Tabel Parser Error: {e}")
|
||||
@@ -0,0 +1 @@
|
||||
[2026-07-08 02:23:21] User: ds.krivochenko@tcs.sibcem.ru | Action: Инициализация 2FA расчетного листа | Error: Не удалось извлечь номер телефона: Не удалось извлечь номер телефона из БД BossCopy: (208, b"Invalid object name 'PR_CARD'.DB-Lib error message 20018, severity 16:\nGeneral SQL Server error: Check messages from the SQL Server\n")
|
||||
@@ -0,0 +1,322 @@
|
||||
# /opt/trueconf_bot/lk/payslip_service.py
|
||||
import calendar
|
||||
import tempfile
|
||||
from itertools import zip_longest
|
||||
from weasyprint import HTML
|
||||
import config.config as config
|
||||
|
||||
# Перевод английских системных месяцев в русские наименования для бланка PDF
|
||||
ENG_TO_RU_MONTHS = {
|
||||
"January": "Январь", "February": "Февраль", "March": "Март",
|
||||
"April": "Апрель", "May": "Май", "June": "Июнь",
|
||||
"July": "Июль", "August": "Август", "September": "Сентябрь",
|
||||
"October": "Октябрь", "November": "Ноябрь", "December": "Декабрь"
|
||||
}
|
||||
|
||||
def format_currency(summa):
|
||||
try:
|
||||
if summa is None:
|
||||
return "0,00"
|
||||
f_summa = float(summa)
|
||||
return f"{f_summa:,.2f}".replace(",", " ").replace(".", ",")
|
||||
except:
|
||||
return str(summa)
|
||||
|
||||
def translate_period(period_str):
|
||||
if not period_str:
|
||||
return ""
|
||||
for eng, ru in ENG_TO_RU_MONTHS.items():
|
||||
if eng in period_str:
|
||||
return period_str.replace(eng, ru)
|
||||
return period_str
|
||||
|
||||
def get_latest_payslip_months(cursor, card_id: str) -> list:
|
||||
query = """
|
||||
SELECT DISTINCT TOP 6 (t.TYEAR * 12 + t.TMONTH) as CMONTH
|
||||
FROM UOV_SELFSERVICE_PR_EMP e
|
||||
JOIN UOV_SELFSERVICE_PR_CARD c on c.ID = e.CARD_ID
|
||||
JOIN UOV_SELFSERVICE_SL_YTAX t on t.CARD_ID = e.CARD_ID
|
||||
JOIN UOV_SELFSERVICE_PR_TRANS tr on tr.EMP_ID = e.ID
|
||||
JOIN UOV_SELFSERVICE_ST_APPOINT a on a.ID = tr.APPOINT_ID
|
||||
JOIN UOV_SELFSERVICE_HR_FIRM f on f.ID = e.FIRM_ID
|
||||
WHERE c.ID = %s
|
||||
ORDER BY CMONTH DESC
|
||||
"""
|
||||
cursor.execute(query, (card_id,))
|
||||
months = cursor.fetchall()
|
||||
if not months:
|
||||
raise Exception("Отсутствуют расчетные периоды.")
|
||||
return [m[0] for m in months]
|
||||
|
||||
def create_payslip_pdf(month_name, target_tyear,
|
||||
header_sys, header_wage, header_deduct, header_base,
|
||||
earnings_rows, deductions_rows, noncash_rows, payout_rows,
|
||||
total_earned, total_withheld, total_noncash, total_payouts,
|
||||
debt_comp_start, debt_emp_start, debt_comp_end, debt_emp_end, net_pay):
|
||||
tbody_html = ""
|
||||
tbody_html += f"""
|
||||
<tr>
|
||||
<td colspan="6" class="section-title">1. Начислено</td>
|
||||
<td colspan="4" class="section-title">2. Удержано</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
for earn, ded in zip_longest(earnings_rows, deductions_rows):
|
||||
if earn:
|
||||
earn_name = str(earn[0]).strip()
|
||||
earn_days = f"{earn[1]:g}".replace('.', ',') if earn[1] else ""
|
||||
earn_hours = f"{earn[2]:g}".replace('.', ',') if earn[2] else ""
|
||||
earn_prc = f"{earn[3]:g}" if earn[3] else ""
|
||||
earn_period = translate_period(earn[4] or "")
|
||||
earn_summa = format_currency(earn[5])
|
||||
earn_html = f'<td class="left-align">{earn_name}</td><td class="center-align">{earn_days}</td><td class="center-align">{earn_hours}</td><td class="center-align">{earn_prc}</td><td class="center-align">{earn_period}</td><td>{earn_summa}</td>'
|
||||
else:
|
||||
earn_html = '<td style="border-bottom: none; border-left: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none;"></td><td style="border-bottom: none; border-right: none;"></td>'
|
||||
|
||||
if ded:
|
||||
ded_name = str(ded[0]).strip()
|
||||
ded_prc = f"{ded[1]:.0f}" if ded[1] else ""
|
||||
ded_period = translate_period(ded[2] or "")
|
||||
ded_summa = format_currency(ded[3])
|
||||
ded_html = f'<td class="left-align">{ded_name}</td><td class="center-align">{ded_prc}</td><td class="center-align">{ded_period}</td><td>{ded_summa}</td>'
|
||||
else:
|
||||
ded_html = '<td style="border-bottom: none; border-left: none; border-right: none;" colspan="4"></td>'
|
||||
|
||||
tbody_html += f"<tr>{earn_html}{ded_html}</tr>"
|
||||
|
||||
tbody_html += f"""
|
||||
<tr class="totals">
|
||||
<td class="left-align" colspan="5">Всего начислено</td><td>{format_currency(total_earned)}</td>
|
||||
<td class="left-align" colspan="3">Всего удержано</td><td>{format_currency(total_withheld)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="6" class="section-title">3. Доходы в неденежной форме</td>
|
||||
<td colspan="4" class="section-title">4. Выплачено</td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
for nc, pay in zip_longest(noncash_rows, payout_rows):
|
||||
if nc:
|
||||
nc_name = str(nc[0]).strip()
|
||||
nc_period = translate_period(nc[1] or "")
|
||||
nc_summa = format_currency(nc[2])
|
||||
nc_html = f'<td class="left-align" colspan="4">{nc_name}</td><td class="center-align">{nc_period}</td><td>{nc_summa}</td>'
|
||||
else:
|
||||
nc_html = '<td style="border-top: none; border-bottom: none; border-left: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none;"></td><td style="border-top: none; border-bottom: none; border-right: none;"></td>'
|
||||
|
||||
if pay:
|
||||
pay_name = str(pay[0]).strip()
|
||||
pay_period = translate_period(pay[1] or "")
|
||||
pay_summa = format_currency(pay[2])
|
||||
pay_html = f'<td class="left-align" colspan="2">{pay_name}</td><td class="center-align">{pay_period}</td><td>{pay_summa}</td>'
|
||||
else:
|
||||
pay_html = '<td style="border-top: none; border-bottom: none; border-left: none; border-right: none;" colspan="4"></td>'
|
||||
|
||||
tbody_html += f"<tr>{nc_html}{pay_html}</tr>"
|
||||
|
||||
tbody_html += f"""
|
||||
<tr class="totals">
|
||||
<td class="left-align" colspan="5">Всего доходов в неденежной форме</td><td>{format_currency(total_noncash)}</td>
|
||||
<td class="left-align" colspan="3">Всего выплачено</td><td>{format_currency(total_payouts)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left-align" colspan="5">Долг за предприятием на начало месяца</td><td>{format_currency(debt_comp_start)}</td>
|
||||
<td class="left-align" colspan="3">Долг за предприятием на конец месяца</td><td>{format_currency(debt_comp_end)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="left-align" colspan="5">Долг за работником на начало месяца</td><td>{format_currency(debt_emp_start)}</td>
|
||||
<td class="left-align" colspan="3">Долг за работником на конец месяца</td><td>{format_currency(debt_emp_end)}</td>
|
||||
</tr>
|
||||
<tr class="totals">
|
||||
<td colspan="6" class="empty-cell"></td>
|
||||
<td class="left-align section-title" colspan="3"><b>5. Выплачено денежными средствами</b></td><td class="section-title"><b>{format_currency(net_pay)}</b></td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
html_template = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@page {{ size: A4 landscape; margin: 15mm; background-color: #ffffff; }}
|
||||
body {{ font-family: Arial, sans-serif; font-size: 11px; color: #333; margin: 0; padding: 0; }}
|
||||
h2 {{ text-align: center; margin-bottom: 15px; font-size: 16px; font-weight: normal; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin-bottom: 10px; }}
|
||||
th, td {{ border: 1px solid #a0a0a0; padding: 5px 8px; text-align: right; vertical-align: middle; }}
|
||||
th {{ background-color: #f2f2f2; text-align: center; font-weight: normal; color: #555; }}
|
||||
.header-table td {{ border: 1px solid #a0a0a0; text-align: left; background-color: #fcfcfc; }}
|
||||
.left-align {{ text-align: left; }}
|
||||
.center-align {{ text-align: center; }}
|
||||
.section-title {{ background-color: #fcfcfc; font-weight: bold; text-align: left; padding-top: 10px; padding-bottom: 10px; }}
|
||||
.totals {{ background-color: #fcfcfc; font-weight: bold; }}
|
||||
.empty-cell {{ border: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2><b>Расчетный лист за {month_name} {target_tyear} г.</b></h2>
|
||||
<table class="header-table" style="margin-bottom: 15px;">
|
||||
<tr><td style="width: 60%;">Система оплаты: {header_sys}</td><td style="width: 40%;">Оклад: {format_currency(header_wage)}</td></tr>
|
||||
<tr><td colspan="2">Стандартные вычеты за месяц: {format_currency(header_deduct)}</td></tr>
|
||||
<tr><td colspan="2">Облагаемая база по ставке 13%: {format_currency(header_base)}</td></tr>
|
||||
</table>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%;">Вид</th><th>Дни</th><th>Часы</th><th>%</th><th>Период</th><th>Сумма</th>
|
||||
<th style="width: 20%;">Вид</th><th>%</th><th>Период</th><th>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tbody_html}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
|
||||
HTML(string=html_template).write_pdf(temp_pdf.name)
|
||||
return temp_pdf.name
|
||||
|
||||
def build_payslip_data(cursor, card_id: str, target_cmonth: int) -> tuple:
|
||||
tyear = (target_cmonth - 1) // 12
|
||||
tmonth = (target_cmonth - 1) % 12 + 1
|
||||
_, last_day = calendar.monthrange(tyear, tmonth)
|
||||
|
||||
date_start = f"{tyear}-{tmonth:02d}-01"
|
||||
date_end = f"{tyear}-{tmonth:02d}-{last_day:02d}"
|
||||
date_params = (date_start, date_start, date_end, date_end, date_end)
|
||||
|
||||
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь",
|
||||
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
|
||||
month_name = months_ru.get(tmonth, f"{tmonth:02d}")
|
||||
|
||||
# FIRM_ID по c.ID
|
||||
q_firm = """
|
||||
select e.FIRM_ID, f.SNAME from (select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e
|
||||
join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s
|
||||
group by e.ID, e.FIRM_ID ) as e
|
||||
join UOV_SELFSERVICE_PR_TRANS t on t.EMP_ID = e.ID
|
||||
join UOV_SELFSERVICE_HR_FIRM f on f.ID = e.FIRM_ID
|
||||
where ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31'))
|
||||
group by e.FIRM_ID, f.SNAME
|
||||
"""
|
||||
cursor.execute(q_firm, (card_id, *date_params))
|
||||
firm_row = cursor.fetchone()
|
||||
if not firm_row:
|
||||
raise Exception("Не удалось определить FIRM_ID для сотрудника в указанном периоде.")
|
||||
firm_id = firm_row[0]
|
||||
|
||||
# ШАПКА по c.ID
|
||||
q_header = """
|
||||
select s.NAME as [NAME], ROUND(t.WAGE, 2), isnull(sum(yt.u21 + yt.u30), 0), ROUND(isnull(sum(yt.n200 + yt.n5100 - yt.u21 - yt.u30 - yt.u42 - yt.u99), 0), 2)
|
||||
from (select e.ID, e.FIRM_ID, e.CARD_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID
|
||||
where c.ID = %s and e.FIRM_ID=%s group by e.ID, e.FIRM_ID, e.CARD_ID ) as e
|
||||
join UOV_SELFSERVICE_pr_trans t on t.EMP_ID = e.ID join UOV_SELFSERVICE_hr_syspay s on s.ID = t.SYSPAY_ID
|
||||
join UOV_SELFSERVICE_sl_ytax yt on yt.CARD_ID = e.CARD_ID and yt.FIRM_ID = e.FIRM_ID
|
||||
where e.FIRM_ID = %s and yt.tmonth = %s and yt.tyear = %s
|
||||
and ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31'))
|
||||
and t.tp_abbr='CUR' group by s.NAME, t.WAGE, t.D_FROM order by t.D_FROM desc
|
||||
"""
|
||||
cursor.execute(q_header, (card_id, firm_id, firm_id, tmonth, tyear, *date_params))
|
||||
h_row = cursor.fetchone()
|
||||
header_sys = str(h_row[0]) if h_row else "Неизвестная система"
|
||||
header_wage = float(h_row[1]) if h_row and h_row[1] else 0.0
|
||||
header_deduct = float(h_row[2]) if h_row and h_row[2] else 0.0
|
||||
header_base = float(h_row[3]) if h_row and h_row[3] else 0.0
|
||||
|
||||
# НАЧИСЛЕНО по c.ID
|
||||
q_earn = """
|
||||
select p.NAME AS [NAME], ROUND(l.days, 1), ROUND(l.hours, 1), CAST(l.prc as INT), convert(varchar, i.D_FROM,104)+'-'+convert(varchar, i.D_TO,104), ROUND(l.summa, 2)
|
||||
from (select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
|
||||
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.ID join UOV_SELFSERVICE_sl_pay p on p.ID = l.PAY_ID join UOV_SELFSERVICE_pr_ill i on i.ID=l.ID_MAIN
|
||||
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t where ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31')) group by EMP_ID) tr on tr.EMP_ID = e.ID
|
||||
where l.t_pay = 1 and l.pay_id not in (264,301,262) and l.n_dirm = 6 and l.cmonth = %s
|
||||
UNION ALL
|
||||
select max(p.NAME) AS [NAME], sum(l.days), ROUND(sum(l.hours), 1), CAST(l.prc as INT), (DateName(month, DateAdd(month, l.TMONTH, 0) - 1) +' '+ cast(l.TYEAR as varchar)), ROUND(isnull(sum(l.summa),0), 2)
|
||||
from(select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
|
||||
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t where ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31')) group by EMP_ID) tr on tr.EMP_ID = e.ID
|
||||
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.ID join UOV_SELFSERVICE_sl_pay p on p.ID = l.PAY_ID
|
||||
where l.t_pay = 1 and l.pay_id not in (264,301,262) and l.n_dirm <> 6 and l.cmonth = %s
|
||||
group by l.emp_id,l.pay_id,l.tmonth,l.prc, case l.PAY_ID when 17 then l.id else 0 end, l.TYEAR order by 6 DESC
|
||||
"""
|
||||
cursor.execute(q_earn, (card_id, firm_id, *date_params, target_cmonth, card_id, firm_id, *date_params, target_cmonth))
|
||||
earnings_rows = cursor.fetchall()
|
||||
|
||||
# УДЕРЖАНО по c.ID
|
||||
q_deduct = """
|
||||
select max(p.NAME) AS [NAME], CAST(l.prc as INT), ISNULL(DATENAME(month, DATEADD(month, l.tmonth, 0) - 1) + ' ' + CAST(NULLIF(l.TYEAR, 0) AS varchar), ''), ROUND(isnull(sum(l.summa),0), 2)
|
||||
from (select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
|
||||
JOIN UOV_SELFSERVICE_sl_lic l ON l.EMP_ID = e.ID JOIN UOV_SELFSERVICE_sl_pay p ON p.ID = l.PAY_ID
|
||||
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t where ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31')) group by EMP_ID) tr on tr.EMP_ID = e.ID
|
||||
where l.t_pay=2 and l.pay_id not in (301,490) and l.pay_id not in (305, 308, 316, 328, 360, 364) and l.cmonth = %s
|
||||
group by l.emp_id,l.pay_id,l.tmonth,l.prc, l.id, l.TYEAR order by 4 DESC
|
||||
"""
|
||||
cursor.execute(q_deduct, (card_id, firm_id, *date_params, target_cmonth))
|
||||
deductions_rows = cursor.fetchall()
|
||||
|
||||
# ДОЛГИ по c.ID
|
||||
q_debts = """
|
||||
select l.pay_id, ROUND(isnull(sum(l.summa),0), 2) from (select e.ID, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
|
||||
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.id join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans t where ((t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO >= %s) or (t.D_FROM <= %s and t.D_TO = '1999-12-31')) group by EMP_ID) tr on tr.EMP_ID = e.ID
|
||||
where l.cmonth = %s and l.pay_id in (264, 301, 490) group by l.pay_id
|
||||
"""
|
||||
cursor.execute(q_debts, (card_id, firm_id, *date_params, target_cmonth))
|
||||
debt_rows = cursor.fetchall()
|
||||
debt_comp_start, debt_emp_start, debt_emp_end = 0.0, 0.0, 0.0
|
||||
for r in debt_rows:
|
||||
if r[0] == 264: debt_comp_start = float(r[1])
|
||||
elif r[0] == 301: debt_emp_start = float(r[1])
|
||||
elif r[0] == 490: debt_emp_end = float(r[1])
|
||||
|
||||
# НЕ ДЕНЕЖНЫЕ по c.ID
|
||||
q_noncash = """
|
||||
select t.NAME AS [NAME], (DateName(month, CAST(%s as date)) +' '+ DateName(YEAR, CAST(%s as date))), ROUND(isnull(p.summa, 0), 2)
|
||||
from (select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
|
||||
join UOV_SELFSERVICE_sl_profit p on p.EMP_ID = e.ID join UOV_SELFSERVICE_sl_tprofit t on t.ID = p.TPROFIT_ID
|
||||
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans tr where ((tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO = '1999-12-31')) group by EMP_ID) tr2 on tr2.EMP_ID = e.ID
|
||||
where p.cmonth = %s and t.id != 9999
|
||||
"""
|
||||
cursor.execute(q_noncash, (date_start, date_start, card_id, firm_id, *date_params, target_cmonth))
|
||||
noncash_rows = cursor.fetchall()
|
||||
|
||||
# ВЫПЛАЧЕНО по c.ID
|
||||
q_payout = """
|
||||
select p.NAME AS [NAME], (DateName(month, DateAdd(month, l.TMONTH, 0) - 1) +' '+ cast(l.TYEAR as varchar)), ROUND(sum(isnull(l.summa, 0)), 2)
|
||||
from (select e.id, e.FIRM_ID from UOV_SELFSERVICE_PR_EMP e join UOV_SELFSERVICE_PR_CARD c on c.ID=e.CARD_ID where c.ID = %s and e.FIRM_ID=%s group by e.ID, FIRM_ID ) as e
|
||||
join UOV_SELFSERVICE_sl_lic l on l.EMP_ID = e.ID join UOV_SELFSERVICE_sl_pay p on p.ID =l.PAY_ID
|
||||
join (select MAX(id) as id, EMP_ID from UOV_SELFSERVICE_pr_trans tr where ((tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO >= %s) or (tr.D_FROM <= %s and tr.D_TO = '1999-12-31')) group by EMP_ID) tr2 on tr2.EMP_ID = e.ID
|
||||
where l.t_pay = 2 and l.cmonth = %s and l.pay_id in (305, 308, 316, 328, 360, 364) group by p.NAME, l.TMONTH, l.TYEAR
|
||||
"""
|
||||
cursor.execute(q_payout, (card_id, firm_id, *date_params, target_cmonth))
|
||||
payout_rows = cursor.fetchall()
|
||||
|
||||
total_earned = sum(float(r[5]) for r in earnings_rows if r[5])
|
||||
total_withheld = sum(float(r[3]) for r in deductions_rows if r[3])
|
||||
total_noncash = sum(float(r[2]) for r in noncash_rows if r[2])
|
||||
total_payouts = sum(float(r[2]) for r in payout_rows if r[2])
|
||||
|
||||
# Расчет долга по формуле СибЦема
|
||||
debt_comp_end = total_earned - total_withheld - total_payouts
|
||||
net_pay = total_earned - total_withheld
|
||||
|
||||
response = f"💰 <b>Расчетный листок за {month_name} {tyear} г.</b>\n\n<b>1. Начислено</b>\n"
|
||||
for row in earnings_rows:
|
||||
time_spent = f" ({float(row[1]):g} дн. / {float(row[2]):g} ч.)" if (row[1] or row[2]) else ""
|
||||
response += f"🔹 {str(row[0]).strip()}{time_spent}: <b>{format_currency(row[5])} руб.</b>\n"
|
||||
response += f"<b>Всего начислено: {format_currency(total_earned)} руб.</b>\n\n<b>2. Удержано</b>\n"
|
||||
for row in deductions_rows:
|
||||
prc_str = f" ({int(row[1])}%)" if row[1] else ""
|
||||
response += f"🔹 {str(row[0]).strip()}{prc_str}: <b>{format_currency(row[3])} руб.</b>\n"
|
||||
response += f"<b>Всего удержано: {format_currency(total_withheld)} руб.</b>\n\n<b>3. Доходы в неденежной форме:</b> {format_currency(total_noncash)} руб.\n\n<b>4. Выплачено</b>\n"
|
||||
for row in payout_rows:
|
||||
response += f"🔹 {str(row[0]).strip()}: <b>{format_currency(row[2])} руб.</b>\n"
|
||||
response += f"<b>Всего выплачено: {format_currency(total_payouts)} руб.</b>\n\n<i>Долг за предприятием на конец месяца: {format_currency(debt_comp_end)} руб.</i>\n<i>Долг за работником на конец месяца: {format_currency(debt_emp_end)} руб.</i>\n\n<b>5. Выплачено денежными средствами: {format_currency(net_pay)} руб.</b>"
|
||||
|
||||
pdf_path = create_payslip_pdf(
|
||||
month_name, tyear, header_sys, header_wage, header_deduct, header_base,
|
||||
earnings_rows, deductions_rows, noncash_rows, payout_rows,
|
||||
total_earned, total_withheld, total_noncash, total_payouts,
|
||||
debt_comp_start, debt_emp_start, debt_comp_end, debt_emp_end, net_pay
|
||||
)
|
||||
return response, pdf_path
|
||||
@@ -0,0 +1,213 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import pymssql
|
||||
from ldap3 import Server, Connection, ALL
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
|
||||
# =========================================================
|
||||
# НАСТРОЙКИ
|
||||
# =========================================================
|
||||
|
||||
TEST_USER = "man.bogov@tcs.sibcem.ru"
|
||||
|
||||
AD_SERVER = "ldap://172.16.20.20"
|
||||
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
|
||||
|
||||
AD_USER = "sdesk-mail"
|
||||
AD_PASSWORD = "fne?e!q.m8phcrGVAcqr"
|
||||
|
||||
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru"
|
||||
SQL_DB_NAME = "BossCopy"
|
||||
|
||||
DB_USER = "SIBCEM\bot_tcs"
|
||||
DB_PASSWORD = "Bav:fX#UwH8%atv4"
|
||||
|
||||
|
||||
# =========================================================
|
||||
# AD
|
||||
# =========================================================
|
||||
|
||||
def get_phone_from_ad(user_id: str) -> str:
|
||||
|
||||
server = Server(AD_SERVER, get_info=ALL)
|
||||
|
||||
conn = Connection(
|
||||
server,
|
||||
user=AD_USER,
|
||||
password=AD_PASSWORD,
|
||||
auto_bind=True
|
||||
)
|
||||
|
||||
safe_user_id = escape_filter_chars(user_id)
|
||||
|
||||
short_username = user_id.split("@")[0]
|
||||
|
||||
search_filter = (
|
||||
f"(&(objectClass=user)"
|
||||
f"(|(mail={safe_user_id})"
|
||||
f"(userPrincipalName={safe_user_id})"
|
||||
f"(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})"
|
||||
f"(sAMAccountName={escape_filter_chars(short_username)})))"
|
||||
)
|
||||
|
||||
conn.search(
|
||||
AD_BASE,
|
||||
search_filter,
|
||||
attributes=['mobile', 'telephoneNumber']
|
||||
)
|
||||
|
||||
if not conn.entries:
|
||||
raise Exception("Пользователь не найден в AD")
|
||||
|
||||
entry = conn.entries[0]
|
||||
|
||||
raw_phone = (
|
||||
str(entry.mobile.value)
|
||||
if 'mobile' in entry and entry.mobile.value
|
||||
else str(entry.telephoneNumber.value)
|
||||
if 'telephoneNumber' in entry and entry.telephoneNumber.value
|
||||
else None
|
||||
)
|
||||
|
||||
if not raw_phone:
|
||||
raise Exception("Телефон не найден")
|
||||
|
||||
clean_phone = re.sub(r'\D', '', raw_phone)
|
||||
|
||||
if clean_phone.startswith("8") and len(clean_phone) == 11:
|
||||
return "7" + clean_phone[1:]
|
||||
|
||||
if len(clean_phone) == 10:
|
||||
return "7" + clean_phone
|
||||
|
||||
return clean_phone
|
||||
|
||||
|
||||
# =========================================================
|
||||
# SQL
|
||||
# =========================================================
|
||||
|
||||
def get_db_connection():
|
||||
|
||||
return pymssql.connect(
|
||||
server=SQL_SERVER,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD,
|
||||
database=SQL_DB_NAME,
|
||||
charset='cp1251'
|
||||
)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# MAIN
|
||||
# =========================================================
|
||||
|
||||
def main():
|
||||
|
||||
print("=" * 80)
|
||||
print("ТЕСТ РАСЧЕТНЫХ ЛИСТКОВ")
|
||||
print("=" * 80)
|
||||
|
||||
date = datetime.now().date()
|
||||
|
||||
cmonth = date.year * 12 + date.month
|
||||
|
||||
print(f"Дата: {date}")
|
||||
print(f"CMONTH: {cmonth}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
|
||||
print("\n[1] Получение телефона из AD")
|
||||
|
||||
phone_full = get_phone_from_ad(TEST_USER)
|
||||
|
||||
print(f"Телефон из AD: {phone_full}")
|
||||
|
||||
phone = phone_full[1:].strip()
|
||||
|
||||
print(f"Телефон для SQL: {phone}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
|
||||
print("\n[2] Подключение к SQL")
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# -----------------------------------------------------
|
||||
|
||||
print("\n[3] Поиск PR_CARD")
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
ID,
|
||||
PHONE
|
||||
FROM UOV_SELFSERVICE_PR_CARD
|
||||
WHERE PHONE = %s
|
||||
"""
|
||||
|
||||
print(query)
|
||||
|
||||
cursor.execute(query, (phone,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
print(f"\nНайдено записей: {len(rows)}")
|
||||
|
||||
if rows:
|
||||
|
||||
for row in rows:
|
||||
|
||||
print("-" * 60)
|
||||
print(f"ID: {row[0]}")
|
||||
print(f"PHONE: {row[1]}")
|
||||
|
||||
else:
|
||||
|
||||
print("\n❌ Запись не найдена")
|
||||
|
||||
print("\n[4] Поиск похожих телефонов")
|
||||
|
||||
query2 = """
|
||||
SELECT TOP 20
|
||||
ID,
|
||||
PHONE
|
||||
FROM UOV_SELFSERVICE_PR_CARD
|
||||
WHERE
|
||||
PHONE LIKE %s
|
||||
OR RIGHT(RTRIM(PHONE), 10) = %s
|
||||
"""
|
||||
|
||||
phone_tail = phone[-10:]
|
||||
|
||||
cursor.execute(
|
||||
query2,
|
||||
(
|
||||
f"%{phone_tail}%",
|
||||
phone_tail
|
||||
)
|
||||
)
|
||||
|
||||
rows2 = cursor.fetchall()
|
||||
|
||||
print(f"\nПохожих записей: {len(rows2)}")
|
||||
|
||||
for row in rows2:
|
||||
|
||||
print("-" * 60)
|
||||
print(f"ID: {row[0]}")
|
||||
print(f"PHONE: {row[1]}")
|
||||
|
||||
conn.close()
|
||||
|
||||
print("\n")
|
||||
print("=" * 80)
|
||||
print("ГОТОВО")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
# /opt/trueconf_bot/test_secure_connect.py
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pymssql
|
||||
|
||||
# Добавляем путь к проекту для корректных импортов конфигурации
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
print("🔄 Загружаем конфигурацию и выгружаем секреты из Passwork...")
|
||||
import config.config as config
|
||||
except ImportError as e:
|
||||
print(f"❌ Ошибка импорта config.py: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
# Технические параметры целевого сервера
|
||||
SERVER = '172.16.20.203'
|
||||
DATABASE = 'iskmdax_model'
|
||||
|
||||
# Извлекаем данные из автоматически загруженного пула
|
||||
USER = getattr(config, 'DB_USER', None)
|
||||
PASSWORD = getattr(config, 'DB_PASSWORD', None)
|
||||
|
||||
if not USER or not PASSWORD:
|
||||
print("❌ Ошибка: Учетные данные для подключения к БД не найдены в Passwork!")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"🔌 Устанавливаем ЗАЩИЩЕННОЕ соединение (encryption='require')...")
|
||||
print(f" Сервер: {SERVER}")
|
||||
print(f" База: {DATABASE}")
|
||||
print(f" Логин: [ДАННЫЕ СКРЫТЫ]")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
conn = pymssql.connect(
|
||||
server=SERVER,
|
||||
user=USER,
|
||||
password=PASSWORD,
|
||||
database=DATABASE,
|
||||
charset='cp1251',
|
||||
encryption='require' # Строгое требование SSL/TLS
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
print("✅ Защищенное TLS/SSL соединение с сервером успешно установлено!")
|
||||
|
||||
# Проверочный SQL-запрос
|
||||
query = "SELECT TOP(5) * FROM dbo.Layer;"
|
||||
print(f"🏃 Выполняем тестовый запрос: {query}\n")
|
||||
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if rows:
|
||||
print(f"🎯 Успешно получено строк: {len(rows)}")
|
||||
for idx, row in enumerate(rows, 1):
|
||||
# Печатаем содержимое строки как есть
|
||||
print(f" 🔹 Строка #{idx}: {row}")
|
||||
else:
|
||||
print("📭 Запрос выполнен успешно, но целевая таблица оказалась пустой.")
|
||||
|
||||
conn.close()
|
||||
print("\n🏁 Тест защищенного соединения завершен успешно!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Ошибка подключения или выполнения запроса!")
|
||||
error_msg = str(e)
|
||||
if "Login failed" in error_msg or "18456" in error_msg:
|
||||
print("Детали: Ошибка аутентификации (проверьте корректность учетных данных в Passwork).")
|
||||
else:
|
||||
print(f"Детали: {error_msg}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
Timestamp;User_ID;Main_Menu;Sub_Menu
|
||||
2026-07-02 18:59:49;ds.krivochenko@tcs.sibcem.ru;Личный кабинет;Расчётный лист
|
||||
2026-07-02 18:59:53;ds.krivochenko@tcs.sibcem.ru;Личный кабинет;Запрос кода 2FA для расчетного листа (в MAX)
|
||||
2026-07-02 18:59:58;ds.krivochenko@tcs.sibcem.ru;Личный кабинет;Успешная проверка 2FA расчетного листа
|
||||
2026-07-02 19:00:15;ds.krivochenko@tcs.sibcem.ru;Speech-to-Text;Вход
|
||||
2026-07-02 19:00:48;ds.krivochenko@tcs.sibcem.ru;Speech-to-Text;Отправка встречи на расшифровку (.mp4)
|
||||
2026-07-02 19:01:06;ds.krivochenko@tcs.sibcem.ru;Speech-to-Text;Выход в главное меню
|
||||
2026-07-02 19:01:10;ds.krivochenko@tcs.sibcem.ru;Service Desk;Вход
|
||||
2026-07-02 19:01:35;ds.krivochenko@tcs.sibcem.ru;Service Desk;Создание заявки #23108
|
||||
2026-07-03 14:55:32;ds.krivochenko@tcs.sibcem.ru;Service Desk;Вход
|
||||
2026-07-03 15:00:29;ds.krivochenko@tcs.sibcem.ru;Service Desk;Вход
|
||||
2026-07-03 15:00:39;ds.krivochenko@tcs.sibcem.ru;Service Desk;Создание заявки #23133
|
||||
2026-07-03 15:07:19;ds.krivochenko@tcs.sibcem.ru;Service Desk;Вход
|
||||
2026-07-03 15:07:30;ds.krivochenko@tcs.sibcem.ru;Service Desk;Создание заявки #23134
|
||||
2026-07-08 02:23:18;ds.krivochenko@tcs.sibcem.ru;Личный кабинет;Расчётный лист
|
||||
2026-07-08 02:35:13;ds.krivochenko@tcs.sibcem.ru;Личный кабинет;Расчётный лист
|
||||
2026-07-08 02:35:15;ds.krivochenko@tcs.sibcem.ru;Личный кабинет;Запрос кода 2FA для расчетного листа (в MAX)
|
||||
2026-07-08 02:35:20;ds.krivochenko@tcs.sibcem.ru;Личный кабинет;Успешная проверка 2FA расчетного листа
|
||||
|
@@ -0,0 +1,181 @@
|
||||
import sys
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# --- НАСТРОЙКА ЛОГИРОВАНИЯ ---
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("bot_log.txt", encoding="utf-8"),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logging.getLogger('trueconf.client.chatbot').propagate = False
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Добавляем путь к проекту в sys.path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from trueconf import Bot, Dispatcher, Router, Message
|
||||
from config.config import *
|
||||
from utils.states import set_state, clear_state, get_state
|
||||
from utils import states
|
||||
|
||||
# 🎯 ИСПРАВЛЕНО: Импортируем оба варианта меню (короткий и приветственный)
|
||||
from utils.menu import MENU_TEXT, WELCOME_TEXT, MENU_MAP
|
||||
|
||||
# =========================================================
|
||||
# 🔄 МОДУЛЬ АВТОМАТИЧЕСКОГО КОНТРОЛЯ СЕССИЙ И ТАЙМАУТОВ
|
||||
# =========================================================
|
||||
USER_LAST_ACTIVITY = {}
|
||||
USER_MENU_SHOWN = {}
|
||||
DEFAULT_TIMEOUT = 1800
|
||||
|
||||
_orig_get_state = states.get_state
|
||||
|
||||
def patched_get_state(user_id):
|
||||
current_time = time.time()
|
||||
last_activity = USER_LAST_ACTIVITY.get(user_id)
|
||||
session_timeout = getattr(states.config, 'SESSION_TIMEOUT', DEFAULT_TIMEOUT)
|
||||
if last_activity and (current_time - last_activity > session_timeout):
|
||||
states.clear_state(user_id)
|
||||
USER_MENU_SHOWN[user_id] = False
|
||||
logger.info(f"⏳ Сессия пользователя {user_id} сброшена по таймауту бездействия ({session_timeout} сек).")
|
||||
USER_LAST_ACTIVITY[user_id] = current_time
|
||||
return _orig_get_state(user_id)
|
||||
|
||||
states.get_state = patched_get_state
|
||||
get_state = patched_get_state
|
||||
|
||||
# =========================================================
|
||||
# 🔌 КОРПОРАТИВНЫЕ ШАБЛОНЫ ТЕКСТОВ КЛЕВЕР
|
||||
# =========================================================
|
||||
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
|
||||
from utils.stats_logger import log_menu_stats
|
||||
|
||||
# =========================================================
|
||||
# ФУНКЦИЯ МОНИТОРИНГА ОПЕРАТИВНОЙ ПАМЯТИ
|
||||
# =========================================================
|
||||
def log_ram_usage(stage_name: str):
|
||||
process = psutil.Process(os.getpid())
|
||||
ram_mb = process.memory_info().rss / 1024 / 1024
|
||||
logger.info(f"📊 [RAM MONITOR] {stage_name}: {ram_mb:.2f} MB")
|
||||
|
||||
log_ram_usage("Старт скрипта (базовый вес)")
|
||||
|
||||
from photo_bot.handlers import router as photo_router, PHOTO_MAIN_MENU_TEXT
|
||||
log_ram_usage("После импорта photo_bot")
|
||||
|
||||
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT
|
||||
log_ram_usage("После импорта service_desk")
|
||||
|
||||
from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT
|
||||
log_ram_usage("После импорта transcription_router (Виспер)")
|
||||
|
||||
from search_bot.handlers import router as search_router, SEARCH_MAIN_MENU_TEXT
|
||||
log_ram_usage("После импорта search_router")
|
||||
|
||||
from lk.handlers import router as lk_router
|
||||
log_ram_usage("После импорта lk_router (Личный кабинет)")
|
||||
|
||||
# =========================================================
|
||||
# ГЛАВНЫЙ РОУТЕР
|
||||
# =========================================================
|
||||
main_router = Router()
|
||||
|
||||
@main_router.message()
|
||||
async def main_menu(msg: Message):
|
||||
if ENABLE_WHITELIST and msg.from_user.id not in ALLOWED_USERS:
|
||||
logger.info(f"Доступ закрыт (Whitelist ON): Игнорирование пользователя {msg.from_user.id}")
|
||||
return
|
||||
user_id = msg.from_user.id
|
||||
if getattr(msg, "handled", False):
|
||||
return
|
||||
if get_state(user_id) is not None:
|
||||
return
|
||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||
if is_attachment:
|
||||
await msg.answer("⚠️ <b>Сначала выберите режим работы из меню.</b>", parse_mode="html")
|
||||
await msg.answer(WELCOME_TEXT, parse_mode="html")
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
return
|
||||
menu_shown = USER_MENU_SHOWN.get(user_id, False)
|
||||
if msg.text:
|
||||
cmd = msg.text.strip().lower()
|
||||
action = MENU_MAP.get(cmd)
|
||||
if action == "SD":
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
set_state(user_id, "SD_MODE")
|
||||
log_menu_stats(user_id, "Service Desk", "Вход")
|
||||
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
elif action == "PHOTO":
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
set_state(user_id, "PHOTO_MODE")
|
||||
log_menu_stats(user_id, "Photo Bot", "Вход")
|
||||
await msg.answer(PHOTO_MAIN_MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
elif action == "TRANSCRIPTION":
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
set_state(user_id, "TRANSCRIPTION_MODE")
|
||||
log_menu_stats(user_id, "Speech-to-Text", "Вход")
|
||||
await msg.answer(TRANSCRIPTION_MAIN_MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
elif action == "SEARCH":
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
set_state(user_id, "SEARCH_MODE")
|
||||
log_menu_stats(user_id, "Search Bot", "Вход")
|
||||
await msg.answer(SEARCH_MAIN_MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
elif action == "LK":
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
set_state(user_id, "LK_MODE")
|
||||
log_menu_stats(user_id, "Личный кабинет", "Вход")
|
||||
from lk.handlers import send_dynamic_lk_main_menu
|
||||
await send_dynamic_lk_main_menu(msg, user_id)
|
||||
return
|
||||
else:
|
||||
if not menu_shown:
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
await msg.answer(WELCOME_TEXT, parse_mode="html")
|
||||
return
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
USER_MENU_SHOWN[user_id] = True
|
||||
await msg.answer(WELCOME_TEXT, parse_mode="html")
|
||||
|
||||
async def main():
|
||||
logger.info("Инициализация Dispatcher и подключение роутеров...")
|
||||
dp = Dispatcher()
|
||||
dp.include_router(photo_router)
|
||||
dp.include_router(sd_router)
|
||||
dp.include_router(transcription_router)
|
||||
dp.include_router(search_router)
|
||||
dp.include_router(lk_router)
|
||||
dp.include_router(main_router)
|
||||
bot = Bot.from_credentials(server=TC_SERVER, username=TC_LOGIN, password=TC_PASSWORD, dispatcher=dp, verify_ssl=False)
|
||||
states.tc_bot = bot
|
||||
logger.info("Бот запущен и готов к работе!")
|
||||
target_chat = globals().get('ADMIN_CHAT_ID', "533a9784afdfe80c8d80b971a732cd2520a71dc6")
|
||||
async def send_startup_notification(bot_instance, chat_id):
|
||||
await asyncio.sleep(10)
|
||||
try:
|
||||
await bot_instance.send_message(chat_id, "✅ <b>Я - online!</b> Запуск прошел успешно.", parse_mode="html")
|
||||
logger.info(f"Стартовое сообщение успешно отправлено в чат {chat_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить стартовое сообщение администратору: {e}")
|
||||
asyncio.create_task(send_startup_notification(bot, target_chat))
|
||||
logger.info("Подключение к серверу TrueConf...")
|
||||
await bot.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main_router.message()
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Бот остановлен вручную.")
|
||||
@@ -0,0 +1,49 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from ldap3 import Server, Connection, ALL, MODIFY_REPLACE
|
||||
|
||||
# Подтягиваем конфиг
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def upload_photo_to_ad(target_user: str, photo_path: str) -> dict:
|
||||
"""
|
||||
Читает готовое фото с диска и записывает его в атрибут thumbnailPhoto в AD.
|
||||
"""
|
||||
try:
|
||||
# Читаем сырые байты картинки
|
||||
with open(photo_path, "rb") as f:
|
||||
photo_bytes = f.read()
|
||||
|
||||
# Формируем фильтр для поиска
|
||||
if '@' in target_user:
|
||||
ldap_filter = f"(mail={target_user})"
|
||||
else:
|
||||
ldap_filter = f"(sAMAccountName={target_user})"
|
||||
|
||||
# Подключаемся к AD
|
||||
server = Server(AD_SERVER, get_info=ALL)
|
||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
||||
|
||||
# Находим пользователя в дереве (нам нужен его точный путь - DN)
|
||||
conn.search(search_base=AD_BASE, search_filter=ldap_filter, attributes=["cn"])
|
||||
|
||||
if not conn.entries:
|
||||
return {"success": False, "error": f"Пользователь {target_user} не найден в AD."}
|
||||
|
||||
user_dn = conn.entries[0].entry_dn
|
||||
|
||||
# 🪄 МАГИЯ: Перезаписываем атрибут thumbnailPhoto нашими байтами
|
||||
conn.modify(user_dn, {'thumbnailPhoto': [(MODIFY_REPLACE, [photo_bytes])]})
|
||||
|
||||
if conn.result['result'] == 0:
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"success": False, "error": str(conn.result['description'])}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки в AD для {target_user}: {e}")
|
||||
return {"success": False, "error": "Сбой при связи с сервером Active Directory."}
|
||||
@@ -0,0 +1,181 @@
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# Добавляем путь к корню проекта
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from trueconf import Bot, Dispatcher, Router, Message
|
||||
from trueconf.types import FSInputFile
|
||||
|
||||
# Наши обработчики
|
||||
from photo_processor import prepare_ad_photo
|
||||
from ad_helper import upload_photo_to_ad
|
||||
|
||||
# Настройки
|
||||
from config.config import *
|
||||
|
||||
# --- НАСТРОЙКА ЛОГИРОВАНИЯ ---
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("photo_bot_log.txt", encoding="utf-8"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = Router()
|
||||
tc_bot = None
|
||||
|
||||
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
|
||||
# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'}
|
||||
pending_photos = {}
|
||||
|
||||
@router.message()
|
||||
async def handle_message(msg: Message):
|
||||
global tc_bot
|
||||
|
||||
# Определяем логин пользователя
|
||||
user_login = msg.from_user.id.split('@')[0] if '@' in msg.from_user.id else msg.from_user.id
|
||||
|
||||
# Проверяем, вложение ли это
|
||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 1: Бот ждет ответа, и пользователь написал текст (Да/Нет)
|
||||
# =====================================================================
|
||||
if not is_attachment and user_login in pending_photos and msg.text:
|
||||
answer = msg.text.strip().lower()
|
||||
|
||||
# Если согласен
|
||||
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
|
||||
photo_path = pending_photos[user_login]
|
||||
await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...")
|
||||
|
||||
# Запускаем скрипт загрузки в AD
|
||||
upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path)
|
||||
|
||||
if upload_result["success"]:
|
||||
await msg.answer(
|
||||
"🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n"
|
||||
"Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)."
|
||||
)
|
||||
else:
|
||||
await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}")
|
||||
|
||||
# Убираем за собой файл и стираем пользователя из памяти
|
||||
if os.path.exists(photo_path):
|
||||
os.remove(photo_path)
|
||||
del pending_photos[user_login]
|
||||
return
|
||||
|
||||
# Если отказался
|
||||
elif answer in ["нет", "no", "-", "отмена"]:
|
||||
photo_path = pending_photos[user_login]
|
||||
await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!")
|
||||
|
||||
if os.path.exists(photo_path):
|
||||
os.remove(photo_path)
|
||||
del pending_photos[user_login]
|
||||
return
|
||||
|
||||
# Если написал что-то непонятное, но мы ждем ответа
|
||||
else:
|
||||
await msg.answer("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.")
|
||||
return
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 2: Пользователь прислал новую фотографию
|
||||
# =====================================================================
|
||||
if is_attachment:
|
||||
file_id = getattr(msg.content, "file_id", None)
|
||||
|
||||
if not file_id:
|
||||
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
|
||||
return
|
||||
|
||||
ready_path = f"/tmp/ready_{user_login}.jpg"
|
||||
raw_path = None
|
||||
|
||||
await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...")
|
||||
|
||||
try:
|
||||
downloaded_file = await tc_bot.download_file_by_id(file_id)
|
||||
raw_path = downloaded_file
|
||||
|
||||
# Обрабатываем фото (с новым разрешением 240x240)
|
||||
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 240)
|
||||
|
||||
if result["success"]:
|
||||
photo_document = FSInputFile(ready_path, filename=f"AD_{user_login}.jpg")
|
||||
|
||||
# Отправляем фото и СРАЗУ задаем вопрос
|
||||
await msg.answer_photo(
|
||||
photo_document,
|
||||
photo_document,
|
||||
caption=(
|
||||
f"✅ **Фото готово!**\n\n"
|
||||
f"📐 Размер: 240x240 px\n"
|
||||
f"⚖️ Вес: {result['size_kb']} KB\n\n"
|
||||
f"❓ **Вам нравится результат? Загружаем в Active Directory?**\n"
|
||||
f"Напишите мне в ответ **Да** или **Нет**."
|
||||
)
|
||||
)
|
||||
|
||||
# 🧠 ЗАПОМИНАЕМ, что этот пользователь теперь в режиме ожидания ответа
|
||||
pending_photos[user_login] = ready_path
|
||||
logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.")
|
||||
|
||||
else:
|
||||
await msg.answer(f"❌ Ошибка обработки: {result.get('error')}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
|
||||
await msg.answer("❌ Произошла системная ошибка при обработке.")
|
||||
|
||||
finally:
|
||||
# ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path).
|
||||
# Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"!
|
||||
if raw_path and os.path.exists(raw_path):
|
||||
try: os.remove(raw_path)
|
||||
except: pass
|
||||
|
||||
return
|
||||
|
||||
# =====================================================================
|
||||
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
|
||||
# =====================================================================
|
||||
await msg.answer(
|
||||
"👋 Привет! Я корпоративный фотограф для Active Directory.\n\n"
|
||||
"📸 **Просто отправь мне фото**, и я автоматически:\n"
|
||||
"1. Найду лицо на снимке и сделаю правильный кадр.\n"
|
||||
"2. Улучшу резкость и сожму до стандарта 240x240 px.\n"
|
||||
"3. Предложу загрузить результат прямо в твой профиль!"
|
||||
)
|
||||
|
||||
async def main():
|
||||
global tc_bot
|
||||
|
||||
logger.info(f"Запуск фото-бота на сервере {TC_SERVER}...")
|
||||
|
||||
dp = Dispatcher()
|
||||
dp.include_router(router)
|
||||
|
||||
tc_bot = Bot.from_credentials(
|
||||
server=TC_SERVER,
|
||||
username=TC_LOGIN,
|
||||
password=TC_PASSWORD,
|
||||
dispatcher=dp,
|
||||
verify_ssl=False
|
||||
)
|
||||
|
||||
await tc_bot.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Бот остановлен пользователем.")
|
||||
@@ -0,0 +1,83 @@
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
from ldap3 import Server, Connection, ALL
|
||||
from PIL import Image
|
||||
|
||||
# Подтягиваем ваши настройки из конфига
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
def check_user_photo(target_user: str):
|
||||
print(f"🔍 Ищем пользователя: {target_user}...")
|
||||
|
||||
# Если передали email, ищем по почте. Если просто логин — по sAMAccountName
|
||||
if '@' in target_user:
|
||||
ldap_filter = f"(mail={target_user})"
|
||||
else:
|
||||
ldap_filter = f"(sAMAccountName={target_user})"
|
||||
|
||||
try:
|
||||
# Подключаемся к AD
|
||||
server = Server(AD_SERVER, get_info=ALL)
|
||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
||||
|
||||
# Ищем пользователя и просим вернуть атрибуты: имя (cn) и фото (thumbnailPhoto)
|
||||
conn.search(
|
||||
search_base=AD_BASE,
|
||||
search_filter=ldap_filter,
|
||||
attributes=["cn", "thumbnailPhoto"]
|
||||
)
|
||||
|
||||
if not conn.entries:
|
||||
print("❌ Пользователь с такими данными не найден в AD.")
|
||||
return
|
||||
|
||||
user = conn.entries[0]
|
||||
user_name = user.cn.value if 'cn' in user else target_user
|
||||
print(f"👤 Найден сотрудник: {user_name}")
|
||||
|
||||
# Проверяем, есть ли вообще фото
|
||||
if 'thumbnailPhoto' not in user or not user.thumbnailPhoto.value:
|
||||
print("⚠️ У этого пользователя НЕТ фотографии в Active Directory.")
|
||||
return
|
||||
|
||||
# Достаем байты фотографии
|
||||
photo_bytes = user.thumbnailPhoto.value
|
||||
size_kb = len(photo_bytes) / 1024
|
||||
|
||||
# Читаем картинку прямо из памяти (без сохранения на диск) с помощью Pillow
|
||||
image = Image.open(io.BytesIO(photo_bytes))
|
||||
width, height = image.size
|
||||
|
||||
print("\n📊 --- ИНФОРМАЦИЯ О ФОТО В AD ---")
|
||||
print(f"📐 Разрешение : {width}x{height} пикселей")
|
||||
print(f"⚖️ Вес : {size_kb:.1f} KB")
|
||||
print(f"🗂 Формат : {image.format}")
|
||||
print("----------------------------------\n")
|
||||
|
||||
# Бонус: сохраняем фото во временную папку, чтобы вы могли на него взглянуть
|
||||
safe_name = target_user.replace('@', '_').replace('.', '_')
|
||||
save_path = f"/tmp/{safe_name}_ad.jpg"
|
||||
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(photo_bytes)
|
||||
|
||||
print(f"💾 Оригинал из AD сохранен сюда: {save_path}")
|
||||
print("Вы можете скачать его через WinSCP или открыть в TrueConf, чтобы оценить качество глазами.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при подключении к AD: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Замените на нужный email или логин (например, 'n.shmargalova' или 'n.shmargalova@sibcem.ru')
|
||||
target = "n.shmargalova@sibcem.ru"
|
||||
|
||||
# Если скрипт запущен с аргументом (python check_ad_photo.py логин), берем аргумент
|
||||
if len(sys.argv) > 1:
|
||||
target = sys.argv[1]
|
||||
|
||||
check_user_photo(target)
|
||||
@@ -0,0 +1,242 @@
|
||||
# /opt/trueconf_bot/photo_bot/handlers.py
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import httpx
|
||||
import smtplib
|
||||
import time
|
||||
from datetime import datetime
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from PIL import Image
|
||||
from trueconf import Router, Message
|
||||
from trueconf.types import FSInputFile
|
||||
|
||||
import config.config as config
|
||||
from utils import states
|
||||
from utils.menu import MENU_TEXT
|
||||
from photo_bot.photo_processor import prepare_ad_photo
|
||||
from utils.texts import (
|
||||
EMOJI_DIGITS,
|
||||
PHOTO_MAIN_MENU_TEXT,
|
||||
PHOTO_UNKNOWN_CMD_TEXT,
|
||||
photo_error_handling,
|
||||
PHOTO_UPLOADING_TEXT,
|
||||
photo_success_sent,
|
||||
photo_cancel_text,
|
||||
PHOTO_PROCESSING_TEXT,
|
||||
photo_preview_ready,
|
||||
photo_error_processing,
|
||||
)
|
||||
|
||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||
from utils.stats_logger import log_menu_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
# Словарь для временного хранения путей к фото пользователей
|
||||
pending_photos = {}
|
||||
|
||||
# =========================================================
|
||||
# СИСТЕМА ОБРАБОТКИ ОШИБОК И ЛОГИРОВАНИЯ
|
||||
# =========================================================
|
||||
EMAIL_COOLDOWN_SEC = 10.0
|
||||
last_email_time = 0.0
|
||||
|
||||
def log_and_notify_photo_error(user_id: str, action: str, error_details: str):
|
||||
"""Записывает системную ошибку в лог-файл и отправляет email-алерт администраторам"""
|
||||
global last_email_time
|
||||
|
||||
log_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, "photo_errors.log")
|
||||
|
||||
current_time = time.time()
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_entry = f"[{timestamp}] User: {user_id} | Action: {action} | Error: {error_details}\n"
|
||||
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(log_entry)
|
||||
|
||||
if current_time - last_email_time < EMAIL_COOLDOWN_SEC:
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{timestamp}] ALERT: Email suppressed due to rate limiting.\n")
|
||||
return
|
||||
|
||||
last_email_time = current_time
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = str(getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com'))
|
||||
|
||||
to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', 'admin@example.com')
|
||||
msg['To'] = ", ".join(to_emails) if isinstance(to_emails, list) else str(to_emails)
|
||||
|
||||
msg['Subject'] = f"TrueConf Bot Error: Фото Профиль - {action}"
|
||||
|
||||
body = (
|
||||
f"Обнаружена системная ошибка в модуле загрузки фото профиля.\n\n"
|
||||
f"Время: {timestamp}\n"
|
||||
f"Пользователь (ID): {user_id}\n"
|
||||
f"Действие: {action}\n\n"
|
||||
f"Детали ошибки:\n{error_details}\n\n"
|
||||
f"(Уведомления об ошибках ограничены: не чаще 1 раза в {EMAIL_COOLDOWN_SEC} сек.)"
|
||||
)
|
||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||
|
||||
smtp_server = getattr(config, 'SMTP_SERVER', 'localhost')
|
||||
smtp_port = int(getattr(config, 'SMTP_PORT', 25))
|
||||
|
||||
with smtplib.SMTP(smtp_server, smtp_port) as server:
|
||||
smtp_user = getattr(config, 'SMTP_USER', None)
|
||||
smtp_pass = getattr(config, 'SMTP_PASSWORD', None)
|
||||
if smtp_user and smtp_pass:
|
||||
server.login(smtp_user, smtp_pass)
|
||||
server.send_message(msg)
|
||||
except Exception as e:
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{timestamp}] СБОЙ ОТПРАВКИ EMAIL: {e}\n")
|
||||
|
||||
|
||||
async def handle_photo_error(msg: Message, user_id: str, action: str, error_details: str):
|
||||
"""Вызывает системное логирование и выдает заглушку пользователю"""
|
||||
await asyncio.to_thread(log_and_notify_photo_error, user_id, action, error_details)
|
||||
|
||||
await msg.answer(photo_error_handling(), parse_mode="html")
|
||||
|
||||
|
||||
# =========================================================
|
||||
# ОСНОВНОЙ ОБРАБОТЧИК
|
||||
# =========================================================
|
||||
@router.message()
|
||||
async def photo_module_handler(msg: Message):
|
||||
user_id = msg.from_user.id
|
||||
|
||||
if states.get_state(user_id) != "PHOTO_MODE":
|
||||
return
|
||||
|
||||
# Помечаем сообщение как обработанное
|
||||
msg.handled = True
|
||||
|
||||
user_login = user_id.split('@')[0] if '@' in user_id else user_id
|
||||
msg_text = getattr(msg.content, "text", "").strip() if hasattr(msg.content, "text") else ""
|
||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||
|
||||
cmd = msg_text.lower()
|
||||
|
||||
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||
if cmd == emoji_num:
|
||||
cmd = raw_num
|
||||
break
|
||||
|
||||
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню с очисткой кэша) ---
|
||||
if cmd in ["0", "9", "/start", "меню"]:
|
||||
log_menu_stats(user_id, "Photo Bot", "Выход в главное меню")
|
||||
states.clear_state(user_id)
|
||||
if user_id in pending_photos:
|
||||
if os.path.exists(pending_photos[user_id]):
|
||||
os.remove(pending_photos[user_id])
|
||||
del pending_photos[user_id]
|
||||
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
# --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 1 или 2) ---
|
||||
if not is_attachment and user_id in pending_photos:
|
||||
if cmd == "1":
|
||||
photo_path = pending_photos[user_id]
|
||||
final_filename = f"{user_login}.bmp"
|
||||
|
||||
await msg.answer(PHOTO_UPLOADING_TEXT, parse_mode="html")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
with open(photo_path, "rb") as f:
|
||||
files = {'file': (final_filename, f, 'image/bmp')}
|
||||
response = await client.post(config.TRASH_API_URL, files=files)
|
||||
|
||||
if response.status_code == 200:
|
||||
# ФИКСАЦИЯ СТАТИСТИКИ: Успешная выгрузка фото на утверждение
|
||||
log_menu_stats(user_id, "Photo Bot", "Отправка фото на утверждение")
|
||||
await msg.answer(photo_success_sent(), parse_mode="html")
|
||||
|
||||
states.clear_state(user_id)
|
||||
if os.path.exists(photo_path):
|
||||
os.remove(photo_path)
|
||||
del pending_photos[user_id]
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
else:
|
||||
error_msg = f"API вернул статус-код {response.status_code}"
|
||||
await handle_photo_error(msg, user_id, "Отправка фото по API", error_msg)
|
||||
except Exception as e:
|
||||
await handle_photo_error(msg, user_id, "Подключение к API загрузки", str(e))
|
||||
return
|
||||
|
||||
# --- ОТМЕНА ФОТО (Кнопка 2) ---
|
||||
elif cmd == "2":
|
||||
log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото")
|
||||
photo_path = pending_photos[user_id]
|
||||
if os.path.exists(photo_path):
|
||||
os.remove(photo_path)
|
||||
del pending_photos[user_id]
|
||||
await msg.answer(photo_cancel_text(), parse_mode="html")
|
||||
return
|
||||
|
||||
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
|
||||
else:
|
||||
await msg.answer(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
# --- ПРИЕМ НОВОГО ФОТО ---
|
||||
if is_attachment:
|
||||
file_id = getattr(msg.content, "file_id", None)
|
||||
ready_path = f"/tmp/{user_login}.bmp"
|
||||
raw_path = None
|
||||
|
||||
await msg.answer(PHOTO_PROCESSING_TEXT, parse_mode="html")
|
||||
|
||||
try:
|
||||
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
|
||||
raw_path = downloaded_file
|
||||
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 96)
|
||||
|
||||
if result["success"]:
|
||||
# ФИКСАЦИЯ СТАТИСТИКИ: Факт успешной загрузки и обработки нового фото
|
||||
log_menu_stats(user_id, "Photo Bot", "Загрузка и конвертация нового фото")
|
||||
pending_photos[user_id] = ready_path
|
||||
preview_path = f"/tmp/prev_{user_login}.jpg"
|
||||
try:
|
||||
Image.open(ready_path).save(preview_path, format="JPEG")
|
||||
p_main = FSInputFile(preview_path, filename="preview.jpg")
|
||||
p_thumb = FSInputFile(preview_path, filename="preview_thumb.jpg")
|
||||
await msg.answer_photo(p_main, p_thumb)
|
||||
except Exception as lib_err:
|
||||
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Генерация превью", str(lib_err))
|
||||
finally:
|
||||
if os.path.exists(preview_path):
|
||||
os.remove(preview_path)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
await msg.answer(
|
||||
photo_preview_ready(),
|
||||
parse_mode="html"
|
||||
)
|
||||
else:
|
||||
error_text = result.get('error', 'Не удалось обработать фото.')
|
||||
await msg.answer(
|
||||
photo_error_processing(error_text),
|
||||
parse_mode="html"
|
||||
)
|
||||
except Exception as e:
|
||||
await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e))
|
||||
finally:
|
||||
if raw_path and os.path.exists(raw_path):
|
||||
try:
|
||||
os.remove(raw_path)
|
||||
except Exception as cleanup_err:
|
||||
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
|
||||
return
|
||||
@@ -0,0 +1,317 @@
|
||||
2026-04-23 23:39:51,315 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-23 23:39:51,930 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-23 23:39:51,969 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:39:51,970 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-23 23:39:52,055 [INFO] ✅ WebSocket connected
|
||||
2026-04-23 23:39:52,055 [INFO] 🚀 Starting authorization
|
||||
2026-04-23 23:39:52,055 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-23 23:39:52,129 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/43099233
|
||||
2026-04-23 23:40:09,861 [INFO] 📥 Incoming event: Message(timestamp=1776962409746, type=<MessageType.PLAIN_MESSAGE: 200>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=60, position='0'), content=TextContent(text='Hdd', parse_mode='html'), message_id='35d354ed-b6f2-41cc-b3a5-b7691342ccad', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:40:09,861 [INFO] [router:0x77d568978a90] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:40:09,861 [ERROR] Handler async_wrapper failed: handle_photo() missing 1 required positional argument: 'bot'
|
||||
Traceback (most recent call last):
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 133, in _run
|
||||
await handler(event, **kwargs)
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 83, in async_wrapper
|
||||
await func(evt, **filtered_kwargs)
|
||||
TypeError: handle_photo() missing 1 required positional argument: 'bot'
|
||||
2026-04-23 23:40:17,711 [INFO] 📥 Incoming event: Message(timestamp=1776962417624, type=<MessageType.PLAIN_MESSAGE: 200>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=61, position='0'), content=TextContent(text='Ddd', parse_mode='html'), message_id='a50e58bb-0924-4875-a6cc-5bcefcd34c1d', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:40:17,711 [INFO] [router:0x77d568978a90] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:40:17,712 [ERROR] Handler async_wrapper failed: handle_photo() missing 1 required positional argument: 'bot'
|
||||
Traceback (most recent call last):
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 133, in _run
|
||||
await handler(event, **kwargs)
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 83, in async_wrapper
|
||||
await func(evt, **filtered_kwargs)
|
||||
TypeError: handle_photo() missing 1 required positional argument: 'bot'
|
||||
2026-04-23 23:41:19,825 [INFO] 📥 Incoming event: Message(timestamp=1776962477100, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=62, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-41-16_7720.webp', file_size=31428, file_id='b9002e074a4774d35ecf1f8cef29b8a737c4f1d2', mimetype='image/webp', ready_state=None), message_id='74faff33-a25d-4aa6-bcaa-cfc510c4ed97', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:41:19,825 [INFO] [router:0x77d568978a90] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:41:19,825 [ERROR] Handler async_wrapper failed: handle_photo() missing 1 required positional argument: 'bot'
|
||||
Traceback (most recent call last):
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 133, in _run
|
||||
await handler(event, **kwargs)
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 83, in async_wrapper
|
||||
await func(evt, **filtered_kwargs)
|
||||
TypeError: handle_photo() missing 1 required positional argument: 'bot'
|
||||
2026-04-23 23:41:33,296 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-23 23:41:33,297 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-23 23:41:35,735 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-23 23:41:35,967 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-23 23:41:35,985 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:41:35,986 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-23 23:41:35,998 [INFO] ✅ WebSocket connected
|
||||
2026-04-23 23:41:35,998 [INFO] 🚀 Starting authorization
|
||||
2026-04-23 23:41:35,998 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-23 23:41:36,067 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/2ee10857
|
||||
2026-04-23 23:41:46,197 [INFO] 📥 Incoming event: Message(timestamp=1776962503542, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=63, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-41-43_2380.webp', file_size=33566, file_id='79e26db73915e1eddea88a79646ca9cbfa71c87c', mimetype='image/webp', ready_state=None), message_id='3246d0e7-5721-42e2-952f-d5891f56ccf2', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:41:46,197 [INFO] [router:0x7d0b32188940] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:41:46,197 [ERROR] Handler async_wrapper failed: handle_photo() missing 1 required positional argument: 'bot'
|
||||
Traceback (most recent call last):
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 133, in _run
|
||||
await handler(event, **kwargs)
|
||||
File "/home/tpxk-03/bot_env/lib/python3.10/site-packages/trueconf/dispatcher/router.py", line 83, in async_wrapper
|
||||
await func(evt, **filtered_kwargs)
|
||||
TypeError: handle_photo() missing 1 required positional argument: 'bot'
|
||||
2026-04-23 23:44:41,946 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-23 23:44:41,947 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-23 23:44:44,556 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-23 23:44:44,775 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-23 23:44:44,797 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:44:44,798 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-23 23:44:44,816 [INFO] ✅ WebSocket connected
|
||||
2026-04-23 23:44:44,817 [INFO] 🚀 Starting authorization
|
||||
2026-04-23 23:44:44,817 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-23 23:44:44,893 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/ca26821d
|
||||
2026-04-23 23:45:03,929 [INFO] 📥 Incoming event: Message(timestamp=1776962701339, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=64, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-45-01_0330.webp', file_size=34734, file_id='b7a33d9f17f2fd8c20e2f89ada14bb543ccd80a3', mimetype='image/webp', ready_state=None), message_id='a3c09710-24fc-4460-987b-7e747542fb89', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:45:03,929 [INFO] [router:0x7e0f72d68b20] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:45:03,929 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:45:04,992 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/b7a33d9f17f2fd8c20e2f89ada14bb543ccd80a3 "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:45:04,995 [ERROR] Ошибка при работе с фото для ds.krivochenko: a bytes-like object is required, not 'PosixPath'
|
||||
2026-04-23 23:45:04,995 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:46:02,036 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-23 23:46:02,037 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-23 23:47:20,892 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-23 23:47:21,125 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-23 23:47:21,142 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:47:21,143 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-23 23:47:21,158 [INFO] ✅ WebSocket connected
|
||||
2026-04-23 23:47:21,158 [INFO] 🚀 Starting authorization
|
||||
2026-04-23 23:47:21,158 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-23 23:47:21,233 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/c35e07f3
|
||||
2026-04-23 23:47:33,755 [INFO] 📥 Incoming event: Message(timestamp=1776962851136, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=67, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-47-30_8460.webp', file_size=37408, file_id='5d8e96f6752a2affd774703ef203b78ff982a9fd', mimetype='image/webp', ready_state=None), message_id='784b7f5a-5d32-4eb6-8656-b43016d5c5a7', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:47:33,755 [INFO] [router:0x7fb664b70af0] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:47:33,756 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:47:34,992 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/5d8e96f6752a2affd774703ef203b78ff982a9fd "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:47:35,114 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:47:58,711 [INFO] 📥 Incoming event: Message(timestamp=1776962875889, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=70, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-47-55_6050.webp', file_size=33936, file_id='dd85f0b2bc7245d872c19dfa8dbc333772e76c6e', mimetype='image/webp', ready_state=None), message_id='4251845f-f133-4ea0-9165-d10e7ef3707e', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:47:58,711 [INFO] [router:0x7fb664b70af0] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:47:58,712 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:47:59,997 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/dd85f0b2bc7245d872c19dfa8dbc333772e76c6e "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:48:00,316 [ERROR] Ошибка при работе с фото для ds.krivochenko: Message.answer_document() got an unexpected keyword argument 'document'
|
||||
2026-04-23 23:48:00,316 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:50:34,153 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-23 23:50:34,153 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-23 23:50:37,003 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-23 23:50:37,225 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-23 23:50:37,245 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:50:37,247 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-23 23:50:37,263 [INFO] ✅ WebSocket connected
|
||||
2026-04-23 23:50:37,263 [INFO] 🚀 Starting authorization
|
||||
2026-04-23 23:50:37,263 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-23 23:50:37,338 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/df72df35
|
||||
2026-04-23 23:50:52,450 [INFO] 📥 Incoming event: Message(timestamp=1776963049877, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=73, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-50-49_5490.webp', file_size=55568, file_id='2225e5a353a97ca067863db453377cbe8163ebea', mimetype='image/webp', ready_state=None), message_id='faec2f35-2dd3-40d2-bdd1-8f7954873cf2', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:50:52,451 [INFO] [router:0x73c06af80b50] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:50:52,451 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:50:53,531 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/2225e5a353a97ca067863db453377cbe8163ebea "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:50:53,683 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:51:09,117 [INFO] 📥 Incoming event: Message(timestamp=1776963066361, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=76, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-51-06_0440.webp', file_size=62052, file_id='72dbcf140bd407daf3dc8e564658615fc9aa00dd', mimetype='image/webp', ready_state=None), message_id='81b66791-daa2-447c-ab98-187d243c86a3', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:51:09,117 [INFO] [router:0x73c06af80b50] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:51:09,117 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:51:10,538 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/72dbcf140bd407daf3dc8e564658615fc9aa00dd "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:51:10,774 [INFO] ✉️ Sending file to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:51:10,959 [INFO] Успешно обработано фото для ds.krivochenko
|
||||
2026-04-23 23:51:37,361 [INFO] 📥 Incoming event: Message(timestamp=1776963094641, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=80, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-51-34_3470.webp', file_size=51088, file_id='363fe9a06d9962d9a08b67639f601de3f705ed74', mimetype='image/webp', ready_state=None), message_id='2f50a004-1d03-4b3d-babd-21b20ffbcbab', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:51:37,361 [INFO] [router:0x73c06af80b50] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:51:37,361 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:51:38,541 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/363fe9a06d9962d9a08b67639f601de3f705ed74 "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:51:38,668 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:51:59,710 [INFO] 📥 Incoming event: Message(timestamp=1776963116930, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=83, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-51-56_6400.webp', file_size=51134, file_id='0aca112ece96137cf7ccef3a1f2aa0e2e7ac6716', mimetype='image/webp', ready_state=None), message_id='ee34d07b-c738-4953-851e-b2bb462f7188', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:51:59,711 [INFO] [router:0x73c06af80b50] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:51:59,712 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:52:00,543 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/0aca112ece96137cf7ccef3a1f2aa0e2e7ac6716 "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:52:00,673 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:52:11,373 [INFO] 📥 Incoming event: Message(timestamp=1776963131192, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=86, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-52-10_9050.webp', file_size=70874, file_id='55b8822f54d91c9de4fc826a510c537befd4ebd5', mimetype='image/webp', ready_state=None), message_id='35ee4f25-d715-48ac-840d-cfbe5b27530d', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:52:11,373 [INFO] [router:0x73c06af80b50] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:52:11,374 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:52:12,543 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/55b8822f54d91c9de4fc826a510c537befd4ebd5 "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:52:12,660 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:52:26,048 [INFO] 📥 Incoming event: Message(timestamp=1776963143388, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=89, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-52-23_0840.webp', file_size=61968, file_id='31146c0fcf22afd27c0f6abe91962b08efeefd7e', mimetype='image/webp', ready_state=None), message_id='295a95ae-0118-4cd0-9a34-7e00c0d69495', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:52:26,048 [INFO] [router:0x73c06af80b50] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:52:26,049 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:52:27,547 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/31146c0fcf22afd27c0f6abe91962b08efeefd7e "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:52:27,677 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:54:49,657 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-23 23:54:49,657 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-23 23:54:51,948 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-23 23:54:52,321 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-23 23:54:52,346 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:54:52,347 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-23 23:54:52,364 [INFO] ✅ WebSocket connected
|
||||
2026-04-23 23:54:52,364 [INFO] 🚀 Starting authorization
|
||||
2026-04-23 23:54:52,364 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-23 23:54:52,433 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/60d64d63
|
||||
2026-04-23 23:55:04,223 [INFO] 📥 Incoming event: Message(timestamp=1776963301650, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=92, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-55-01_3280.webp', file_size=48884, file_id='ec564bb845d9ead8144a632a008ca361143ef326', mimetype='image/webp', ready_state=None), message_id='27865386-09b3-4d0d-bffa-7a077b831da9', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:55:04,224 [INFO] [router:0x775ecb984ca0] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:55:04,224 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:55:05,197 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/ec564bb845d9ead8144a632a008ca361143ef326 "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:55:05,405 [INFO] ✉️ Sending file to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:55:05,544 [INFO] Успешно обработано фото для ds.krivochenko
|
||||
2026-04-23 23:56:02,536 [INFO] 📥 Incoming event: Message(timestamp=1776963359671, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=96, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-55-59_3880.webp', file_size=41150, file_id='3105ea960dbd509a3fd08ef9f09477053b31f6a5', mimetype='image/webp', ready_state=None), message_id='6dcfa1a4-452f-4999-b2e0-3385c04c754c', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:56:02,536 [INFO] [router:0x775ecb984ca0] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:56:02,536 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:56:03,985 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/3105ea960dbd509a3fd08ef9f09477053b31f6a5 "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:56:04,090 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:56:19,904 [INFO] 📥 Incoming event: Message(timestamp=1776963377210, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=99, position='0'), content=AttachmentContent(file_name='image_2026.04.23_23-56-16_9780.webp', file_size=30108, file_id='dbd42aa40a3491e5c496c906722887cccb99b47d', mimetype='image/webp', ready_state=None), message_id='37dd0e1b-e000-4de3-a160-3d4dc3c11743', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-23 23:56:19,904 [INFO] [router:0x775ecb984ca0] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-23 23:56:19,904 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:56:20,959 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/dbd42aa40a3491e5c496c906722887cccb99b47d "HTTP/1.1 200 OK"
|
||||
2026-04-23 23:56:21,119 [INFO] ✉️ Sending file to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-23 23:56:21,254 [INFO] Успешно обработано фото для ds.krivochenko
|
||||
2026-04-24 00:02:01,048 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-24 00:02:01,049 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-24 00:02:03,574 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-24 00:02:03,963 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-24 00:02:03,981 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:02:03,982 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-24 00:02:03,996 [INFO] ✅ WebSocket connected
|
||||
2026-04-24 00:02:03,996 [INFO] 🚀 Starting authorization
|
||||
2026-04-24 00:02:03,996 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-24 00:02:04,069 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/3aca404a
|
||||
2026-04-24 00:02:18,536 [INFO] 📥 Incoming event: Message(timestamp=1776963735846, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=103, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-02-15_6030.webp', file_size=34002, file_id='75a04b613653999c91814222650aa3afad25acbd', mimetype='image/webp', ready_state=None), message_id='fbc6a5ad-14f0-45e3-974a-2c46d75beb6f', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:02:18,536 [INFO] [router:0x7099be182050] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:02:18,537 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:02:20,023 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/75a04b613653999c91814222650aa3afad25acbd "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:02:20,164 [INFO] ✉️ Sending file to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:02:20,293 [INFO] Успешно обработано фото для ds.krivochenko
|
||||
2026-04-24 00:03:17,433 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-24 00:03:17,433 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-24 00:03:19,011 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-24 00:03:19,279 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-24 00:03:19,302 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:03:19,303 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-24 00:03:19,322 [INFO] ✅ WebSocket connected
|
||||
2026-04-24 00:03:19,322 [INFO] 🚀 Starting authorization
|
||||
2026-04-24 00:03:19,322 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-24 00:03:19,393 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/7686cbd0
|
||||
2026-04-24 00:03:30,444 [INFO] 📥 Incoming event: Message(timestamp=1776963807702, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=107, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-03-27_4020.webp', file_size=32534, file_id='46dea905d43af0fd4a0aeb939cb52a0d54a41eb1', mimetype='image/webp', ready_state=None), message_id='a5bab295-8a17-401c-8782-45dd3cfa07e9', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:03:30,444 [INFO] [router:0x7f763ab7dea0] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:03:30,444 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:03:30,973 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/46dea905d43af0fd4a0aeb939cb52a0d54a41eb1 "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:03:31,141 [INFO] ✉️ Sending file to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:03:31,290 [INFO] Успешно обработано фото для ds.krivochenko
|
||||
2026-04-24 00:04:57,657 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-24 00:04:57,657 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-24 00:04:59,521 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-24 00:04:59,897 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-24 00:04:59,920 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:04:59,921 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-24 00:04:59,938 [INFO] ✅ WebSocket connected
|
||||
2026-04-24 00:04:59,938 [INFO] 🚀 Starting authorization
|
||||
2026-04-24 00:04:59,938 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-24 00:05:00,020 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/dc28dd50
|
||||
2026-04-24 00:05:12,095 [INFO] 📥 Incoming event: Message(timestamp=1776963909484, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=111, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-05-09_1850.webp', file_size=36734, file_id='137bb1dc861f951934607e84df8d2e3067b0e81a', mimetype='image/webp', ready_state=None), message_id='4fad207a-1a3a-4f5e-8ce6-ada8460e856e', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:05:12,096 [INFO] [router:0x7bc5f7589de0] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:05:12,096 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:05:13,600 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/137bb1dc861f951934607e84df8d2e3067b0e81a "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:05:13,758 [ERROR] Ошибка при работе с фото для ds.krivochenko: Message.answer_photo() missing 1 required positional argument: 'preview'
|
||||
2026-04-24 00:05:13,758 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:06:47,892 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-24 00:06:47,892 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-24 00:06:50,231 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-24 00:06:50,615 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-24 00:06:50,634 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:06:50,635 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-24 00:06:50,648 [INFO] ✅ WebSocket connected
|
||||
2026-04-24 00:06:50,648 [INFO] 🚀 Starting authorization
|
||||
2026-04-24 00:06:50,649 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-24 00:06:50,723 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/89b2d6f9
|
||||
2026-04-24 00:06:54,616 [INFO] 📥 Incoming event: Message(timestamp=1776964014581, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=114, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-06-54_0770.webp', file_size=36734, file_id='6b6d137bb96d86f9b064372b1d545ae5ca580e08', mimetype='image/webp', ready_state=None), message_id='45208bf9-4942-49ab-9db2-ac130fd48adf', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:06:54,616 [INFO] [router:0x757f5e571e70] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:06:54,616 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:06:56,676 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/6b6d137bb96d86f9b064372b1d545ae5ca580e08 "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:06:56,837 [INFO] ✉️ Sending photo to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:06:56,990 [INFO] Успешно обработано фото для ds.krivochenko
|
||||
2026-04-24 00:10:28,416 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-24 00:10:28,416 [INFO] 🛑 ChatBot stopped
|
||||
2026-04-24 00:21:37,851 [INFO] Запуск фото-бота на сервере tcs.sibcem.ru...
|
||||
2026-04-24 00:21:38,237 [INFO] HTTP Request: POST https://tcs.sibcem.ru/bridge/api/client/v1/oauth/token "HTTP/1.1 201 Created"
|
||||
2026-04-24 00:21:38,257 [INFO] HTTP Request: GET https://tcs.sibcem.ru/api/v4/server "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:21:38,258 [INFO] ⏳ Attempting WebSocket connection...
|
||||
2026-04-24 00:21:38,269 [INFO] ✅ WebSocket connected
|
||||
2026-04-24 00:21:38,270 [INFO] 🚀 Starting authorization
|
||||
2026-04-24 00:21:38,270 [INFO] 🛠 Created AuthMethod with id=1
|
||||
2026-04-24 00:21:38,348 [INFO] 🔐 Authenticated as bot_tcs@tcs.sibcem.ru/1c46032d
|
||||
2026-04-24 00:24:04,723 [INFO] 📥 Incoming event: Message(timestamp=1776965041929, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=118, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-24-01_6060.webp', file_size=28260, file_id='fc945836ef4f03ab001a2b8380ffe6743bfd7d71', mimetype='image/webp', ready_state=None), message_id='47af2156-718c-4b38-8c42-aeb26c002cf6', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:24:04,724 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:24:04,725 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:24:06,125 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/fc945836ef4f03ab001a2b8380ffe6743bfd7d71 "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:24:06,301 [INFO] ✉️ Sending photo to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:24:06,455 [INFO] Фото подготовлено для ds.krivochenko, ждем подтверждения.
|
||||
2026-04-24 00:24:32,525 [INFO] 📥 Incoming event: Message(timestamp=1776965072292, type=<MessageType.PLAIN_MESSAGE: 200>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=122, position='0'), content=TextContent(text='Нет', parse_mode='html'), message_id='af22acbc-5b9f-4f03-aaab-8b537733f964', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:24:32,525 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:24:32,525 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:25:14,683 [INFO] 📥 Incoming event: Message(timestamp=1776965111899, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=124, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-25-11_5690.webp', file_size=25826, file_id='945182624c6424558092caf588aac83a071656b4', mimetype='image/webp', ready_state=None), message_id='40c604e8-d252-4227-92aa-cfe807e3ad10', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:25:14,683 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:25:14,683 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:25:16,148 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/945182624c6424558092caf588aac83a071656b4 "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:25:16,258 [INFO] ✉️ Sending photo to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:25:16,411 [INFO] Фото подготовлено для ds.krivochenko, ждем подтверждения.
|
||||
2026-04-24 00:25:30,911 [INFO] 📥 Incoming event: Message(timestamp=1776965130661, type=<MessageType.PLAIN_MESSAGE: 200>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=128, position='0'), content=TextContent(text='Да', parse_mode='html'), message_id='5cc37cc7-74d9-46c2-98f7-712ab19e6fad', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:25:30,911 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:25:30,911 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:25:31,111 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:27:38,891 [INFO] 📥 Incoming event: Message(timestamp=1776965256125, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=131, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-27-35_8290.webp', file_size=39604, file_id='71c02d7294899f9b801d804dd5d2d8085f4ed300', mimetype='image/webp', ready_state=None), message_id='ebf9fab0-6c23-4231-8383-a628ff22831a', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:27:38,891 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:27:38,891 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:27:40,704 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/71c02d7294899f9b801d804dd5d2d8085f4ed300 "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:27:40,849 [INFO] ✉️ Sending photo to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:27:41,004 [INFO] Фото подготовлено для ds.krivochenko, ждем подтверждения.
|
||||
2026-04-24 00:28:35,890 [INFO] 📥 Incoming event: Message(timestamp=1776965313221, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=135, position='0'), content=AttachmentContent(file_name='image_2026.04.24_00-28-32_9170.webp', file_size=30814, file_id='a50aaded753fd1d8a8cd7be18b53d19f8ff81f9a', mimetype='image/webp', ready_state=None), message_id='ac4db733-0418-4f59-8483-03180350d633', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:28:35,890 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:28:35,890 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:28:37,510 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/a50aaded753fd1d8a8cd7be18b53d19f8ff81f9a "HTTP/1.1 200 OK"
|
||||
2026-04-24 00:28:37,662 [INFO] ✉️ Sending photo to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:28:37,814 [INFO] Фото подготовлено для ds.krivochenko, ждем подтверждения.
|
||||
2026-04-24 00:29:02,445 [INFO] 📥 Incoming event: Message(timestamp=1776965342360, type=<MessageType.PLAIN_MESSAGE: 200>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=139, position='0'), content=TextContent(text='Да', parse_mode='html'), message_id='0a3128bc-ed28-48ac-acc1-dc8eabadfbf1', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 00:29:02,445 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 00:29:02,445 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 00:29:02,672 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:08:28,256 [INFO] 📥 Incoming event: Message(timestamp=1776967707857, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=142, position='0'), content=AttachmentContent(file_name='image_2026.04.24_01-08-27_7210.webp', file_size=70874, file_id='3b9c34d0a4d5159302bbd2e664bbebc74bf7b1f8', mimetype='image/webp', ready_state=None), message_id='0b126ae1-9feb-4339-bee3-f9929a76da03', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 01:08:28,281 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 01:08:28,300 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:08:30,338 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/3b9c34d0a4d5159302bbd2e664bbebc74bf7b1f8 "HTTP/1.1 200 OK"
|
||||
2026-04-24 01:08:30,613 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:08:43,132 [INFO] 📥 Incoming event: Message(timestamp=1776967722748, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=145, position='0'), content=AttachmentContent(file_name='image_2026.04.24_01-08-42_5490.webp', file_size=44960, file_id='192fb72c99e818b5a3cc8e096d057716aa0a8fee', mimetype='image/webp', ready_state=None), message_id='027b3201-9cda-496f-9569-c047ad05dc3d', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 01:08:43,132 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 01:08:43,132 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:08:44,085 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/192fb72c99e818b5a3cc8e096d057716aa0a8fee "HTTP/1.1 200 OK"
|
||||
2026-04-24 01:08:44,329 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:08:55,062 [INFO] 📥 Incoming event: Message(timestamp=1776967734742, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=148, position='0'), content=AttachmentContent(file_name='image_2026.04.24_01-08-54_6370.webp', file_size=41150, file_id='cd7c52d43bb145cab9db9e5ece908c18a638e885', mimetype='image/webp', ready_state=None), message_id='dbbf3cab-aa5d-445d-ac4f-33e783bc4f20', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 01:08:55,063 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 01:08:55,063 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:08:56,594 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/cd7c52d43bb145cab9db9e5ece908c18a638e885 "HTTP/1.1 200 OK"
|
||||
2026-04-24 01:08:56,751 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:09:06,471 [INFO] 📥 Incoming event: Message(timestamp=1776967746177, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=151, position='0'), content=AttachmentContent(file_name='image_2026.04.24_01-09-05_9600.webp', file_size=51088, file_id='64c8e4f93958d31c7d968f31187a57242c8c75a1', mimetype='image/webp', ready_state=None), message_id='eec9d2de-01d5-469e-8805-7a4ae22b3c59', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 01:09:06,471 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 01:09:06,471 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 01:09:08,453 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/64c8e4f93958d31c7d968f31187a57242c8c75a1 "HTTP/1.1 200 OK"
|
||||
2026-04-24 01:09:08,592 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:08:20,569 [INFO] 📥 Incoming event: Message(timestamp=1777007297696, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=154, position='0'), content=AttachmentContent(file_name='image_2026.04.24_12-08-17_3930.webp', file_size=41550, file_id='ab6dd76dbccf29443043f9ad4a2259cdf9d88139', mimetype='image/webp', ready_state=None), message_id='146d6162-8b7d-4e2b-8d1c-cb79c859ff25', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 12:08:20,598 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 12:08:20,617 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:08:22,738 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/ab6dd76dbccf29443043f9ad4a2259cdf9d88139 "HTTP/1.1 200 OK"
|
||||
2026-04-24 12:08:23,151 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:08:39,495 [INFO] 📥 Incoming event: Message(timestamp=1777007316582, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=157, position='0'), content=AttachmentContent(file_name='image_2026.04.24_12-08-36_2420.webp', file_size=46970, file_id='6828a553f6cdbc5d0dfa224da4aef9948d3309aa', mimetype='image/webp', ready_state=None), message_id='832d86ea-82ec-4851-9507-818ad67e44d1', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 12:08:39,495 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 12:08:39,495 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:08:41,467 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/6828a553f6cdbc5d0dfa224da4aef9948d3309aa "HTTP/1.1 200 OK"
|
||||
2026-04-24 12:08:41,719 [INFO] ✉️ Sending photo to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:08:42,077 [INFO] Фото подготовлено для ds.krivochenko, ждем подтверждения.
|
||||
2026-04-24 12:09:11,132 [INFO] 📥 Incoming event: Message(timestamp=1777007351000, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=161, position='0'), content=AttachmentContent(file_name='image_2026.04.24_12-09-10_5280.webp', file_size=50966, file_id='8b475f2a95701d32f2ca01cdc72a31ba1a775946', mimetype='image/webp', ready_state=None), message_id='f256a6b8-176c-4b5c-a6f0-9e25e856b829', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 12:09:11,132 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 12:09:11,132 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:09:13,945 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/8b475f2a95701d32f2ca01cdc72a31ba1a775946 "HTTP/1.1 200 OK"
|
||||
2026-04-24 12:09:14,301 [INFO] ✉️ Sending photo to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:09:14,440 [INFO] Фото подготовлено для ds.krivochenko, ждем подтверждения.
|
||||
2026-04-24 12:09:29,899 [INFO] 📥 Incoming event: Message(timestamp=1777007369786, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=165, position='0'), content=AttachmentContent(file_name='image_2026.04.24_12-09-29_3140.webp', file_size=54302, file_id='9399f7914e37463ff42adc2647b90aaf4644e47d', mimetype='image/webp', ready_state=None), message_id='d650aa68-fd7a-414a-ad23-7d2d58d9ea5b', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 12:09:29,899 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 12:09:29,899 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:09:31,446 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/9399f7914e37463ff42adc2647b90aaf4644e47d "HTTP/1.1 200 OK"
|
||||
2026-04-24 12:09:31,543 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:09:45,818 [INFO] 📥 Incoming event: Message(timestamp=1777007385689, type=<MessageType.ATTACHMENT: 202>, author=EnvelopeAuthor(id='ds.krivochenko@tcs.sibcem.ru', type=<EnvelopeAuthorType.USER: 1>), box=EnvelopeBox(id=168, position='0'), content=AttachmentContent(file_name='image_2026.04.24_12-09-45_2090.webp', file_size=43322, file_id='225a71a2869ae1ca8f7d5a544f3eaec4b9f50587', mimetype='image/webp', ready_state=None), message_id='717652b9-de3a-456d-ae14-b6bd6f40a450', chat_id='744bad93e58c4c498e15468562c6a2da3b610e9c', is_edited=False, reply_message_id=None)
|
||||
2026-04-24 12:09:45,818 [INFO] [router:0x757ad5372650] matched handler=async_wrapper filters=[InstanceOfFilter]
|
||||
2026-04-24 12:09:45,818 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-24 12:09:47,467 [INFO] HTTP Request: GET https://tcs.sibcem.ru/bridge/api/client/v1/files/225a71a2869ae1ca8f7d5a544f3eaec4b9f50587 "HTTP/1.1 200 OK"
|
||||
2026-04-24 12:09:47,569 [INFO] ✉️ Sending message to 744bad93e58c4c498e15468562c6a2da3b610e9c
|
||||
2026-04-26 19:36:24,516 [INFO] 🛑 Connect loop cancelled, performing cleanup.
|
||||
2026-04-26 19:36:24,549 [INFO] 🛑 ChatBot stopped
|
||||
@@ -0,0 +1,127 @@
|
||||
import io
|
||||
import cv2
|
||||
import os
|
||||
import logging
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter, ImageEnhance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -> dict:
|
||||
try:
|
||||
# 1. Загружаем изображение для OpenCV
|
||||
if isinstance(input_path, bytes):
|
||||
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
|
||||
else:
|
||||
img = cv2.imread(input_path)
|
||||
if img is None:
|
||||
return {"success": False, "error": "Не удалось прочитать файл изображения."}
|
||||
if isinstance(input_path, bytes):
|
||||
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
|
||||
else:
|
||||
img = cv2.imread(input_path)
|
||||
if img is None:
|
||||
return {"success": False, "error": "Не удалось прочитать файл изображения."}
|
||||
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
# 2. Загружаем каскад Хаара для поиска лица
|
||||
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
||||
face_cascade = cv2.CascadeClassifier(cascade_path)
|
||||
|
||||
# 3. Ищем лица
|
||||
# Оставляем оптимальное значение minNeighbors=8 для баланса
|
||||
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50))
|
||||
|
||||
if len(faces) == 0:
|
||||
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
|
||||
|
||||
if len(faces) > 1:
|
||||
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
|
||||
|
||||
x, y, w, h = faces[0]
|
||||
|
||||
# =========================================================
|
||||
# АБСОЛЮТНАЯ ЗАЩИТА ОТ ГРАФИКИ (НОРМАЛИЗОВАННАЯ МАТЕМАТИКА)
|
||||
# =========================================================
|
||||
# 1. Вырезаем центральную зону лица
|
||||
face_roi_original = gray[y + int(h*0.2):y + int(h*0.8), x + int(w*0.2):x + int(w*0.8)]
|
||||
|
||||
# 2. Сжимаем до 100x100 пикселей.
|
||||
# Это уравнивает 4K-фотографии и мелкие картинки, делая метрики стабильными.
|
||||
face_roi = cv2.resize(face_roi_original, (100, 100))
|
||||
|
||||
std_dev = np.std(face_roi)
|
||||
laplacian_var = cv2.Laplacian(face_roi, cv2.CV_64F).var()
|
||||
unique_colors = len(np.unique(face_roi))
|
||||
|
||||
logger.info(f"Анализ нормализованного лица: std_dev={std_dev:.2f}, laplacian={laplacian_var:.2f}, colors={unique_colors}")
|
||||
|
||||
# ПРОВЕРКА 1: Плоские, замыленные картинки
|
||||
if std_dev < 10.0 or laplacian_var < 30.0:
|
||||
logger.warning(f"Заблокирован плоский рисунок/замыливание: std={std_dev:.1f}, lap={laplacian_var:.1f}")
|
||||
return {"success": False, "error": "Изображение слишком размыто или имеет плоскую текстуру. Отправьте реальное фото."}
|
||||
|
||||
# ПРОВЕРКА 2: Векторы, комиксы и жесткий 2D-арт (Умный порог резкости + цвета)
|
||||
# У реального человека на хорошую камеру laplacian может доходить до 500-600,
|
||||
# но при этом всегда будет богатая палитра цветов (шум матрицы).
|
||||
# У мультиков высокая резкость всегда сопровождается бедной палитрой.
|
||||
if laplacian_var > 850.0 or (laplacian_var > 400.0 and unique_colors < 150):
|
||||
logger.warning(f"Заблокирован резкий 2D-арт: lap={laplacian_var:.1f}, colors={unique_colors}")
|
||||
return {"success": False, "error": "Изображение похоже на графический арт или сильно отфильтровано. Пожалуйста, загрузите естественное фото."}
|
||||
|
||||
# ПРОВЕРКА 3: 3D-рендеры и нейросети (Мало шума)
|
||||
# На реальной фотографии (даже с хорошим светом) шум матрицы телефона
|
||||
# даст минимум 110-120 уникальных оттенков на 10000 пикселей.
|
||||
if unique_colors < 105:
|
||||
logger.warning(f"Заблокирован 3D-рендер по палитре: unique_colors={unique_colors}")
|
||||
return {"success": False, "error": "Изображение распознано как 3D-модель или рисунок (неестественная палитра). Ждем реальное фото!"}
|
||||
# =========================================================
|
||||
|
||||
# 4. Кадрируем (делаем квадрат с отступами вокруг лица)
|
||||
margin = int(w * 0.3)
|
||||
y1 = max(0, y - int(margin * 1.5))
|
||||
y2 = min(img.shape[0], y + h + margin)
|
||||
x1 = max(0, x - margin)
|
||||
x2 = min(img.shape[1], x + w + margin)
|
||||
|
||||
side = min(x2 - x1, y2 - y1)
|
||||
center_x, center_y = (x1 + x2) // 2, (y1 + y2) // 2
|
||||
crop_x1 = max(0, center_x - side // 2)
|
||||
crop_y1 = max(0, center_y - side // 2)
|
||||
crop_x2 = crop_x1 + side
|
||||
crop_y2 = crop_y1 + side
|
||||
|
||||
# 5. Обработка через Pillow
|
||||
pil_img = Image.open(io.BytesIO(input_path))
|
||||
|
||||
# ВАЖНО: Убираем альфа-канал (прозрачность), иначе BMP выдаст ошибку
|
||||
if pil_img.mode in ('RGBA', 'P', 'LA'):
|
||||
background = Image.new('RGB', pil_img.size, (255, 255, 255))
|
||||
background.paste(pil_img, mask=pil_img.split()[3] if pil_img.mode == 'RGBA' else None)
|
||||
pil_img = background
|
||||
elif pil_img.mode != 'RGB':
|
||||
pil_img = pil_img.convert('RGB')
|
||||
|
||||
cropped_img = pil_img.crop((crop_x1, crop_y1, crop_x2, crop_y2))
|
||||
|
||||
# Сжимаем до нужного размера (96x96)
|
||||
final_img = cropped_img.resize((target_size, target_size), Image.Resampling.LANCZOS)
|
||||
|
||||
# Добавляем немного резкости, чтобы лицо не мылилось при 96px
|
||||
final_img = final_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=100, threshold=3))
|
||||
|
||||
# 6. Сохраняем в формате BMP
|
||||
final_img.save(output_path, format="BMP")
|
||||
|
||||
# 7. Проверяем финальный вес
|
||||
size_kb = os.path.getsize(output_path) / 1024
|
||||
|
||||
if size_kb > 99:
|
||||
return {"success": False, "error": f"Сбой: размер файла ({size_kb:.1f} KB) превысил лимит в 99 KB."}
|
||||
|
||||
return {"success": True, "size_kb": round(size_kb, 1)}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в photo_processor: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -0,0 +1,160 @@
|
||||
aiofiles==25.1.0
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.13.5
|
||||
aiosignal==1.4.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.13.0
|
||||
async-property==0.2.2
|
||||
async-timeout==4.0.3
|
||||
attrs==26.1.0
|
||||
autocommand==2.2.2
|
||||
backports.tarfile==1.2.0
|
||||
blinker==1.9.0
|
||||
brotli==1.2.0
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.6
|
||||
click==8.3.1
|
||||
cryptography==46.0.7
|
||||
cssselect2==0.9.0
|
||||
dataclasses-json==0.6.7
|
||||
diskcache==5.6.3
|
||||
distro==1.9.0
|
||||
docx2txt==0.9
|
||||
exceptiongroup==1.3.1
|
||||
faiss-cpu==1.9.0.post1
|
||||
filelock==3.29.0
|
||||
filetype==1.2.0
|
||||
flask==3.1.3
|
||||
fonttools==4.63.0
|
||||
frozenlist==1.8.0
|
||||
fsspec==2026.3.0
|
||||
greenlet==3.2.5
|
||||
grpcio==1.80.0
|
||||
grpcio-tools==1.80.0
|
||||
h11==0.16.0
|
||||
h2==4.3.0
|
||||
hf-xet==1.4.3
|
||||
hpack==4.1.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
httpx-sse==0.4.3
|
||||
huggingface-hub==0.36.2
|
||||
hyperframe==6.1.0
|
||||
idna==3.11
|
||||
importlib-metadata==8.0.0
|
||||
inflect==7.3.1
|
||||
itsdangerous==2.2.0
|
||||
jaraco.collections==5.1.0
|
||||
jaraco.context==5.3.0
|
||||
jaraco.functools==4.0.1
|
||||
jaraco.text==3.12.1
|
||||
jinja2==3.1.6
|
||||
jiter==0.14.0
|
||||
joblib==1.5.3
|
||||
jsonpatch==1.33
|
||||
jsonpointer==3.1.1
|
||||
langchain==1.2.17
|
||||
langchain-classic==1.0.7
|
||||
langchain-community==0.4.1
|
||||
langchain-core==1.3.3
|
||||
langchain-openai==1.2.1
|
||||
langchain-protocol==0.0.15
|
||||
langchain-qdrant==1.1.0
|
||||
langchain-text-splitters==1.1.2
|
||||
langgraph==1.1.10
|
||||
langgraph-checkpoint==4.0.3
|
||||
langgraph-prebuilt==1.0.13
|
||||
langgraph-sdk==0.3.14
|
||||
langsmith==0.8.3
|
||||
ldap3==2.9
|
||||
llama-cpp-python==0.3.30
|
||||
loguru==0.7.3
|
||||
lxml==6.1.0
|
||||
magic-filter==1.0.12
|
||||
markdown-it-py==4.0.0
|
||||
markupsafe==3.0.3
|
||||
marshmallow==3.26.2
|
||||
mashumaro==3.20
|
||||
mdurl==0.1.2
|
||||
more-itertools==10.3.0
|
||||
mpmath==1.3.0
|
||||
multidict==6.7.1
|
||||
mypy-extensions==1.1.0
|
||||
networkx==3.4.2
|
||||
numpy==1.26.4
|
||||
openai==2.36.0
|
||||
opencv-python-headless==4.11.0.86
|
||||
orjson==3.11.9
|
||||
ormsgpack==1.12.2
|
||||
packaging==26.2
|
||||
passwork==0.1.6
|
||||
passwork-python==0.2.0
|
||||
pbkdf2==1.3
|
||||
pillow==12.2.0
|
||||
platformdirs==4.2.2
|
||||
portalocker==3.2.0
|
||||
propcache==0.4.1
|
||||
protobuf==6.33.6
|
||||
psutil==7.2.2
|
||||
pyasn1==0.6.2
|
||||
pycparser==3.0
|
||||
pydantic==2.13.4
|
||||
pydantic-core==2.46.4
|
||||
pydantic-settings==2.14.0
|
||||
pydyf==0.12.1
|
||||
pygments==2.20.0
|
||||
pymssql==2.3.13
|
||||
pymupdf==1.26.0
|
||||
pymupdf4llm==0.0.24
|
||||
pyodbc==5.3.0
|
||||
pyphen==0.17.2
|
||||
python-docx==1.2.0
|
||||
python-dotenv==1.2.2
|
||||
python-magic==0.4.27
|
||||
python-trueconf-bot==1.4.1
|
||||
pyyaml==6.0.3
|
||||
qdrant-client==1.17.1
|
||||
rank-bm25==0.2.2
|
||||
regex==2026.4.4
|
||||
requests==2.32.5
|
||||
requests-toolbelt==1.0.0
|
||||
rich==15.0.0
|
||||
safetensors==0.7.0
|
||||
scikit-learn==1.7.2
|
||||
scipy==1.15.3
|
||||
sentence-transformers==2.6.1
|
||||
setuptools==79.0.1
|
||||
shellingham==1.5.4
|
||||
sniffio==1.3.1
|
||||
sqlalchemy==2.0.49
|
||||
sympy==1.13.1
|
||||
tenacity==9.1.4
|
||||
threadpoolctl==3.6.0
|
||||
tiktoken==0.11.0
|
||||
tinycss2==1.5.1
|
||||
tinyhtml5==2.1.0
|
||||
tokenizers==0.15.2
|
||||
tomli==2.0.1
|
||||
torch==2.2.2+cpu
|
||||
tqdm==4.67.3
|
||||
transformers==4.39.3
|
||||
truststore==0.10.4
|
||||
typeguard==4.3.0
|
||||
typer==0.25.0
|
||||
typing-extensions==4.15.0
|
||||
typing-inspect==0.9.0
|
||||
typing-inspection==0.4.2
|
||||
urllib3==1.26.20
|
||||
uuid-utils==0.14.1
|
||||
weasyprint==68.1
|
||||
webencodings==0.5.1
|
||||
websockets==16.0
|
||||
werkzeug==3.1.6
|
||||
wheel==0.45.1
|
||||
xxhash==3.7.0
|
||||
yarl==1.23.0
|
||||
zipp==3.19.2
|
||||
zopfli==0.4.1
|
||||
zstandard==0.25.0
|
||||
@@ -0,0 +1,685 @@
|
||||
# /opt/trueconf_bot/search_bot/handlers.py
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import csv
|
||||
import time
|
||||
import math
|
||||
import httpx
|
||||
import smtplib
|
||||
import asyncio
|
||||
import torch
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime
|
||||
|
||||
from trueconf import Router, Message
|
||||
from sentence_transformers import CrossEncoder
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
|
||||
|
||||
from config.config import *
|
||||
from utils import states
|
||||
from utils.menu import MENU_TEXT
|
||||
from utils.texts import SEARCH_MAIN_MENU_TEXT, SEARCH_UNKNOWN_CMD_TEXT, search_searching_text, search_not_found_text, search_feedback_thanks_text, search_feedback_info_text, search_unavailable_action
|
||||
|
||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||
from utils.stats_logger import log_menu_stats
|
||||
|
||||
# =========================================================
|
||||
# ГЛОБАЛЬНЫЙ МАРШРУТИЗАТОР ЦЕЛЬНЫХ VK-ЭМОДЗИ
|
||||
# =========================================================
|
||||
EMOJI_DIGITS = {
|
||||
"0": "0⃣",
|
||||
"1": "1⃣",
|
||||
"2": "2⃣",
|
||||
"3": "3⃣",
|
||||
"4": "4⃣",
|
||||
"5": "5⃣",
|
||||
"6": "6⃣",
|
||||
"7": "7⃣",
|
||||
"8": "8⃣",
|
||||
"9": "9⃣",
|
||||
"10": "🔟"
|
||||
}
|
||||
|
||||
# Глобальная константа для меню Поискового Бота
|
||||
|
||||
# Универсальный стандарт сообщения об ошибке ввода
|
||||
|
||||
# Проверка флага глубокого профилирования
|
||||
ENABLE_DEEP_PROFILING = getattr(sys.modules['config.config'], 'ENABLE_DEEP_PROFILING', False) if 'config.config' in sys.modules else True
|
||||
|
||||
# =========================================================
|
||||
# INIT
|
||||
# =========================================================
|
||||
router = Router()
|
||||
|
||||
# Подключаем Qdrant динамически из живых настроек config.py
|
||||
live_config = sys.modules.get('config.config')
|
||||
client = QdrantClient(
|
||||
host=getattr(live_config, 'QDRANT_HOST', 'localhost'),
|
||||
port=getattr(live_config, 'QDRANT_PORT', 6333)
|
||||
)
|
||||
|
||||
# Универсальная гибридная инициализация локального реранкера (если выбран LOCAL)
|
||||
reranker = None
|
||||
if getattr(live_config, 'ENABLE_RERANKER', False) and getattr(live_config, 'RERANKER_MODE', '') == "LOCAL":
|
||||
try:
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
model_path = getattr(live_config, 'RERANK_MODEL_PATH', '/opt/models/bge-reranker')
|
||||
reranker = CrossEncoder(model_path, max_length=512, device=device)
|
||||
print(f"📦 Реранкинг: ЛОКАЛЬНЫЙ режим на {device.upper()}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка загрузки локальной модели реранкера: {e}")
|
||||
|
||||
# =========================================================
|
||||
# EMAIL ALERT FUNCTION
|
||||
# =========================================================
|
||||
last_email_time = 0.0
|
||||
|
||||
def send_alert_email(subject: str, body: str, is_critical_error: bool = False):
|
||||
global last_email_time
|
||||
current_config = sys.modules.get('config.config')
|
||||
if not getattr(current_config, 'ALERTS_SUPPORT_EMAILS', []):
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
if is_critical_error and (current_time - last_email_time < EMAIL_COOLDOWN_SEC):
|
||||
return
|
||||
|
||||
if is_critical_error:
|
||||
last_email_time = current_time
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = getattr(current_config, 'DEFAULT_EMAIL_FROM', 'ai@sibcem.ru')
|
||||
msg["To"] = ", ".join(getattr(current_config, 'ALERTS_SUPPORT_EMAILS', []))
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server:
|
||||
server.send_message(msg)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка отправки email-алерта: {e}")
|
||||
|
||||
# =========================================================
|
||||
# SYSTEM ERRORS & LOGGING
|
||||
# =========================================================
|
||||
def log_system_error(action: str, error_details: str):
|
||||
os.makedirs(LOGS_PATH, exist_ok=True)
|
||||
log_file = os.path.join(LOGS_PATH, "search_errors.log")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"[{timestamp}] Действие: {action} | Ошибка: {error_details}\n")
|
||||
|
||||
async def handle_search_system_error(msg: Message, user_id: str, action: str, error_details: str):
|
||||
await asyncio.to_thread(log_system_error, action, error_details)
|
||||
email_body = f"Пользователь: {user_id}\nДействие: {action}\nДетали проблемы:\n{error_details}"
|
||||
await asyncio.to_thread(send_alert_email, f"🚨 Критический сбой: Поиск Бот - {action}", email_body, True)
|
||||
|
||||
await msg.answer(search_unavailable_action(EMOJI_DIGITS), parse_mode="html")
|
||||
|
||||
def log_analytics(login: str, query: str, answer: str, is_success: bool, response_time: float, qdrant_debug: str, sources: set):
|
||||
os.makedirs(LOGS_PATH, exist_ok=True)
|
||||
csv_file = os.path.join(LOGS_PATH, "chat_history.csv")
|
||||
text_log = os.path.join(LOGS_PATH, "chat_debug.log")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
sources_str = ", ".join(sorted(sources)) if sources else "Нет"
|
||||
file_exists = os.path.isfile(csv_file)
|
||||
|
||||
time_str = f"{response_time:.2f}".replace(".", ",")
|
||||
|
||||
with open(csv_file, mode="a", encoding="utf-8-sig", newline="") as f:
|
||||
writer = csv.writer(f, delimiter=";")
|
||||
if not file_exists:
|
||||
writer.writerow(["Дата и Время", "Логин", "Вопрос", "Ответ", "Успешно?", "Время ответа (сек)", "Документы", "Ответ устроил?", "Текст отзыва"])
|
||||
|
||||
writer.writerow([
|
||||
timestamp, login, query.replace("\n", " ").strip(),
|
||||
answer.replace("\n", " ").strip(), "Да" if is_success else "Нет",
|
||||
time_str, sources_str, "Да", ""
|
||||
])
|
||||
|
||||
if not getattr(sys.modules['config.config'], 'ENABLE_QDRANT_DEBUG', False):
|
||||
return
|
||||
|
||||
with open(text_log, mode="a", encoding="utf-8") as f:
|
||||
f.write(f"╔{'═'*78}╗\n")
|
||||
f.write(f"║ ВРЕМЯ: {timestamp}\n")
|
||||
f.write(f"║ ПОЛЬЗОВАТЕЛЬ: {login}\n")
|
||||
f.write(f"║ ВОПРОС: {query}\n")
|
||||
f.write(f"╔{'═'*78}╗\n")
|
||||
f.write("║ ВЫВОД QDRANT / RERANKER:\n")
|
||||
f.write(f"{qdrant_debug}\n")
|
||||
f.write(f"╠{'═'*78}╣\n")
|
||||
f.write("║ ПОЛНЫЙ ОТВЕТ ИИ / ОШИБКА:\n")
|
||||
f.write(f"║ {answer.replace(chr(10), chr(10) + '║ ')}\n")
|
||||
f.write(f"╠{'═'*78}╣\n")
|
||||
f.write(f"║ ИСТОЧНИКИ: {sources_str}\n")
|
||||
f.write(f"║ ВРЕМЯ ОБРАБОТКИ: {response_time:.2f} сек.\n")
|
||||
f.write(f"╚{'═'*78}╝\n\n")
|
||||
|
||||
def update_feedback_log(login: str, rating: str = None, feedback_text: str = None):
|
||||
csv_file = os.path.join(LOGS_PATH, "chat_history.csv")
|
||||
if not os.path.isfile(csv_file):
|
||||
return
|
||||
try:
|
||||
with open(csv_file, mode="r", encoding="utf-8-sig") as f:
|
||||
reader = list(csv.reader(f, delimiter=";"))
|
||||
target_idx = -1
|
||||
for i in range(len(reader) - 1, 0, -1):
|
||||
if reader[i][1] == login:
|
||||
target_idx = i
|
||||
break
|
||||
if target_idx != -1:
|
||||
while len(reader[target_idx]) < 9:
|
||||
reader[target_idx].append("")
|
||||
if rating is not None:
|
||||
reader[target_idx][7] = rating
|
||||
if feedback_text is not None:
|
||||
reader[target_idx][8] = feedback_text.replace("\n", " ").strip()
|
||||
with open(csv_file, mode="w", encoding="utf-8-sig", newline="") as f:
|
||||
writer = csv.writer(f, delimiter=";")
|
||||
writer.writerows(reader)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка обновления отзыва: {e}")
|
||||
|
||||
def get_last_query_and_answer(login: str):
|
||||
csv_file = os.path.join(LOGS_PATH, "chat_history.csv")
|
||||
if not os.path.isfile(csv_file):
|
||||
return "Неизвестно", "Неизвестно"
|
||||
try:
|
||||
with open(csv_file, mode="r", encoding="utf-8-sig") as f:
|
||||
reader = list(csv.reader(f, delimiter=";"))
|
||||
for i in range(len(reader) - 1, 0, -1):
|
||||
if reader[i][1] == login:
|
||||
query = reader[i][2] if len(reader[i]) > 2 else "Неизвестно"
|
||||
answer = reader[i][3] if len(reader[i]) > 3 else "Неизвестно"
|
||||
return query, answer
|
||||
except Exception as e:
|
||||
pass
|
||||
return "Неизвестно", "Неизвестно"
|
||||
|
||||
# =========================================================
|
||||
# HELPERS
|
||||
# =========================================================
|
||||
def truncate_context(text: str) -> str:
|
||||
if len(text) <= MAX_SINGLE_CHUNK_LENGTH:
|
||||
return text
|
||||
return text[:MAX_SINGLE_CHUNK_LENGTH] + "\n...[обрезано]"
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text.lower()).strip()
|
||||
|
||||
def is_table_chunk(text: str) -> bool:
|
||||
table_patterns = ["Контекст:", "Параметр:", "Значение:", "Col", "|"]
|
||||
hits = sum(1 for p in table_patterns if p in text)
|
||||
return hits >= 2
|
||||
|
||||
# =========================================================
|
||||
# AI REQUEST
|
||||
# =========================================================
|
||||
async def ask_ai(query: str, context_texts: list) -> str:
|
||||
刻_texts = "\n\n".join(context_texts)
|
||||
|
||||
current_config = sys.modules.get('config.config')
|
||||
live_ollama_url = getattr(current_config, 'OLLAMA_URL', 'http://172.16.80.250:8080/v1/chat/completions')
|
||||
live_ollama_model = getattr(current_config, 'OLLAMA_MODEL', 'gemma-4-E4B-it-Q4_K_M')
|
||||
live_ollama_temp = getattr(current_config, 'OLLAMA_TEMPERATURE', 0.0)
|
||||
live_ollama_predict = getattr(current_config, 'OLLAMA_NUM_PREDICT', 1500)
|
||||
live_ai_timeout = getattr(current_config, 'AI_HTTP_TIMEOUT', 180.0)
|
||||
|
||||
full_prompt = f"""Ты — строгий и точный корпоративный аналитик документов АО «ХК «Сибцем».
|
||||
Твоя единственная задача — отвечать на вопросы пользователя, используя ИСКЛЮЧИТЕЛЬНО информацию из предоставленного контекста.
|
||||
|
||||
<context>
|
||||
{刻_texts}
|
||||
</context>
|
||||
|
||||
ИНСТРУКЦИИ И ПРАВИЛА:
|
||||
1. Опирайся строго на текст внутри тегов <context>. Категорически запрещено использовать внешние знания или додумывать факты.
|
||||
2. Приоритет данных: Если в тексте есть таблицы, списки или структурированные блоки, в первую очередь бери сроки и параметры оттуда.
|
||||
3. Внимательность: Всегда дочитывай абзац до конца, чтобы не пропустить исключения.
|
||||
4. Косвенные ответы: Если в тексте прямо не описан процесс (например, "Как создать?"), но указано, на основании чего он выполняется (например, "создается по заявке"), ОБЯЗАТЕЛЬНО сообщи об этом пользователю.
|
||||
5. Формат ответа: Пиши кратко, ясно и только по делу на русском языке.
|
||||
6. ЕСЛИ ОТВЕТ НАЙДЕН: В конце ответа всегда с новой строки указывай источник в формате: "Источник: [название файла]".
|
||||
7. ЕСЛИ ОТВЕТА НЕТ: Если информации в тегах <context> недостаточно даже для косвенного ответа, выведи строго одну фразу: «В данных документах этот вопрос не регламентирован.». Источник указывать не нужно.
|
||||
|
||||
ВОПРОС ПОЛЬЗОВАТЕЛЯ:
|
||||
{query}"""
|
||||
|
||||
safe_temp = 0.1 if live_ollama_temp <= 0.0 else live_ollama_temp
|
||||
|
||||
payload = {
|
||||
"model": live_ollama_model,
|
||||
"messages": [
|
||||
{"role": "user", "content": full_prompt}
|
||||
],
|
||||
"temperature": safe_temp,
|
||||
"max_tokens": live_ollama_predict,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(live_ai_timeout)) as http_client:
|
||||
response = await http_client.post(live_ollama_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# =========================================================
|
||||
# MAIN MESSAGE HANDLER
|
||||
# =========================================================
|
||||
@router.message()
|
||||
async def main_message_handler(msg: Message):
|
||||
user_id = msg.from_user.id
|
||||
current_state = states.get_state(user_id)
|
||||
|
||||
current_config = sys.modules.get('config.config')
|
||||
live_use_reranker = getattr(current_config, 'ENABLE_RERANKER', False)
|
||||
live_reranker_mode = getattr(current_config, 'RERANKER_MODE', 'REMOTE_LLAMACPP')
|
||||
live_reranker_url = getattr(current_config, 'RERANKER_API_URL', 'http://172.16.80.250:8080/v1/rerank')
|
||||
live_reranker_model = getattr(current_config, 'RERANKER_MODEL_NAME', 'bge-reranker-v2-m3-Q8_0')
|
||||
|
||||
msg_text = msg.text.strip() if msg.text else ""
|
||||
cmd = msg_text.lower()
|
||||
|
||||
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||
if cmd == emoji_num:
|
||||
cmd = raw_num
|
||||
break
|
||||
|
||||
# -----------------------------------------------------
|
||||
# ЛОГИКА ФИДБЕКА
|
||||
# -----------------------------------------------------
|
||||
if current_state in ["AWAITING_FEEDBACK", "FEEDBACK_DONE"]:
|
||||
msg.handled = True
|
||||
if cmd in ["0", "9", "/start", "меню"]:
|
||||
log_menu_stats(user_id, "Search Bot", "Выход в главное меню")
|
||||
states.clear_state(user_id)
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
elif cmd == "1":
|
||||
log_menu_stats(user_id, "Search Bot", "Повторный ввод вопроса")
|
||||
states.set_state(user_id, "SEARCH_MODE")
|
||||
await msg.answer(f"🔍 <b>Жду ваш следующий вопрос по регламентам!</b>\n\n<i>Для выхода отправьте {EMOJI_DIGITS['0']}.</i>", parse_mode="html")
|
||||
return
|
||||
elif current_state == "AWAITING_FEEDBACK" and msg_text:
|
||||
log_menu_stats(user_id, "Search Bot", "Отправлен развернутый отзыв")
|
||||
update_feedback_log(str(user_id), feedback_text=msg_text)
|
||||
states.set_state(user_id, "SEARCH_MODE")
|
||||
last_query, last_answer = get_last_query_and_answer(str(user_id))
|
||||
send_alert_email(
|
||||
subject="💬 Новый текстовый отзыв к боту-регламентов",
|
||||
body=(f"Пользователь: {user_id}\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n{last_query}\n\nОТВЕТ БОТА:\n{last_answer}\n\nТЕКСТ ОТЗЫВА:\n{msg_text}")
|
||||
)
|
||||
await msg.answer(search_feedback_thanks_text(EMOJI_DIGITS), parse_mode="html")
|
||||
return
|
||||
else:
|
||||
await msg.answer(UNKNOWN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
# -----------------------------------------------------
|
||||
# ЛОГИКА ПОИСКА (SEARCH_MODE)
|
||||
# -----------------------------------------------------
|
||||
if current_state == "SEARCH_MODE":
|
||||
msg.handled = True
|
||||
|
||||
if cmd in ["0", "9", "/start", "меню"]:
|
||||
log_menu_stats(user_id, "Search Bot", "Выход в главное меню")
|
||||
states.clear_state(user_id)
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
if cmd == "1":
|
||||
log_menu_stats(user_id, "Search Bot", "Клик: Ответ не удовлетворил (1)")
|
||||
states.set_state(user_id, "AWAITING_FEEDBACK")
|
||||
update_feedback_log(str(user_id), rating="Нет")
|
||||
last_query, last_answer = get_last_query_and_answer(str(user_id))
|
||||
send_alert_email(
|
||||
subject="⚠️ Пользователя не устроил answer бота",
|
||||
body=(f"Пользователь: {user_id}\n\nВОПРОС ПОЛЬЗОВАТЕЛЯ:\n{last_query}\n\nОТВЕТ БОТА:\n{last_answer}\n\nНажал '1' (Ответ не удовлетворил).")
|
||||
)
|
||||
await msg.answer(search_feedback_info_text(EMOJI_DIGITS), parse_mode="html")
|
||||
return
|
||||
|
||||
if not msg_text:
|
||||
await msg.answer(UNKNOWN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
# ФИКСАЦИЯ СТАТИСТИКИ: Начало семантического поиска
|
||||
log_menu_stats(user_id, "Search Bot", "Выполнение поиска по регламентам")
|
||||
|
||||
# =====================================================
|
||||
# ГЛУБОКОЕ ПРОФИЛИРОВАНИЕ (DEEP PROFILING)
|
||||
# =====================================================
|
||||
start_time = time.time()
|
||||
prof_log = []
|
||||
|
||||
def add_prof(msg_text):
|
||||
if ENABLE_DEEP_PROFILING:
|
||||
elapsed = time.time() - start_time
|
||||
prof_log.append(f"[{elapsed:>6.2f}s] {msg_text}")
|
||||
|
||||
add_prof(f"=== СТАРТ ЗАПРОСА: '{msg_text}' ===")
|
||||
add_prof(f"[НАСТРОЙКИ] QDRANT_LIMIT={QDRANT_LIMIT}, RERANK_TOP_K={RERANK_TOP_K}, USE_RERANKER={live_use_reranker}, CHUNK_WINDOW={QDRANT_CHUNK_WINDOW}")
|
||||
|
||||
await msg.answer(search_searching_text(), parse_mode="html")
|
||||
|
||||
# =====================================================
|
||||
# ВЕКТОРИЗАЦИЯ И ПОИСК (API ЭМБЕДДИНГОВ)
|
||||
# =====================================================
|
||||
try:
|
||||
t_vec = time.time()
|
||||
|
||||
payload_api = {
|
||||
"model": getattr(current_config, 'EMBEDDING_MODEL_NAME', 'bge-m3'),
|
||||
"input_type": "query",
|
||||
"texts": [msg_text]
|
||||
}
|
||||
headers_api = {"Content-Type": "application/json"}
|
||||
api_token = getattr(current_config, 'EMBEDDING_API_TOKEN', None)
|
||||
if api_token:
|
||||
headers_api["X-Embedding-Token"] = api_token
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(API_HTTP_TIMEOUT)) as http_client:
|
||||
api_resp = await http_client.post(EMBEDDING_API_URL, json=payload_api, headers=headers_api)
|
||||
api_resp.raise_for_status()
|
||||
query_vector = api_resp.json()["embeddings"][0]
|
||||
|
||||
add_prof(f"[ВЕКТОРИЗАЦИЯ API] Текст преобразован в вектор через API за {time.time() - t_vec:.2f}s.")
|
||||
|
||||
fetch_limit = QDRANT_LIMIT * 3 if live_use_reranker else QDRANT_LIMIT
|
||||
|
||||
t_qdr = time.time()
|
||||
search_results = client.query_points(
|
||||
collection_name=QDRANT_COLLECTION,
|
||||
query=query_vector,
|
||||
limit=fetch_limit,
|
||||
with_payload=True
|
||||
).points
|
||||
add_prof(f"[QDRANT ПОИСК] Извлечено чанков: {len(search_results)} (запрашивали {fetch_limit}). Заняло {time.time() - t_qdr:.2f}s.")
|
||||
|
||||
# --- УНИВЕРСАЛЬНЫЙ ГИБРИДНЫЙ БЛОК РЕРАНКИНГА ---
|
||||
if live_use_reranker and search_results:
|
||||
t_rerank = time.time()
|
||||
logits = None
|
||||
rerank_success = False
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ВАРИАНТ 1: Новый API Llama.cpp (Standard Jina API format) с БАТЧИНГОМ
|
||||
# ---------------------------------------------------------
|
||||
if live_reranker_mode == "REMOTE_LLAMACPP":
|
||||
add_prof(f"[RERANKER] Вызов Llama.cpp API для {len(search_results)} чанков (Батчинг).")
|
||||
|
||||
documents = [res.payload.get("page_content", "")[:1500] for res in search_results]
|
||||
batch_size = 4
|
||||
logits = [0.0] * len(documents)
|
||||
rerank_success = True
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(API_HTTP_TIMEOUT)) as http_client:
|
||||
for b_start in range(0, len(documents), batch_size):
|
||||
b_docs = documents[b_start:b_start + batch_size]
|
||||
payload = {
|
||||
"model": live_reranker_model,
|
||||
"query": msg_text,
|
||||
"documents": b_docs,
|
||||
"top_n": len(b_docs)
|
||||
}
|
||||
|
||||
response = await http_client.post(live_reranker_url, json=payload)
|
||||
response.raise_for_status()
|
||||
rerank_data = response.json()
|
||||
|
||||
if "results" in rerank_data:
|
||||
for item in rerank_data["results"]:
|
||||
local_index = item.get("index")
|
||||
score = item.get("relevance_score", 0.0)
|
||||
if local_index is not None:
|
||||
global_index = b_start + local_index
|
||||
if 0 <= global_index < len(logits):
|
||||
logits[global_index] = score
|
||||
else:
|
||||
add_prof(f"[RERANKER ERROR] Неверный формат ответа в батче {b_start}: {rerank_data}")
|
||||
rerank_success = False
|
||||
break
|
||||
except Exception as e:
|
||||
add_prof(f"[RERANKER CRASH] Ошибка Llama.cpp API (батч {b_start}): {e}")
|
||||
rerank_success = False
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ВАРИАНТ 2: Старый самописный удаленный API (Legacy)
|
||||
# ---------------------------------------------------------
|
||||
elif live_reranker_mode == "REMOTE_CUSTOM":
|
||||
add_prof(f"[RERANKER] Вызов старого кастомного API для {len(search_results)} чанков.")
|
||||
passages = [res.payload.get("page_content", "") for res in search_results]
|
||||
payload = {
|
||||
"query": msg_text,
|
||||
"passages": passages
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(API_HTTP_TIMEOUT)) as http_client:
|
||||
response = await http_client.post(live_reranker_url, json=payload)
|
||||
response.raise_for_status()
|
||||
rerank_data = response.json()
|
||||
|
||||
if rerank_data.get("status") == "success":
|
||||
logits = rerank_data.get("scores", [])
|
||||
rerank_success = True
|
||||
else:
|
||||
add_prof(f"[RERANKER ERROR] Сервер вернул ошибку: {rerank_data.get('detail')}")
|
||||
except Exception as e:
|
||||
add_prof(f"[RERANKER CRASH] Ошибка кастомного API: {e}")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ВАРИАНТ 3: Локальный инференс (CrossEncoder)
|
||||
# ---------------------------------------------------------
|
||||
elif live_reranker_mode == "LOCAL" and reranker is not None:
|
||||
add_prof(f"[RERANKER] Локальный инференс для {len(search_results)} пар.")
|
||||
pairs = [[msg_text, res.payload.get("page_content", "")] for res in search_results]
|
||||
try:
|
||||
logits = reranker.predict(pairs)
|
||||
rerank_success = True
|
||||
except Exception as e:
|
||||
add_prof(f"[RERANKER CRASH] Ошибка локального инференса: {e}")
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ПРИМЕНЕНИЕ РЕЗУЛЬТАТОВ
|
||||
# ---------------------------------------------------------
|
||||
if rerank_success and logits is not None:
|
||||
for i, res in enumerate(search_results):
|
||||
res.payload["original_qdrant_score"] = res.score
|
||||
res.score = float(logits[i])
|
||||
|
||||
search_results = sorted(search_results, key=lambda r: r.score, reverse=True)
|
||||
search_results = search_results[:RERANK_TOP_K]
|
||||
add_prof(f"[RERANKER END] Успешно отобрано {len(search_results)} чанков за {time.time() - t_rerank:.2f}s.")
|
||||
else:
|
||||
add_prof("[RERANKER FALLBACK] Ошибка реранкинга. Падаем на базовые скоры Qdrant.")
|
||||
search_results = sorted(search_results, key=lambda r: r.score, reverse=True)
|
||||
search_results = search_results[:RERANK_TOP_K]
|
||||
else:
|
||||
search_results = sorted(search_results, key=lambda r: r.score, reverse=True)
|
||||
|
||||
except Exception as e:
|
||||
error_str = f"[ОШИБКА ПОИСКА]: {type(e).__name__} - {str(e)}"
|
||||
add_prof(f"[КРИТИЧЕСКАЯ ОШИБКА] {error_str}")
|
||||
log_analytics(str(user_id), msg_text, error_str, False, time.time() - start_time, error_str, set())
|
||||
await handle_search_system_error(msg, str(user_id), "Семантический поиск", error_str)
|
||||
return
|
||||
|
||||
if not search_results:
|
||||
error_str = "Поиск вернул 0 результатов."
|
||||
add_prof(error_str)
|
||||
log_analytics(str(user_id), msg_text, error_str, False, time.time() - start_time, error_str, set())
|
||||
await handle_search_system_error(msg, str(user_id), "Проверка результатов Qdrant", error_str)
|
||||
return
|
||||
|
||||
# =====================================================
|
||||
# ФИЛЬТРАЦИЯ И СБОРКА КОНТЕКСТА
|
||||
# =====================================================
|
||||
add_prof("[СБОРКА КОНТЕКСТА] Начат анализ найденных чанков...")
|
||||
raw_contexts, sources, processed_windows, seen_chunk_hashes, qdrant_debug_lines = [], set(), set(), set(), []
|
||||
contexts_added, total_context_size = 0, 0
|
||||
query_lower = msg_text.lower()
|
||||
query_words = set(w for w in re.findall(r"\w+", query_lower) if len(w) > 3)
|
||||
|
||||
for i, res in enumerate(search_results, 1):
|
||||
if contexts_added >= MAX_AI_CONTEXTS:
|
||||
add_prof(f" -> ПРЕРЫВАНИЕ: Достигнут лимит MAX_AI_CONTEXTS ({MAX_AI_CONTEXTS}).")
|
||||
break
|
||||
|
||||
score = res.score
|
||||
metadata = res.payload.get("metadata", {})
|
||||
filename = metadata.get("filename", "unknown")
|
||||
source_path = metadata.get("source", "")
|
||||
chunk_idx = metadata.get("chunk_index", -1)
|
||||
chunk_text = res.payload.get("page_content", "")
|
||||
chunk_text_lower = chunk_text.lower()
|
||||
|
||||
chunk_words = set(w for w in re.findall(r"\w+", chunk_text_lower) if len(w) > 3)
|
||||
keyword_overlap = len(query_words & chunk_words)
|
||||
|
||||
add_prof(f" -> [{i}] Оценка чанка: '{filename}' (idx={chunk_idx}). Score: {score:.4f}. Пересечение: {keyword_overlap} слов.")
|
||||
|
||||
if not live_use_reranker:
|
||||
if keyword_overlap == 0: score -= 0.05
|
||||
elif keyword_overlap == 1: score += 0.03
|
||||
elif keyword_overlap >= 2: score += 0.10
|
||||
|
||||
if "хран" in query_lower and "хран" in filename.lower(): score += 0.10
|
||||
if "долгосроч" in query_lower and ("долгосроч" in chunk_text_lower or "срок хран" in chunk_text_lower or "архив" in chunk_text_lower): score += 0.15
|
||||
if "файл" in query_lower and ("файл" in chunk_text_lower or "электрон" in chunk_text_lower): score += 0.05
|
||||
|
||||
is_table = is_table_chunk(chunk_text)
|
||||
|
||||
# --- УНИВЕРСАЛЬНАЯ ДИНАМИЧЕСКАЯ ФИЛЬТРАЦИЯ ОТ МУСОРА ---
|
||||
if live_use_reranker:
|
||||
effective_threshold = getattr(current_config, 'RERANK_SCORE_THRESHOLD', -3.5)
|
||||
else:
|
||||
effective_threshold = TABLE_SCORE_THRESHOLD if is_table else QDRANT_SCORE_THRESHOLD
|
||||
if is_table:
|
||||
add_prof(f" - Обнаружена таблица. Порог снижен до {effective_threshold}.")
|
||||
|
||||
if score < effective_threshold:
|
||||
add_prof(f" - ПРОПУСК: Итоговый score {score:.4f} ниже порога {effective_threshold}.")
|
||||
qdrant_debug_lines.append(f"[SKIP] {score:.4f} < {effective_threshold} | {filename}")
|
||||
continue
|
||||
|
||||
min_idx, max_idx = max(0, chunk_idx - QDRANT_CHUNK_WINDOW), chunk_idx + QDRANT_CHUNK_WINDOW
|
||||
|
||||
try:
|
||||
t_scroll = time.time()
|
||||
neighbor_records, _ = client.scroll(
|
||||
collection_name=QDRANT_COLLECTION,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="metadata.source", match=MatchValue(value=source_path)),
|
||||
FieldCondition(key="metadata.chunk_index", range=Range(gte=min_idx, lte=max_idx))
|
||||
]
|
||||
),
|
||||
limit=QDRANT_SCROLL_LIMIT,
|
||||
with_payload=True
|
||||
)
|
||||
add_prof(f" - SCROLL соседей ({min_idx}-{max_idx}) занял {time.time() - t_scroll:.2f}s. Найдено соседей: {len(neighbor_records)}.")
|
||||
except Exception as e:
|
||||
add_prof(f" - SCROLL ERROR: {e}")
|
||||
qdrant_debug_lines.append(f"[SCROLL ERROR] {e}")
|
||||
continue
|
||||
|
||||
if not neighbor_records:
|
||||
continue
|
||||
|
||||
neighbor_records.sort(key=lambda x: x.payload["metadata"]["chunk_index"])
|
||||
|
||||
merged_parts = []
|
||||
for p in neighbor_records:
|
||||
idx = p.payload["metadata"].get("chunk_index", -1)
|
||||
txt = p.payload.get("page_content", "")
|
||||
if txt:
|
||||
normalized_sub = normalize_text(txt)[:1200]
|
||||
sub_hash = hash(normalized_sub)
|
||||
if sub_hash in seen_chunk_hashes:
|
||||
continue
|
||||
|
||||
seen_chunk_hashes.add(sub_hash)
|
||||
merged_parts.append(f"[Чанк {idx}]\n{truncate_context(txt)}")
|
||||
|
||||
if not merged_parts:
|
||||
continue
|
||||
|
||||
merged_text = "\n\n".join(merged_parts)
|
||||
final_context = f"ФАЙЛ: {filename}\n\n{merged_text}"
|
||||
|
||||
if total_context_size + len(final_context) > MAX_CONTEXT_LENGTH:
|
||||
add_prof(f" - ПРЕРЫВАНИЕ: Лимит контекста ({total_context_size + len(final_context)} > {MAX_CONTEXT_LENGTH} симв.)")
|
||||
qdrant_debug_lines.append("[STOP] Достигнут лимит контекста")
|
||||
break
|
||||
|
||||
total_context_size += len(final_context)
|
||||
raw_contexts.append(final_context)
|
||||
sources.add(filename)
|
||||
contexts_added += 1
|
||||
|
||||
add_prof(f" - ДОБАВЛЕНО В ИТОГ. Текущий размер промпта: {total_context_size} симв.")
|
||||
|
||||
debug_preview = merged_text[:DEBUG_TEXT_LIMIT].replace("\n", " ")
|
||||
orig_score = res.payload.get("original_qdrant_score", res.score)
|
||||
qdrant_debug_lines.append(f"[{i}] orig={orig_score:.4f} | final={score:.4f} | overlap={keyword_overlap} | table={is_table} | file={filename} | chunk={chunk_idx}\npreview={debug_preview}...")
|
||||
|
||||
qdrant_debug_full = "\n".join(qdrant_debug_lines)
|
||||
add_prof(f"[СБОРКА КОНТЕКСТА ЗАВЕРШЕНА] Итого контекстов: {contexts_added}. Источников: {len(sources)}.")
|
||||
|
||||
if not raw_contexts:
|
||||
error_str = "Документы найдены Qdrant, но отсеяны фильтрами (score threshold)."
|
||||
add_prof(f"[СТОП] {error_str}")
|
||||
log_analytics(str(user_id), msg_text, "Не найдено", False, time.time() - start_time, qdrant_debug_full, set())
|
||||
send_alert_email(subject="📭 Бот не нашел релевантных документов", body=f"Пользователь: {user_id}\nВопрос: {msg_text}\nПричина: {error_str}")
|
||||
await msg.answer(search_not_found_text(EMOJI_DIGITS), parse_mode="html")
|
||||
|
||||
if ENABLE_DEEP_PROFILING:
|
||||
with open(os.path.join(LOGS_PATH, "deep_profiling.log"), "a", encoding="utf-8") as f:
|
||||
f.write(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] USER: {user_id}\n" + "\n".join(prof_log) + "\n" + "="*80 + "\n")
|
||||
return
|
||||
|
||||
# =====================================================
|
||||
# AI REQUEST (LLM)
|
||||
# =====================================================
|
||||
try:
|
||||
add_prof(f"[LLM START] Подготовка запроса к {getattr(current_config, 'OLLAMA_MODEL', 'gemma')}.")
|
||||
t_llm = time.time()
|
||||
|
||||
ai_answer = await ask_ai(msg_text, raw_contexts)
|
||||
|
||||
add_prof(f"[LLM END] Ответ получен за {time.time() - t_llm:.2f}s.")
|
||||
|
||||
if not ai_answer or ai_answer.strip() == "":
|
||||
add_prof(f"[LLM WARNING] Модель вернула пустую строку. Применяется фоллбэк.")
|
||||
ai_answer = "В данных документах этот вопрос не регламентирован."
|
||||
|
||||
is_success = "не регламентирован" not in ai_answer.lower()
|
||||
|
||||
if not is_success:
|
||||
send_alert_email(
|
||||
subject="👁 Бот не смог найти answer в тексте (Не регламентирован)",
|
||||
body=f"Пользователь: {user_id}\nВопрос: {msg_text}\n\nИИ ответил, что вопрос не регламентирован."
|
||||
)
|
||||
|
||||
final_response = f"🤖 <b>ОТВЕТ ИИ:</b>\n\n{ai_answer}\n\n<i>{EMOJI_DIGITS['1']} — Ответ не удовлетворил\n{EMOJI_DIGITS['0']} — Выход в меню</i>"
|
||||
|
||||
add_prof(f"[ИТОГ] Запрос полностью выполнен за {time.time() - start_time:.2f}s.")
|
||||
log_analytics(str(user_id), msg_text, ai_answer, is_success, time.time() - start_time, qdrant_debug_full, sources)
|
||||
await msg.answer(final_response, parse_mode="html")
|
||||
|
||||
except Exception as e:
|
||||
error_str = f"[ОШИБКА LLM]: {type(e).__name__} - {str(e)}"
|
||||
add_prof(f"[FATAL ERROR] {error_str}")
|
||||
qdrant_debug_full += f"\n\n[FATAL ERROR]\n{error_str}"
|
||||
log_analytics(str(user_id), msg_text, error_str, False, time.time() - start_time, qdrant_debug_full, sources)
|
||||
await handle_search_system_error(msg, str(user_id), "Запрос к серверу LLM", error_str)
|
||||
|
||||
if ENABLE_DEEP_PROFILING:
|
||||
with open(os.path.join(LOGS_PATH, "deep_profiling.log"), "a", encoding="utf-8") as f:
|
||||
f.write(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] USER: {user_id}\n" + "\n".join(prof_log) + "\n" + "="*80 + "\n")
|
||||
@@ -0,0 +1,173 @@
|
||||
import os
|
||||
import sys
|
||||
import string
|
||||
import pickle # Библиотека для сохранения фрагментов текста на диск
|
||||
|
||||
# Загрузчики и разбивка текста
|
||||
from langchain_community.document_loaders import DirectoryLoader, PyMuPDFLoader, Docx2txtLoader
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
# Эмбеддинги и векторная база
|
||||
from langchain_community.embeddings import HuggingFaceEmbeddings
|
||||
from langchain_community.vectorstores import FAISS
|
||||
|
||||
# Ретриверы
|
||||
from langchain_community.retrievers import BM25Retriever
|
||||
from langchain_classic.retrievers import EnsembleRetriever
|
||||
|
||||
# Цепочки
|
||||
from langchain_classic.chains.combine_documents import create_stuff_documents_chain
|
||||
from langchain_classic.chains import create_retrieval_chain
|
||||
|
||||
# Базовые модули и LLM
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
# ==========================================
|
||||
# НАСТРОЙКА ПУТЕЙ
|
||||
# ==========================================
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BASE_REGULATIONS_PATH = os.path.join(BASE_DIR, "search_bot", "Регламенты", "АОХКСибцем", "07 Служба Вице-президента по экономике и финансам", "ДИТ")
|
||||
MODEL_PATH = os.path.join(BASE_DIR, "models", "mpnet_model")
|
||||
|
||||
# Пути для сохранения базы данных
|
||||
FAISS_DB_PATH = os.path.join(BASE_DIR, "search_bot", "faiss_index")
|
||||
CHUNKS_PATH = os.path.join(BASE_DIR, "search_bot", "chunks.pkl")
|
||||
|
||||
# ==========================================
|
||||
# 1. ИНИЦИАЛИЗАЦИЯ ЭМБЕДДИНГОВ
|
||||
# ==========================================
|
||||
print("Загрузка модели эмбеддингов (оффлайн)...")
|
||||
embedding = HuggingFaceEmbeddings(
|
||||
model_name=MODEL_PATH,
|
||||
model_kwargs={'device': 'cpu'},
|
||||
encode_kwargs={'normalize_embeddings': False}
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# 2. ЗАГРУЗКА ИЛИ СОЗДАНИЕ БАЗЫ ДАННЫХ
|
||||
# ==========================================
|
||||
# Проверяем, существует ли уже сохраненная база
|
||||
if os.path.exists(FAISS_DB_PATH) and os.path.exists(CHUNKS_PATH):
|
||||
print("✅ Найдена сохраненная база данных! Загружаю с диска (это быстро)...")
|
||||
|
||||
# Загружаем фрагменты текста для BM25
|
||||
with open(CHUNKS_PATH, 'rb') as f:
|
||||
split_docs = pickle.load(f)
|
||||
|
||||
# Загружаем векторы FAISS
|
||||
vector_store = FAISS.load_local(
|
||||
FAISS_DB_PATH,
|
||||
embedding,
|
||||
allow_dangerous_deserialization=True # Обязательно для локальных файлов
|
||||
)
|
||||
else:
|
||||
print("⚠️ Сохраненная база не найдена. Начинаю чтение и индексацию документов...\n")
|
||||
|
||||
if not os.path.exists(BASE_REGULATIONS_PATH):
|
||||
print("❌ ОШИБКА: Указанный путь к документам не существует.")
|
||||
sys.exit(1)
|
||||
|
||||
# Загружаем PDF и DOCX
|
||||
pdf_loader = DirectoryLoader(BASE_REGULATIONS_PATH, glob="**/*.pdf", loader_cls=PyMuPDFLoader, show_progress=True)
|
||||
docx_loader = DirectoryLoader(BASE_REGULATIONS_PATH, glob="**/*.docx", loader_cls=Docx2txtLoader, show_progress=True)
|
||||
|
||||
docs = pdf_loader.load() + docx_loader.load()
|
||||
if len(docs) == 0:
|
||||
print("❌ В указанной папке нет файлов .pdf или .docx. Завершение.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"✅ Загружено страниц/файлов: {len(docs)}. Разбивка на фрагменты...")
|
||||
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1200, chunk_overlap=200, separators=["\n\n", "\n", ".", " "])
|
||||
split_docs = text_splitter.split_documents(docs)
|
||||
|
||||
print("Создаю векторную базу FAISS...")
|
||||
vector_store = FAISS.from_documents(split_docs, embedding=embedding)
|
||||
|
||||
# СОХРАНЕНИЕ НА ДИСК ДЛЯ БУДУЩИХ ЗАПУСКОВ
|
||||
print("💾 Сохраняю базу данных на диск...")
|
||||
vector_store.save_local(FAISS_DB_PATH)
|
||||
with open(CHUNKS_PATH, 'wb') as f:
|
||||
pickle.dump(split_docs, f)
|
||||
print("✅ База успешно сохранена!")
|
||||
|
||||
# ==========================================
|
||||
# 3. НАСТРОЙКА РЕТРИВЕРОВ (ПОИСКА)
|
||||
# ==========================================
|
||||
# 3.1 Семантический поиск
|
||||
embedding_retriever = vector_store.as_retriever(search_kwargs={"k": 4})
|
||||
|
||||
# 3.2 Лексический поиск
|
||||
def tokenize(s):
|
||||
return s.lower().translate(str.maketrans("", "", string.punctuation)).split(" ")
|
||||
|
||||
bm25_retriever = BM25Retriever.from_documents(
|
||||
documents=split_docs,
|
||||
preprocess_func=tokenize,
|
||||
k=5
|
||||
)
|
||||
|
||||
# 3.3 Гибридный поиск (Ансамбль)
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[embedding_retriever, bm25_retriever],
|
||||
weights=[0.4, 0.6]
|
||||
)
|
||||
|
||||
# ==========================================
|
||||
# 4. ПОДКЛЮЧЕНИЕ К LLAMA.CPP
|
||||
# ==========================================
|
||||
print("Подключение к серверу llama.cpp...")
|
||||
llm = ChatOpenAI(
|
||||
base_url="http://127.0.0.1:8080/v1",
|
||||
api_key="not-needed",
|
||||
temperature=0.0,
|
||||
max_tokens=1024
|
||||
)
|
||||
|
||||
prompt = ChatPromptTemplate.from_template('''Ты — строгий корпоративный ИИ-помощник.
|
||||
Твоя задача — отвечать на вопросы строго на основании текста предоставленных регламентов.
|
||||
Внимательно изучи контекст. Если там есть ответ, сформулируй его четко и по делу. Обязательно указывай номер пункта или название документа, откуда взята информация.
|
||||
Если в контексте НЕТ ответа на вопрос, не придумывай информацию, а выведи фразу: "В предоставленных регламентах нет информации по данному вопросу."
|
||||
|
||||
Контекст:
|
||||
{context}
|
||||
|
||||
Вопрос пользователя: {input}
|
||||
|
||||
Ответ:'''
|
||||
)
|
||||
|
||||
document_chain = create_stuff_documents_chain(llm=llm, prompt=prompt)
|
||||
rag_chain = create_retrieval_chain(ensemble_retriever, document_chain)
|
||||
|
||||
# ==========================================
|
||||
# 5. ТЕСТИРОВАНИЕ СИСТЕМЫ
|
||||
# ==========================================
|
||||
print("\n🚀 Система готова к работе! Выполняем запросы...\n")
|
||||
|
||||
# Тестовые вопросы по Положению № ПОЛ-177
|
||||
questions = [
|
||||
"Где работник обязан хранить электронные документы, связанные с производственной деятельностью?",
|
||||
"Через какой срок удаляются электронные документы, если их не открывали, и можно ли их восстановить?",
|
||||
"Кому разрешен доступ к сетевым ресурсам Soft/Distr?",
|
||||
"Какие правила хранения и удаления установлены для медиафайлов в сетевой папке 'СВК'?",
|
||||
"Разрешено ли самостоятельно предоставлять общий доступ к папкам на своем рабочем компьютере?",
|
||||
"Что имеют право сделать сотрудники ДИТ или СИБ, если файл несет угрозу ресурсам?"
|
||||
]
|
||||
|
||||
for q in questions:
|
||||
print(f"❓ Вопрос: {q}")
|
||||
try:
|
||||
response = rag_chain.invoke({'input': q})
|
||||
print(f"🤖 Ответ: {response['answer']}\n")
|
||||
|
||||
# Распечатка источников (полезно для отладки)
|
||||
print("🔍 Найденные источники:")
|
||||
for i, doc in enumerate(response['context']):
|
||||
source_name = doc.metadata.get('source', 'Неизвестный источник').split('/')[-1] # Берем только имя файла
|
||||
print(f" [{i+1}] Файл: {source_name}")
|
||||
print("\n" + "-" * 50 + "\n")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при запросе к серверу llama.cpp: {e}")
|
||||
print("-" * 50)
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Адрес вашего сервера с поднятым Docker-контейнером Unstructured
|
||||
API_URL = "http://172.16.80.250:9000/general/v0/general"
|
||||
|
||||
def main():
|
||||
# 1. Ищем все файлы .pdf и .docx в текущей директории
|
||||
target_files = [f for f in os.listdir('.') if f.lower().endswith(('.pdf', '.docx'))]
|
||||
target_files.sort() # Для порядка
|
||||
|
||||
if not target_files:
|
||||
print("❌ В текущей папке не найдено ни одного PDF или DOCX файла.")
|
||||
return
|
||||
|
||||
print(f"📂 Найдено файлов для обработки: {len(target_files)}")
|
||||
|
||||
# 2. Проходимся по всем найденным файлам
|
||||
for target_file in target_files:
|
||||
print(f"\n📄 Обрабатывается файл: {target_file}")
|
||||
print(f"⏳ Отправляем на сервер {API_URL}... (это может занять время)")
|
||||
|
||||
# Настройки запроса (параметры PDF просто игнорируются сервером для DOCX)
|
||||
payload_data = {
|
||||
"strategy": "hi_res",
|
||||
"languages": ["rus"],
|
||||
"pdf_infer_table_structure": "true"
|
||||
}
|
||||
|
||||
# Определяем правильный MIME-тип для файла
|
||||
ext = target_file.lower().split('.')[-1]
|
||||
if ext == 'pdf':
|
||||
mime_type = "application/pdf"
|
||||
else:
|
||||
mime_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
|
||||
# 3. Открываем файл и делаем POST-запрос
|
||||
try:
|
||||
with open(target_file, "rb") as f:
|
||||
files = {
|
||||
"files": (target_file, f, mime_type)
|
||||
}
|
||||
|
||||
# Таймаут 300 секунд
|
||||
response = requests.post(API_URL, files=files, data=payload_data, timeout=300)
|
||||
response.raise_for_status()
|
||||
|
||||
# 4. Получаем JSON и сохраняем его в файл
|
||||
parsed_data = response.json()
|
||||
output_filename = f"{os.path.splitext(target_file)[0]}_parsed.json"
|
||||
|
||||
with open(output_filename, "w", encoding="utf-8") as out_f:
|
||||
json.dump(parsed_data, out_f, ensure_ascii=False, indent=4)
|
||||
|
||||
print(f"✅ Готово! Результат успешно сохранен в файл: {output_filename}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f"❌ Ошибка соединения! Проверьте доступность {API_URL}")
|
||||
except requests.exceptions.Timeout:
|
||||
print("❌ Сервер слишком долго не отвечал.")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"❌ Сервер вернул ошибку: {e}")
|
||||
if response.text:
|
||||
print(f"Детали ошибки от сервера: {response.text}")
|
||||
except Exception as e:
|
||||
print(f"❌ Произошла непредвиденная ошибка при обработке {target_file}: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,650 @@
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
import hashlib
|
||||
import requests
|
||||
|
||||
from glob import glob
|
||||
from datetime import datetime
|
||||
|
||||
# Импорт SentenceTransformer УДАЛЕН для разгрузки ОЗУ сервера
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import (
|
||||
Distance,
|
||||
VectorParams,
|
||||
PointStruct,
|
||||
PayloadSchemaType,
|
||||
Filter,
|
||||
FieldCondition,
|
||||
MatchValue
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# ИМПОРТ ЦЕНТРАЛЬНОГО КОНФИГА БОТА
|
||||
# =========================================================
|
||||
# Добавляем корневую папку бота в sys.path, чтобы скрипт увидел модуль config,
|
||||
# даже если скрипт запускается из подпапки.
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import config.config as config
|
||||
|
||||
# =========================================================
|
||||
# CONFIG (Берем данные из центрального config.py)
|
||||
# =========================================================
|
||||
DOCS_DIR = config.SEARCH_DOCS_PATH
|
||||
QDRANT_HOST = config.QDRANT_HOST
|
||||
QDRANT_PORT = config.QDRANT_PORT
|
||||
COLLECTION_NAME = config.QDRANT_COLLECTION
|
||||
|
||||
# Формируем путь к реестру внутри папки логов, указанной в конфиге
|
||||
REGISTRY_PATH = os.path.join(config.LOGS_PATH, "indexing_registry.json")
|
||||
|
||||
# =========================================================
|
||||
# ЛОКАЛЬНЫЕ НАСТРОЙКИ ИНДЕКСАТОРА
|
||||
# =========================================================
|
||||
UNSTRUCTURED_API_URL = "http://172.16.80.250:9000/general/v0/general"
|
||||
BATCH_SIZE = 32
|
||||
RECREATE_COLLECTION = False
|
||||
MIN_TEXT_LENGTH = 25
|
||||
|
||||
# =========================================================
|
||||
# INIT
|
||||
# =========================================================
|
||||
|
||||
# Локальная инициализация нейросети убрана. Работаем через API.
|
||||
|
||||
print("🔄 Подключение к Qdrant...")
|
||||
|
||||
client = QdrantClient(
|
||||
host=QDRANT_HOST,
|
||||
port=QDRANT_PORT
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# REGISTRY & HASHING
|
||||
# =========================================================
|
||||
|
||||
def get_file_hash(filepath):
|
||||
"""Вычисляет MD5 хэш физического файла"""
|
||||
hasher = hashlib.md5()
|
||||
with open(filepath, 'rb') as f:
|
||||
buf = f.read()
|
||||
hasher.update(buf)
|
||||
return hasher.hexdigest()
|
||||
|
||||
def load_registry():
|
||||
"""Загружает текущее состояние проиндексированных файлов"""
|
||||
if os.path.exists(REGISTRY_PATH):
|
||||
with open(REGISTRY_PATH, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_registry(registry):
|
||||
"""Сохраняет реестр на диск"""
|
||||
os.makedirs(os.path.dirname(REGISTRY_PATH), exist_ok=True)
|
||||
with open(REGISTRY_PATH, 'w', encoding='utf-8') as f:
|
||||
json.dump(registry, f, ensure_ascii=False, indent=4)
|
||||
|
||||
# =========================================================
|
||||
# HELPERS
|
||||
# =========================================================
|
||||
|
||||
def sha256_text(text):
|
||||
return hashlib.sha256(
|
||||
text.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
def clean_text(text):
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = re.sub(
|
||||
r"\s+",
|
||||
" ",
|
||||
text
|
||||
)
|
||||
|
||||
return text.strip()
|
||||
|
||||
def is_page_number(text):
|
||||
return bool(
|
||||
re.match(
|
||||
r"^Стр\.\s*\d+\s*из\s*\d+$",
|
||||
text
|
||||
)
|
||||
)
|
||||
|
||||
def is_header_noise(text):
|
||||
patterns = [
|
||||
"АО «ХК «Сибцем»",
|
||||
"Тип документа:",
|
||||
"Ведущее подразделение:",
|
||||
"Дата утверждения:",
|
||||
"Редакция 1",
|
||||
"Оглавление"
|
||||
]
|
||||
|
||||
return any(
|
||||
p in text
|
||||
for p in patterns
|
||||
)
|
||||
|
||||
def is_junk(item, text):
|
||||
if not text:
|
||||
return True
|
||||
|
||||
if len(text) < MIN_TEXT_LENGTH:
|
||||
return True
|
||||
|
||||
if item["type"] in [
|
||||
"Header",
|
||||
"Footer",
|
||||
"Image"
|
||||
]:
|
||||
return True
|
||||
|
||||
if is_page_number(text):
|
||||
return True
|
||||
|
||||
if is_header_noise(text):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# =========================================================
|
||||
# TABLE PARSER
|
||||
# =========================================================
|
||||
|
||||
def html_table_to_text(html):
|
||||
html = re.sub(
|
||||
r"\n+",
|
||||
" ",
|
||||
html
|
||||
)
|
||||
|
||||
rows = re.findall(
|
||||
r"<tr.*?>(.*?)</tr>",
|
||||
html,
|
||||
re.I | re.S
|
||||
)
|
||||
|
||||
parsed_rows = []
|
||||
|
||||
for row in rows:
|
||||
cells = re.findall(
|
||||
r"<t[hd].*?>(.*?)</t[hd]>",
|
||||
row,
|
||||
re.I | re.S
|
||||
)
|
||||
|
||||
clean_cells = []
|
||||
|
||||
for cell in cells:
|
||||
box = re.sub(
|
||||
r"<[^>]+>",
|
||||
" ",
|
||||
cell
|
||||
)
|
||||
box = clean_text(box)
|
||||
clean_cells.append(box)
|
||||
|
||||
if clean_cells:
|
||||
parsed_rows.append(
|
||||
clean_cells
|
||||
)
|
||||
|
||||
if not parsed_rows:
|
||||
return ""
|
||||
|
||||
headers = parsed_rows[0]
|
||||
|
||||
result_lines = []
|
||||
|
||||
for row_idx, row in enumerate(parsed_rows[1:], 1):
|
||||
result_lines.append(
|
||||
f"Запись {row_idx}:"
|
||||
)
|
||||
|
||||
for col_idx, cell in enumerate(row):
|
||||
if not cell:
|
||||
continue
|
||||
|
||||
if col_idx < len(headers):
|
||||
header = headers[col_idx]
|
||||
else:
|
||||
header = f"Колонка {col_idx+1}"
|
||||
|
||||
result_lines.append(
|
||||
f"{header}: {cell}"
|
||||
)
|
||||
|
||||
result_lines.append("")
|
||||
|
||||
return "\n".join(result_lines).strip()
|
||||
|
||||
# =========================================================
|
||||
# MERGE FRAGMENTS
|
||||
# =========================================================
|
||||
|
||||
def merge_fragments(elements):
|
||||
merged = []
|
||||
buffer = []
|
||||
buffer_page = None
|
||||
buffer_section = None
|
||||
|
||||
def flush_buffer():
|
||||
nonlocal buffer
|
||||
|
||||
if not buffer:
|
||||
return
|
||||
|
||||
text = clean_text(
|
||||
" ".join(buffer)
|
||||
)
|
||||
|
||||
if text:
|
||||
merged.append({
|
||||
"type": "MergedText",
|
||||
"text": text,
|
||||
"page_number": buffer_page,
|
||||
"section_title": buffer_section
|
||||
})
|
||||
|
||||
buffer = []
|
||||
|
||||
for el in elements:
|
||||
text = clean_text(
|
||||
el["text"]
|
||||
)
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
el_type = el["type"]
|
||||
page = el["metadata"].get("page_number", 0)
|
||||
section = el.get("section_title", "")
|
||||
|
||||
# =========================================
|
||||
# TABLE
|
||||
# =========================================
|
||||
if el_type == "Table":
|
||||
flush_buffer()
|
||||
|
||||
merged.append({
|
||||
"type": "Table",
|
||||
"text": text,
|
||||
"html": el["metadata"].get("text_as_html", ""),
|
||||
"page_number": page,
|
||||
"section_title": section
|
||||
})
|
||||
continue
|
||||
|
||||
# =========================================
|
||||
# SHORT TEXT
|
||||
# =========================================
|
||||
if el_type in [
|
||||
"NarrativeText",
|
||||
"ListItem",
|
||||
"UncategorizedText"
|
||||
]:
|
||||
if len(text) < 80:
|
||||
buffer.append(text)
|
||||
buffer_page = page
|
||||
buffer_section = section
|
||||
else:
|
||||
flush_buffer()
|
||||
merged.append({
|
||||
"type": "Text",
|
||||
"text": text,
|
||||
"page_number": page,
|
||||
"section_title": section
|
||||
})
|
||||
else:
|
||||
flush_buffer()
|
||||
|
||||
flush_buffer()
|
||||
|
||||
return merged
|
||||
|
||||
# =========================================================
|
||||
# QDRANT INIT & DELETE
|
||||
# =========================================================
|
||||
|
||||
def init_collection():
|
||||
collections = [
|
||||
c.name
|
||||
for c in client.get_collections().collections
|
||||
]
|
||||
|
||||
if (
|
||||
RECREATE_COLLECTION
|
||||
and COLLECTION_NAME in collections
|
||||
):
|
||||
print("🗑 Удаление коллекции...")
|
||||
client.delete_collection(COLLECTION_NAME)
|
||||
collections = [
|
||||
c.name
|
||||
for c in client.get_collections().collections
|
||||
]
|
||||
|
||||
if COLLECTION_NAME not in collections:
|
||||
print("⚙️ Создание коллекции...")
|
||||
client.create_collection(
|
||||
collection_name=COLLECTION_NAME,
|
||||
vectors_config=VectorParams(
|
||||
size=1024,
|
||||
distance=Distance.COSINE
|
||||
)
|
||||
)
|
||||
|
||||
indexes = [
|
||||
("metadata.filename", PayloadSchemaType.KEYWORD),
|
||||
("metadata.section_title", PayloadSchemaType.KEYWORD),
|
||||
("metadata.page_number", PayloadSchemaType.INTEGER),
|
||||
("metadata.content_type", PayloadSchemaType.KEYWORD)
|
||||
]
|
||||
|
||||
for field_name, field_schema in indexes:
|
||||
client.create_payload_index(
|
||||
collection_name=COLLECTION_NAME,
|
||||
field_name=field_name,
|
||||
field_schema=field_schema
|
||||
)
|
||||
|
||||
def delete_file_from_qdrant(filename):
|
||||
"""Удаляет все векторы, привязанные к конкретному файлу"""
|
||||
client.delete(
|
||||
collection_name=COLLECTION_NAME,
|
||||
points_selector=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="metadata.filename",
|
||||
match=MatchValue(value=filename)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
print(f" 🗑 Удалены старые данные файла {filename} из базы.")
|
||||
|
||||
# =========================================================
|
||||
# PARSE DOCUMENT
|
||||
# =========================================================
|
||||
|
||||
def parse_document(file_path):
|
||||
filename = os.path.basename(
|
||||
file_path
|
||||
)
|
||||
|
||||
print(f"\n📄 Парсинг: {filename}")
|
||||
|
||||
payload_data = {
|
||||
"strategy": "hi_res",
|
||||
"languages": ["rus"],
|
||||
"pdf_infer_table_structure": "true"
|
||||
}
|
||||
|
||||
ext = filename.lower().split(".")[-1]
|
||||
|
||||
if ext == "pdf":
|
||||
mime_type = "application/pdf"
|
||||
else:
|
||||
mime_type = (
|
||||
"application/vnd.openxmlformats-officedocument."
|
||||
"wordprocessingml.document"
|
||||
)
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
files = {
|
||||
"files": (
|
||||
filename,
|
||||
f,
|
||||
mime_type
|
||||
)
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
UNSTRUCTURED_API_URL,
|
||||
files=files,
|
||||
data=payload_data,
|
||||
timeout=900
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json()
|
||||
|
||||
# =========================================================
|
||||
# INDEX DOCUMENT
|
||||
# =========================================================
|
||||
|
||||
def index_document(file_path, data):
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
cleaned = []
|
||||
current_title = "Общая информация"
|
||||
|
||||
# =====================================================
|
||||
# CLEAN
|
||||
# =====================================================
|
||||
for item in data:
|
||||
raw_text = item.get("text", "")
|
||||
text = clean_text(raw_text)
|
||||
|
||||
if is_junk(item, text):
|
||||
continue
|
||||
|
||||
if item["type"] == "Title":
|
||||
current_title = text
|
||||
continue
|
||||
|
||||
item["section_title"] = current_title
|
||||
cleaned.append(item)
|
||||
|
||||
# =====================================================
|
||||
# MERGE
|
||||
# =====================================================
|
||||
merged = merge_fragments(cleaned)
|
||||
points = []
|
||||
|
||||
# =====================================================
|
||||
# BUILD CHUNKS
|
||||
# =====================================================
|
||||
for idx, chunk in enumerate(merged):
|
||||
chunk_type = chunk["type"]
|
||||
section_title = chunk["section_title"]
|
||||
page_number = chunk["page_number"]
|
||||
|
||||
# =================================================
|
||||
# TABLE / TEXT
|
||||
# =================================================
|
||||
if chunk_type == "Table":
|
||||
table_text = html_table_to_text(chunk["html"])
|
||||
content = (
|
||||
f"Раздел: {section_title}\n"
|
||||
f"Тип: Таблица\n\n"
|
||||
f"{table_text}"
|
||||
)
|
||||
else:
|
||||
content = (
|
||||
f"Раздел: {section_title}\n\n"
|
||||
f"{chunk['text']}"
|
||||
)
|
||||
|
||||
content = clean_text(content)
|
||||
|
||||
if len(content) < 40:
|
||||
continue
|
||||
|
||||
# =================================================
|
||||
# VECTOR (Переведено на центральный Embedding API)
|
||||
# =================================================
|
||||
try:
|
||||
payload_api = {
|
||||
"model": config.EMBEDDING_MODEL_NAME, # Отправляем "bge-m3"
|
||||
"input_type": "passage", # Для сохранения документов используем тип passage
|
||||
"texts": [content]
|
||||
}
|
||||
headers_api = {"Content-Type": "application/json"}
|
||||
if hasattr(config, 'EMBEDDING_API_TOKEN') and config.EMBEDDING_API_TOKEN:
|
||||
headers_api["X-Embedding-Token"] = config.EMBEDDING_API_TOKEN
|
||||
|
||||
api_resp = requests.post(config.EMBEDDING_API_URL, json=payload_api, headers=headers_api)
|
||||
api_resp.raise_for_status()
|
||||
vector = api_resp.json()["embeddings"][0]
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка векторизации чанка {idx} через API: {e}")
|
||||
continue # Пропускаем чанк, если центральный сервис недоступен
|
||||
|
||||
chunk_hash = sha256_text(
|
||||
filename + str(idx) + content
|
||||
)
|
||||
|
||||
point_id = str(
|
||||
uuid.uuid5(
|
||||
uuid.NAMESPACE_DNS,
|
||||
chunk_hash
|
||||
)
|
||||
)
|
||||
|
||||
payload = {
|
||||
"page_content": content,
|
||||
"metadata": {
|
||||
"filename": filename,
|
||||
"source": file_path,
|
||||
"chunk_index": idx,
|
||||
"section_title": section_title,
|
||||
"page_number": page_number,
|
||||
"content_type": chunk_type,
|
||||
"chunk_hash": chunk_hash
|
||||
}
|
||||
}
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=point_id,
|
||||
vector=vector,
|
||||
payload=payload
|
||||
)
|
||||
)
|
||||
|
||||
# =================================================
|
||||
# UPSERT BATCH
|
||||
# =================================================
|
||||
if len(points) >= BATCH_SIZE:
|
||||
client.upsert(
|
||||
collection_name=COLLECTION_NAME,
|
||||
points=points
|
||||
)
|
||||
print(f" ⬆️ batch={len(points)}")
|
||||
points = []
|
||||
|
||||
# =====================================================
|
||||
# FINAL UPSERT
|
||||
# =====================================================
|
||||
if points:
|
||||
client.upsert(
|
||||
collection_name=COLLECTION_NAME,
|
||||
points=points
|
||||
)
|
||||
|
||||
print(f" ✅ Загружено chunks: {len(merged)}")
|
||||
return len(merged)
|
||||
|
||||
# =========================================================
|
||||
# MAIN
|
||||
# =========================================================
|
||||
|
||||
def main():
|
||||
init_collection()
|
||||
|
||||
target_files = []
|
||||
|
||||
for ext in ["*.pdf", "*.docx"]:
|
||||
target_files.extend(
|
||||
glob(
|
||||
os.path.join(DOCS_DIR, "**", ext),
|
||||
recursive=True
|
||||
)
|
||||
)
|
||||
|
||||
# ФИЛЬТРАЦИЯ СКРЫТЫХ ВРЕМЕННЫХ ФАЙЛОВ WORD (~$)
|
||||
target_files = [f for f in target_files if not os.path.basename(f).startswith("~$")]
|
||||
|
||||
target_files.sort()
|
||||
|
||||
if not target_files:
|
||||
print("❌ PDF/DOCX файлы не найдены")
|
||||
return
|
||||
|
||||
print(f"📂 Найдено файлов для индексации: {len(target_files)}")
|
||||
print(f"📡 Использование удаленной векторизации: {config.EMBEDDING_API_URL} ({config.EMBEDDING_MODEL_NAME})")
|
||||
|
||||
# Загружаем реестр
|
||||
registry = load_registry()
|
||||
|
||||
# =====================================================
|
||||
# PROCESS
|
||||
# =====================================================
|
||||
for file_path in target_files:
|
||||
filename = os.path.basename(file_path)
|
||||
current_hash = get_file_hash(file_path)
|
||||
|
||||
file_info = registry.get(filename)
|
||||
|
||||
# Проверка: Файл уже есть, хэш совпадает, статус был успешным
|
||||
if file_info and file_info.get("hash") == current_hash and file_info.get("status") == "SUCCESS":
|
||||
print(f"\n⏭️ Пропуск (не изменился): {filename}")
|
||||
continue
|
||||
|
||||
print(f"\n🔄 Обработка: {filename}")
|
||||
|
||||
# Если файл есть в реестре со статусом SUCCESS, но хэш другой (файл обновили)
|
||||
if file_info and file_info.get("status") == "SUCCESS":
|
||||
print(f" ⚠️ Обнаружена новая версия файла! Обновляем индекс...")
|
||||
delete_file_from_qdrant(filename)
|
||||
|
||||
try:
|
||||
data = parse_document(file_path)
|
||||
chunks_count = index_document(file_path, data)
|
||||
|
||||
# Запись успешного результата
|
||||
registry[filename] = {
|
||||
"hash": current_hash,
|
||||
"status": "SUCCESS",
|
||||
"chunks": chunks_count,
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
save_registry(registry)
|
||||
|
||||
# ПАУЗА ПРИ НЕДОСТУПНОСТИ СЕРВЕРА ПАРСИНГА
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(f" ⚠️ Сервер парсинга документов недоступен. Ждем 60 секунд...")
|
||||
time.sleep(60)
|
||||
registry[filename] = {
|
||||
"hash": current_hash,
|
||||
"status": "ERROR",
|
||||
"error_details": "Connection refused",
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
save_registry(registry)
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Ошибка обработки файла {filename}: {e}")
|
||||
|
||||
# Запись ошибки в реестр
|
||||
registry[filename] = {
|
||||
"hash": current_hash,
|
||||
"status": "ERROR",
|
||||
"error_details": str(e),
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
save_registry(registry)
|
||||
|
||||
print("\n🎉 ГОТОВО. База Qdrant полностью синхронизирована через API.")
|
||||
|
||||
# =========================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,54 @@
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
|
||||
# Импортируем только учетные данные для подключения
|
||||
from config.config import TC_SERVER, TC_LOGIN, TC_PASSWORD
|
||||
from trueconf import Bot
|
||||
|
||||
async def send_proactive_message(target_user: str, message_text: str):
|
||||
# Создаем объект бота
|
||||
bot = Bot.from_credentials(
|
||||
server=TC_SERVER,
|
||||
username=TC_LOGIN,
|
||||
password=TC_PASSWORD,
|
||||
verify_ssl=False
|
||||
)
|
||||
|
||||
# Запускаем фоновый цикл для поднятия WebSocket соединения
|
||||
bot_task = asyncio.create_task(bot.run())
|
||||
|
||||
print("🔄 Подключение к серверу (ожидание 3 секунды)...")
|
||||
await asyncio.sleep(3) # Даем боту время поднять коннект
|
||||
|
||||
try:
|
||||
print(f"✉️ Отправка сообщения пользователю {target_user}...")
|
||||
|
||||
# Отправляем сообщение
|
||||
await bot.send_message(target_user, text=message_text, parse_mode="html")
|
||||
|
||||
print("✅ Сообщение успешно отправлено!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при отправке: {e}")
|
||||
|
||||
finally:
|
||||
print("🛑 Завершение скрипта...")
|
||||
# Мгновенно убиваем процесс, чтобы не зависнуть на WebSockets
|
||||
os._exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Чтение аргументов из консоли
|
||||
if len(sys.argv) >= 3:
|
||||
user = sys.argv[1]
|
||||
text = " ".join(sys.argv[2:])
|
||||
else:
|
||||
# Для ручного теста (без аргументов)
|
||||
user = "ds.krivochenko@tcs.sibcem.ru" # Убедитесь, что чат с ботом уже инициирован!
|
||||
text = "🔔 <b>Уведомление:</b> Тестовое сообщение."
|
||||
|
||||
try:
|
||||
asyncio.run(send_proactive_message(user, text))
|
||||
except KeyboardInterrupt:
|
||||
os._exit(0)
|
||||
@@ -0,0 +1,396 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import httpx
|
||||
import re
|
||||
import smtplib
|
||||
import urllib3
|
||||
import importlib.util
|
||||
from email.message import EmailMessage
|
||||
from ldap3 import Server, Connection, ALL
|
||||
from trueconf import Router, Message
|
||||
|
||||
# --- SD WORKFLOW LOGGING SETUP ---
|
||||
log_dir = os.path.join(os.path.dirname(__file__), 'logs')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
sd_workflow_logger = logging.getLogger('sd_workflow')
|
||||
sd_workflow_logger.setLevel(logging.INFO)
|
||||
if not sd_workflow_logger.handlers:
|
||||
handler = RotatingFileHandler(os.path.join(log_dir, 'sd_workflow.log'), maxBytes=10*1024*1024, backupCount=5)
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
sd_workflow_logger.addHandler(handler)
|
||||
# ----------------------------------
|
||||
|
||||
# Абсолютный импорт конфигурации
|
||||
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
|
||||
custom_config = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(custom_config)
|
||||
globals().update(vars(custom_config))
|
||||
|
||||
from utils import states
|
||||
from utils.menu import MENU_TEXT
|
||||
from utils.texts import (
|
||||
EMOJI_DIGITS,
|
||||
SD_MAIN_MENU_TEXT,
|
||||
SD_UNKNOWN_CMD_TEXT,
|
||||
SD_TEXT_REQUIRED,
|
||||
SD_CREATING,
|
||||
sd_ticket_created,
|
||||
sd_ticket_create_error,
|
||||
sd_system_error,
|
||||
sd_uploading_file,
|
||||
sd_file_attached,
|
||||
sd_file_upload_error,
|
||||
)
|
||||
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
|
||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||
from utils.stats_logger import log_menu_stats
|
||||
|
||||
# Таймаут ожидания вложений после текста (секунды)
|
||||
ATTACHMENT_WAIT_TIMEOUT = 2.0
|
||||
|
||||
SD_DISPATCHER_SYSTEM_PROMPT = (
|
||||
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
|
||||
"ПРАВИЛА:\n"
|
||||
"1. Назови проблему и оборудование/программу (например: 'Самопроизвольное выключение ПК', 'Неисправность принтера', 'Сбой авторизации в почте').\n"
|
||||
"2. Отвечать на английском языке ЗАПРЕЩЕНО.\n"
|
||||
"3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n"
|
||||
"4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n"
|
||||
"5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ.")
|
||||
|
||||
# Глушим системные предупреждения
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
sd_sessions = {}
|
||||
|
||||
# ================================================
|
||||
# БИЗНЕС-ЛОГИКА
|
||||
# ================================================
|
||||
def get_ad_user_sync(login: str):
|
||||
try:
|
||||
server = Server(AD_SERVER, get_info=ALL)
|
||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
||||
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["displayName", "mail", "l", "userAccountControl"])
|
||||
if conn.entries:
|
||||
user = conn.entries[0]
|
||||
uac = user.userAccountControl.value if 'userAccountControl' in user else 0
|
||||
return {
|
||||
"name": user.displayName.value if 'displayName' in user else login,
|
||||
"mail": user.mail.value if 'mail' in user else None,
|
||||
"city": user.l.value if 'l' in user else "Кемерово",
|
||||
"is_disabled": bool(uac & 2)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка LDAP: {e}")
|
||||
return None
|
||||
|
||||
async def generate_smart_subject(text: str) -> str:
|
||||
import re, traceback, importlib.util, httpx
|
||||
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
|
||||
custom_config = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(custom_config)
|
||||
OLLAMA_SUBJECT_URL = custom_config.OLLAMA_SUBJECT_URL
|
||||
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
|
||||
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
|
||||
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
|
||||
|
||||
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg}
|
||||
]
|
||||
|
||||
try:
|
||||
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
|
||||
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
|
||||
endpoint = OLLAMA_SUBJECT_URL.replace("/api/generate", "/v1/chat/completions")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(endpoint, json=payload, timeout=120.0)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if "choices" in data and len(data["choices"]) > 0:
|
||||
res = data["choices"][0]["message"]["content"].strip(" .'\"`")
|
||||
res = re.sub(r'^(тема|заголовок|subject):\s*', '', res, flags=re.IGNORECASE).strip()
|
||||
if res and not has_chinese(res) and not any(m in res.upper() for m in ["СПАМ", "НЕ ПОНЯЛ", "Я ИСКУССТВЕННЫЙ", "Я МОДЕЛЬ"]):
|
||||
return res[0].upper() + res[1:] if len(res) > 0 else "Заявка из КЛЕВЕР"
|
||||
else:
|
||||
sd_workflow_logger.info(f"📝 [Subject Gen Error] Validation Failed: Received '{res}'")
|
||||
else:
|
||||
sd_workflow_logger.info(f"📝 [Subject Gen Error] Empty choices in LLM response")
|
||||
else:
|
||||
sd_workflow_logger.info(f"📝 [Subject Gen Error] API Error: Status {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
sd_workflow_logger.error(f"📝 [Subject Gen Error] Exception: {e}\n{traceback.format_exc()}")
|
||||
return "Заявка из КЛЕВЕР"
|
||||
|
||||
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str):
|
||||
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
|
||||
endpoint = f"{SD_URL}/api/v3/requests"
|
||||
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
|
||||
async def _send_req(email, current_city):
|
||||
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}}
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
|
||||
resp = await _send_req(requester_email, city)
|
||||
data = resp.json() if resp.status_code in [200, 201] else {}
|
||||
if data.get("response_status", {}).get("status_code") != 2000:
|
||||
resp = await _send_req(requester_email, "Кемерово")
|
||||
data = resp.json() if resp.status_code in [200, 201] else {}
|
||||
if data.get("response_status", {}).get("status_code") == 2000:
|
||||
return data.get("request", {}).get("id")
|
||||
return None
|
||||
|
||||
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
|
||||
import io
|
||||
downloaded_path = None
|
||||
try:
|
||||
if isinstance(filename, bytes) or (isinstance(filename, str) and filename.startswith("b'")):
|
||||
safe_filename = f"attachment_{ticket_id}.dat"
|
||||
else:
|
||||
safe_filename = str(filename)
|
||||
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
|
||||
if isinstance(downloaded_file, bytes):
|
||||
file_obj = io.BytesIO(downloaded_file)
|
||||
downloaded_path = None
|
||||
else:
|
||||
downloaded_path = str(downloaded_file)
|
||||
file_obj = open(downloaded_path, 'rb')
|
||||
try:
|
||||
upload_url = f"{SD_URL}/api/v3/requests/{ticket_id}/upload"
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
attach_resp = await client.put(
|
||||
upload_url,
|
||||
headers={"authtoken": SD_TOKEN},
|
||||
files={'input_file': (safe_filename, file_obj, 'application/octet-stream')},
|
||||
timeout=30.0
|
||||
)
|
||||
return attach_resp.status_code in [200, 201]
|
||||
finally:
|
||||
if not isinstance(downloaded_file, bytes):
|
||||
file_obj.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки файла {filename}: {e}")
|
||||
return False
|
||||
finally:
|
||||
if downloaded_path and os.path.exists(downloaded_path):
|
||||
os.remove(downloaded_path)
|
||||
|
||||
def _extract_text_from_content(content):
|
||||
"""Извлечение текста из msg.content."""
|
||||
if isinstance(content, dict):
|
||||
return content.get("text", "").strip()
|
||||
elif hasattr(content, "text"):
|
||||
return str(content.text).strip()
|
||||
return ""
|
||||
|
||||
def _extract_attachments_from_content(content, is_attachment_type=False):
|
||||
"""Извлечение file_id и file_name из msg.content."""
|
||||
attachments = []
|
||||
if isinstance(content, dict):
|
||||
atts = content.get("attachments") or content.get("files")
|
||||
single_fid = content.get("file_id")
|
||||
single_fname = content.get("file_name")
|
||||
if atts:
|
||||
if isinstance(atts, list):
|
||||
for a in atts:
|
||||
if isinstance(a, dict):
|
||||
fid = a.get("file_id") or a.get("id")
|
||||
fname = a.get("file_name") or a.get("name", "attachment")
|
||||
if fid: attachments.append({"file_id": fid, "file_name": fname})
|
||||
elif isinstance(a, str):
|
||||
attachments.append({"file_id": a, "file_name": "attachment"})
|
||||
elif isinstance(atts, dict):
|
||||
fid = atts.get("file_id") or atts.get("id")
|
||||
fname = atts.get("file_name") or atts.get("name", "attachment")
|
||||
if fid: attachments.append({"file_id": fid, "file_name": fname})
|
||||
if single_fid:
|
||||
attachments.append({"file_id": single_fid, "file_name": single_fname or "attachment"})
|
||||
elif is_attachment_type:
|
||||
if hasattr(content, "file_id"):
|
||||
fid = content.file_id
|
||||
fname = getattr(content, "file_name", "attachment")
|
||||
attachments.append({"file_id": fid, "file_name": fname})
|
||||
return attachments
|
||||
|
||||
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
|
||||
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
|
||||
session["step"] = "creating_ticket"
|
||||
try:
|
||||
ad_user = await asyncio.to_thread(get_ad_user_sync, login)
|
||||
sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}")
|
||||
sender_email = ad_user.get("mail") if ad_user else DEFAULT_REQUESTER
|
||||
city = ad_user.get("city") if ad_user else "Кемерово"
|
||||
subject = await generate_smart_subject(msg_text)
|
||||
sd_workflow_logger.info(f"📝 [Subject Gen] Text: {msg_text[:50]}... -> Subject: {subject}")
|
||||
|
||||
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city)
|
||||
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
|
||||
|
||||
if ticket_id:
|
||||
session["ticket_id"] = ticket_id
|
||||
|
||||
# 1. Загрузка основных вложений (накопленных до и во время таймаута)
|
||||
queued_files = list(session.get("files_queue", []))
|
||||
if queued_files:
|
||||
sd_workflow_logger.info(f"📎 [Upload] Uploading {len(queued_files)} file(s) to ticket #{ticket_id}")
|
||||
for f in queued_files:
|
||||
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
||||
sd_workflow_logger.info(f"✅ [Upload] All queued file(s) uploaded")
|
||||
|
||||
# 2. Загрузка вложений, прилетевших во время выполнения API-запросов (из "черной дыры")
|
||||
post_files = list(session.get("post_create_queue", []))
|
||||
if post_files:
|
||||
sd_workflow_logger.info(f"📎 [Upload Extra] Uploading {len(post_files)} late file(s) to ticket #{ticket_id}")
|
||||
for f in post_files:
|
||||
await process_and_upload_file(f['file_id'], f['file_name'], ticket_id)
|
||||
|
||||
session["files_queue"] = []
|
||||
session["post_create_queue"] = []
|
||||
session["step"] = "ticket_created"
|
||||
|
||||
await msg.answer(
|
||||
sd_ticket_created(ticket_id, subject, msg_text),
|
||||
parse_mode="html"
|
||||
)
|
||||
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
|
||||
else:
|
||||
session["step"] = "need_text"
|
||||
await msg.answer(sd_ticket_create_error(), parse_mode="html")
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка: {e}")
|
||||
sd_workflow_logger.error(f"❌ [SD Error] {e}")
|
||||
session["step"] = "need_text"
|
||||
await msg.answer(sd_system_error(), parse_mode="html")
|
||||
|
||||
async def _wait_for_attachments_and_create(user_id, session):
|
||||
"""Фоновый таймер с возможностью отмены (Debounce)."""
|
||||
try:
|
||||
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
|
||||
except asyncio.CancelledError:
|
||||
return # Таймер сброшен, прерываем выполнение текущей таски
|
||||
|
||||
if session.get("step") != "waiting_for_attachments":
|
||||
return
|
||||
|
||||
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
|
||||
msg = session.get("msg")
|
||||
login = session.get("login")
|
||||
if msg:
|
||||
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
|
||||
|
||||
@router.message()
|
||||
async def sd_module_handler(msg: Message):
|
||||
user_id = msg.from_user.id
|
||||
sd_workflow_logger.info(f"🚀 [SD Workflow Start] User: {user_id}")
|
||||
if states.get_state(user_id) != "SD_MODE":
|
||||
return
|
||||
|
||||
msg_text = _extract_text_from_content(msg.content)
|
||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||
inline_attachments = _extract_attachments_from_content(msg.content, is_attachment)
|
||||
|
||||
sd_workflow_logger.info(f"🚀 [SD Debug] msg.type={msg.type}, content_type={type(msg.content).__name__}, msg_text='{msg_text}', is_attachment={is_attachment}, inline_attachments={len(inline_attachments)}")
|
||||
|
||||
msg.handled = True
|
||||
login = user_id.split("@")[0] if "@" in user_id else user_id
|
||||
cmd = msg_text.lower()
|
||||
|
||||
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||
if cmd == emoji_num:
|
||||
cmd = raw_num
|
||||
break
|
||||
|
||||
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) ---
|
||||
if cmd in ["0", "9", "/start", "меню"]:
|
||||
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
|
||||
states.clear_state(user_id)
|
||||
sd_sessions.pop(user_id, None)
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
if user_id not in sd_sessions:
|
||||
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
|
||||
session = sd_sessions[user_id]
|
||||
|
||||
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
|
||||
if session["step"] == "need_text":
|
||||
if not msg_text.strip() and not inline_attachments:
|
||||
await msg.answer(SD_UNKNOWN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
# Если только вложение без текста — сохраняем и ждем текст
|
||||
if not msg_text.strip() and inline_attachments:
|
||||
session["files_queue"].extend(inline_attachments)
|
||||
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
|
||||
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
|
||||
return
|
||||
|
||||
# Если пришел текст (с вложениями или без)
|
||||
if inline_attachments:
|
||||
session["files_queue"].extend(inline_attachments)
|
||||
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
|
||||
|
||||
# Настраиваем параметры сессии для ожидания
|
||||
session["step"] = "waiting_for_attachments"
|
||||
session["msg_text"] = msg_text
|
||||
session["msg"] = msg
|
||||
session["user_id"] = user_id
|
||||
session["login"] = login
|
||||
|
||||
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
|
||||
await msg.answer(SD_CREATING, parse_mode="html")
|
||||
|
||||
# Запускаем фоновый таймер с механизмом сброса (Debounce)
|
||||
if "timer_task" in session and not session["timer_task"].done():
|
||||
session["timer_task"].cancel()
|
||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
|
||||
return
|
||||
|
||||
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
|
||||
if session["step"] == "waiting_for_attachments":
|
||||
if inline_attachments:
|
||||
session["files_queue"].extend(inline_attachments)
|
||||
sd_workflow_logger.info(f"📎 [SD] Received {len(inline_attachments)} more files. Total queue: {len(session['files_queue'])}")
|
||||
if msg_text.strip():
|
||||
session["msg_text"] = session["msg_text"] + " " + msg_text.strip()
|
||||
sd_workflow_logger.info(f"📝 [SD] Additional text appended")
|
||||
|
||||
# Перезапускаем таймер (пользователь активен, сдвигаем окно создания вперед)
|
||||
if "timer_task" in session and not session["timer_task"].done():
|
||||
session["timer_task"].cancel()
|
||||
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
|
||||
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
|
||||
return
|
||||
|
||||
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
|
||||
if session["step"] == "creating_ticket":
|
||||
if inline_attachments:
|
||||
if "post_create_queue" not in session:
|
||||
session["post_create_queue"] = []
|
||||
session["post_create_queue"].extend(inline_attachments)
|
||||
sd_workflow_logger.info(f"📥 [SD Hole-Fix] Captured {len(inline_attachments)} file(s) DURING ticket creation API call.")
|
||||
return
|
||||
|
||||
# --- ШАГ 4: ПРИЕМ ДОП. ФАЙЛОВ К УЖЕ СОЗДАННОЙ ЗАЯВКЕ ---
|
||||
if session["step"] == "ticket_created":
|
||||
if inline_attachments:
|
||||
ticket_id = session["ticket_id"]
|
||||
for att in inline_attachments:
|
||||
await msg.answer(sd_uploading_file(ticket_id), parse_mode="html")
|
||||
ok = await process_and_upload_file(att['file_id'], att['file_name'], ticket_id)
|
||||
sd_workflow_logger.info(f"📎 [Late File Upload] Name: {att['file_name']} -> Success: {ok}")
|
||||
if ok:
|
||||
log_menu_stats(user_id, "Service Desk", f"Добавление файла к заявке #{ticket_id}")
|
||||
await msg.answer(sd_file_attached(), parse_mode="html")
|
||||
else:
|
||||
await msg.answer(sd_file_upload_error(), parse_mode="html")
|
||||
elif msg_text.strip():
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
return
|
||||
@@ -0,0 +1,67 @@
|
||||
2026-07-02 18:59:04,145 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 18:59:48,065 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 18:59:49,603 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 18:59:51,983 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 18:59:58,388 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:00:02,571 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:00:03,647 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:00:15,574 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:00:47,620 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:01:06,814 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:01:10,483 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:01:23,003 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:01:23,003 - INFO - 🚀 [SD Debug] msg.type=MessageType.PLAIN_MESSAGE, content_type=TextContent, msg_text='не работает принтер', is_attachment=False, inline_attachments=0
|
||||
2026-07-02 19:01:23,018 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:01:23,018 - INFO - 🚀 [SD Debug] msg.type=MessageType.ATTACHMENT, content_type=AttachmentContent, msg_text='', is_attachment=True, inline_attachments=1
|
||||
2026-07-02 19:01:23,018 - INFO - 📎 [SD] Received 1 more files. Total queue: 1
|
||||
2026-07-02 19:01:23,018 - INFO - 🔄 [SD] Timer reset due to user activity. Waiting another 2.0s
|
||||
2026-07-02 19:01:23,021 - INFO - ⏳ [SD] Timer started: waiting 2.0s for potentially more attachments...
|
||||
2026-07-02 19:01:25,024 - INFO - ⏰ [SD] Timeout reached. Initiating ticket creation for user ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-02 19:01:25,229 - INFO - 👤 [AD Lookup] User: ds.krivochenko -> Found: True
|
||||
2026-07-02 19:01:25,621 - INFO - 📝 [Subject Gen] Payload: {'model': 'gemma-4-E4B-it-Q4_K_M', 'messages': [{'role': 'system', 'content': "Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\nПРАВИЛА:\n1. Назови проблему и оборудование/программу (например: 'Самопроизвольное выключение ПК', 'Неисправность принтера', 'Сбой авторизации в почте').\n2. Отвечать на английском языке ЗАПРЕЩЕНО.\n3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ."}, {'role': 'user', 'content': 'Сформулируй краткую тему для следующей ИТ-заявки:\nне работает принтер'}], 'temperature': 0.0}
|
||||
2026-07-02 19:01:34,653 - INFO - 📝 [Subject Gen] Text: не работает принтер... -> Subject: Не работает принтер
|
||||
2026-07-02 19:01:35,020 - INFO - 🎫 [Ticket Created] ID: 23108
|
||||
2026-07-02 19:01:35,021 - INFO - 📎 [Upload] Uploading 1 file(s) to ticket #23108
|
||||
2026-07-02 19:01:35,328 - INFO - ✅ [Upload] All queued file(s) uploaded
|
||||
2026-07-03 01:33:49,457 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 09:27:27,099 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 14:55:29,676 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 14:55:32,598 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 14:55:44,534 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 14:55:44,534 - INFO - 🚀 [SD Debug] msg.type=MessageType.PLAIN_MESSAGE, content_type=TextContent, msg_text='не работает интернет', is_attachment=False, inline_attachments=0
|
||||
2026-07-03 14:55:44,565 - INFO - ⏳ [SD] Timer started: waiting 2.0s for potentially more attachments...
|
||||
2026-07-03 14:55:46,568 - INFO - ⏰ [SD] Timeout reached. Initiating ticket creation for user ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 14:55:46,820 - INFO - 👤 [AD Lookup] User: ds.krivochenko -> Found: True
|
||||
2026-07-03 14:55:47,278 - INFO - 📝 [Subject Gen] Payload: {'model': 'gemma-4-E4B-it-Q4_K_M', 'messages': [{'role': 'system', 'content': "Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\nПРАВИЛА:\n1. Назови проблему и оборудование/программу (например: 'Самопроизвольное выключение ПК', 'Неисправность принтера', 'Сбой авторизации в почте').\n2. Отвечать на английском языке ЗАПРЕЩЕНО.\n3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ."}, {'role': 'user', 'content': 'Сформулируй краткую тему для следующей ИТ-заявки:\nне работает интернет'}], 'temperature': 0.0}
|
||||
2026-07-03 14:55:55,269 - INFO - 📝 [Subject Gen] Text: не работает интернет... -> Subject: Не работает доступ в интернет
|
||||
2026-07-03 14:55:55,504 - INFO - 🎫 [Ticket Created] ID: 23132
|
||||
2026-07-03 14:55:55,504 - ERROR - ❌ [SD Error] sd_ticket_created() missing 2 required positional arguments: 'subject' and 'description'
|
||||
2026-07-03 15:00:29,654 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 15:00:34,389 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 15:00:34,389 - INFO - 🚀 [SD Debug] msg.type=MessageType.PLAIN_MESSAGE, content_type=TextContent, msg_text='не работает интернет', is_attachment=False, inline_attachments=0
|
||||
2026-07-03 15:00:34,398 - INFO - ⏳ [SD] Timer started: waiting 2.0s for potentially more attachments...
|
||||
2026-07-03 15:00:36,401 - INFO - ⏰ [SD] Timeout reached. Initiating ticket creation for user ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 15:00:36,644 - INFO - 👤 [AD Lookup] User: ds.krivochenko -> Found: True
|
||||
2026-07-03 15:00:37,078 - INFO - 📝 [Subject Gen] Payload: {'model': 'gemma-4-E4B-it-Q4_K_M', 'messages': [{'role': 'system', 'content': "Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\nПРАВИЛА:\n1. Назови проблему и оборудование/программу (например: 'Самопроизвольное выключение ПК', 'Неисправность принтера', 'Сбой авторизации в почте').\n2. Отвечать на английском языке ЗАПРЕЩЕНО.\n3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ."}, {'role': 'user', 'content': 'Сформулируй краткую тему для следующей ИТ-заявки:\nне работает интернет'}], 'temperature': 0.0}
|
||||
2026-07-03 15:00:38,739 - INFO - 📝 [Subject Gen] Text: не работает интернет... -> Subject: Не работает доступ в интернет
|
||||
2026-07-03 15:00:39,151 - INFO - 🎫 [Ticket Created] ID: 23133
|
||||
2026-07-03 15:07:19,766 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 15:07:25,800 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 15:07:25,800 - INFO - 🚀 [SD Debug] msg.type=MessageType.PLAIN_MESSAGE, content_type=TextContent, msg_text='не работает принтер', is_attachment=False, inline_attachments=0
|
||||
2026-07-03 15:07:25,807 - INFO - ⏳ [SD] Timer started: waiting 2.0s for potentially more attachments...
|
||||
2026-07-03 15:07:27,810 - INFO - ⏰ [SD] Timeout reached. Initiating ticket creation for user ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-03 15:07:28,053 - INFO - 👤 [AD Lookup] User: ds.krivochenko -> Found: True
|
||||
2026-07-03 15:07:28,704 - INFO - 📝 [Subject Gen] Payload: {'model': 'gemma-4-E4B-it-Q4_K_M', 'messages': [{'role': 'system', 'content': "Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\nПРАВИЛА:\n1. Назови проблему и оборудование/программу (например: 'Самопроизвольное выключение ПК', 'Неисправность принтера', 'Сбой авторизации в почте').\n2. Отвечать на английском языке ЗАПРЕЩЕНО.\n3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ."}, {'role': 'user', 'content': 'Сформулируй краткую тему для следующей ИТ-заявки:\nне работает принтер'}], 'temperature': 0.0}
|
||||
2026-07-03 15:07:30,662 - INFO - 📝 [Subject Gen] Text: не работает принтер... -> Subject: Не работает принтер
|
||||
2026-07-03 15:07:30,833 - INFO - 🎫 [Ticket Created] ID: 23134
|
||||
2026-07-04 06:25:49,706 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 02:23:16,605 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 02:23:18,888 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 02:23:20,868 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 02:35:11,233 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 02:35:13,170 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 02:35:14,851 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 02:35:20,964 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-08 06:53:15,134 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-13 01:48:17,529 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
2026-07-14 06:36:37,817 - INFO - 🚀 [SD Workflow Start] User: ds.krivochenko@tcs.sibcem.ru
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
# show_stats.py
|
||||
|
||||
import os
|
||||
import csv
|
||||
from collections import defaultdict
|
||||
|
||||
def main():
|
||||
# Динамически определяем корень папки, где лежит сам скрипт show_stats.py
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
log_file = os.path.join(base_dir, "logs", "menu_stats.csv")
|
||||
|
||||
if not os.path.exists(log_file):
|
||||
print(f"❌ Файл статистики еще не создан по пути: {log_file}")
|
||||
print("💡 Сначала перейдите по пунктам меню в боте, чтобы накопились первые логи.")
|
||||
return
|
||||
|
||||
total_actions = 0
|
||||
main_menu_counts = defaultdict(int)
|
||||
sub_menu_counts = defaultdict(lambda: defaultdict(int))
|
||||
cert_counts = defaultdict(lambda: defaultdict(int))
|
||||
|
||||
try:
|
||||
with open(log_file, "r", encoding="utf-8-sig") as f:
|
||||
reader = csv.reader(f, delimiter=";")
|
||||
header = next(reader)
|
||||
|
||||
for row in reader:
|
||||
if len(row) < 4:
|
||||
continue
|
||||
timestamp, user_id, main_menu, sub_menu = row
|
||||
|
||||
main_menu = main_menu.strip()
|
||||
sub_menu = sub_menu.strip()
|
||||
sub_menu_lower = sub_menu.lower()
|
||||
|
||||
# ИСПРАВЛЕНО: Полностью исключаем фоновые логи безопасности 2FA и выходы из статистики кликов
|
||||
if "выход" in sub_menu_lower or "2fa" in sub_menu_lower:
|
||||
continue
|
||||
|
||||
total_actions += 1
|
||||
main_menu_counts[main_menu] += 1
|
||||
|
||||
if sub_menu and sub_menu_lower != "вход":
|
||||
if sub_menu_lower == "справки" or "справк" in sub_menu_lower:
|
||||
parent_item = "Справки (Заказ документов)"
|
||||
|
||||
if sub_menu_lower == "справки":
|
||||
specific_cert = "📂 Открытие главного меню выбора"
|
||||
elif "выбор справки:" in sub_menu_lower:
|
||||
cert_title = sub_menu.split(":", 1)[1].strip()
|
||||
specific_cert = f"👀 Просмотр/Выбор: {cert_title}"
|
||||
elif "заказ справки:" in sub_menu_lower:
|
||||
cert_title = sub_menu.split(":", 1)[1].strip()
|
||||
specific_cert = f"🚀 Оформлен заказ: {cert_title}"
|
||||
else:
|
||||
specific_cert = sub_menu
|
||||
|
||||
sub_menu_counts[main_menu][parent_item] += 1
|
||||
cert_counts[main_menu][specific_cert] += 1
|
||||
else:
|
||||
sub_menu_counts[main_menu][sub_menu] += 1
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при чтении файла логов: {e}")
|
||||
return
|
||||
|
||||
if total_actions == 0:
|
||||
print("📭 Файл статистики пуст после фильтрации выходов и логов 2FA.")
|
||||
return
|
||||
|
||||
print("=" * 70)
|
||||
print(f"📊 СТАТИСТИКА ИСПОЛЬЗОВАНИЯ МЕНЮ БОТА (Всего переходов: {total_actions})")
|
||||
print("=" * 70)
|
||||
|
||||
sorted_main = sorted(main_menu_counts.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
for main_item, main_count in sorted_main:
|
||||
main_pct = (main_count / total_actions) * 100
|
||||
print(f"\n📦 {main_item}")
|
||||
print(f" └── Всего вызовов: {main_count} ({main_pct:.1f}% от общей активности)")
|
||||
|
||||
if main_item in sub_menu_counts and sub_menu_counts[main_item]:
|
||||
print(" └── Внутреннее распределение по подпунктам:")
|
||||
|
||||
total_sub_clicks = sum(sub_menu_counts[main_item].values())
|
||||
sorted_subs = sorted(sub_menu_counts[main_item].items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
for sub_item, sub_count in sorted_subs:
|
||||
sub_pct = (sub_count / total_sub_clicks) * 100
|
||||
print(f" ├── {sub_item}: {sub_count} шт. ({sub_pct:.1f}%)")
|
||||
|
||||
if sub_item == "Справки (Заказ документов)" and main_item in cert_counts:
|
||||
total_certs = sum(cert_counts[main_item].values())
|
||||
sorted_certs = sorted(cert_counts[main_item].items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
print(" │ └── 📑 Детализация по типам документов:")
|
||||
for idx, (cert_title, cert_num) in enumerate(sorted_certs, 1):
|
||||
# ИСПРАВЛЕНО: Локальный расчет процента перенесен строго внутрь итерации
|
||||
cert_pct = (cert_num / total_certs) * 100 if total_certs > 0 else 0
|
||||
tree_prefix = "└──" if idx == len(sorted_certs) else "├──"
|
||||
print(f" │ {tree_prefix} {cert_title}: {cert_num} шт. ({cert_pct:.1f}%)")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
# /opt/trueconf_bot/transcription_bot/handlers.py
|
||||
import io
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import httpx
|
||||
from ldap3 import Server, Connection, ALL
|
||||
from trueconf import Router, Message
|
||||
|
||||
from config.config import *
|
||||
from utils import states
|
||||
from utils.menu import MENU_TEXT
|
||||
from utils.texts import (
|
||||
EMOJI_DIGITS,
|
||||
TRANSCRIPTION_MAIN_MENU_TEXT,
|
||||
transcription_error_bad_extension,
|
||||
TRANSCRIPTION_UPLOADING_TEXT,
|
||||
transcription_success_received,
|
||||
transcription_error_api,
|
||||
transcription_error_system,
|
||||
UNKNOWN_MAIN_CMD_TEXT,
|
||||
)
|
||||
|
||||
# 🔌 Импортируем централизованную функцию сбора статистики из main
|
||||
from utils.stats_logger import log_menu_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
# ✅ Полный список разрешенных расширений (Аудио, Видео, Проф. форматы)
|
||||
ALLOWED_EXTENSIONS = [
|
||||
# Аудио
|
||||
'.mp3', '.aac', '.m4a', '.flac', '.ogg', '.oga', '.opus', '.wma',
|
||||
'.aif', '.aiff', '.ac3', '.eac3', '.dts', '.thd', '.au', '.caf',
|
||||
'.wv', '.ape', '.tta', '.wav',
|
||||
# Видео
|
||||
'.mp4', '.m4v', '.mkv', '.mka', '.mk3d', '.avi', '.mov', '.flv',
|
||||
'.webm', '.wmv', '.ts', '.m2ts', '.mpg', '.mpeg', '.vob',
|
||||
# Профессиональные и вещательные
|
||||
'.mxf', '.gxf', '.nucap',
|
||||
# Специфические
|
||||
'.4xm', '.bik', '.smk', '.cak'
|
||||
]
|
||||
|
||||
# --- Функция получения Email из Active Directory ---
|
||||
def get_user_email_sync(login: str) -> str:
|
||||
try:
|
||||
server = Server(AD_SERVER, get_info=ALL)
|
||||
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
|
||||
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["mail"])
|
||||
if conn.entries and 'mail' in conn.entries[0] and conn.entries[0].mail.value:
|
||||
return str(conn.entries[0].mail.value)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка поиска email в AD: {e}")
|
||||
return DEFAULT_REQUESTER
|
||||
|
||||
@router.message()
|
||||
async def transcription_handler(msg: Message):
|
||||
user_id = msg.from_user.id
|
||||
|
||||
# Работаем только если включен режим расшифровки
|
||||
if states.get_state(user_id) != "TRANSCRIPTION_MODE":
|
||||
return
|
||||
|
||||
# Помечаем сообщение как обработанное
|
||||
msg.handled = True
|
||||
|
||||
user_login = user_id.split('@')[0] if '@' in user_id else user_id
|
||||
msg_text = getattr(msg.content, "text", "").strip() if hasattr(msg.content, "text") else ""
|
||||
is_attachment = hasattr(msg.type, "name") and msg.type.name == "ATTACHMENT"
|
||||
|
||||
cmd = msg_text.lower()
|
||||
|
||||
# 🔄 НОРМАЛИЗАЦИЯ КНОПОК ВК-ЭМОДЗИ
|
||||
for raw_num, emoji_num in EMOJI_DIGITS.items():
|
||||
if cmd == emoji_num:
|
||||
cmd = raw_num
|
||||
break
|
||||
|
||||
# --- ВЫХОД (Кнопки 0 и 9 приведены к единому стандарту) ---
|
||||
if cmd in ["0", "9", "/start", "меню"]:
|
||||
log_menu_stats(user_id, "Speech-to-Text", "Выход в главное меню")
|
||||
states.clear_state(user_id)
|
||||
await msg.answer(MENU_TEXT, parse_mode="html")
|
||||
return
|
||||
|
||||
# --- ОБРАБОТКА ФАЙЛА ---
|
||||
if is_attachment:
|
||||
file_name = getattr(msg.content, "file_name", "").lower()
|
||||
file_id = getattr(msg.content, "file_id", None)
|
||||
|
||||
# Проверка расширения
|
||||
ext = os.path.splitext(file_name)[1]
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
await msg.answer(transcription_error_bad_extension(ext), parse_mode="html")
|
||||
return
|
||||
|
||||
await msg.answer(TRANSCRIPTION_UPLOADING_TEXT, parse_mode="html")
|
||||
try:
|
||||
# 1. Добываем почту из AD
|
||||
user_email = await asyncio.to_thread(get_user_email_sync, user_login)
|
||||
|
||||
# 2. Качаем файл (возвращает bytes)
|
||||
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
|
||||
|
||||
# 3. Отправка на API
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
files = {'file': (file_name, io.BytesIO(downloaded_file), 'application/octet-stream')}
|
||||
payload = {
|
||||
'email': user_email,
|
||||
'generate_summary': 'true'
|
||||
}
|
||||
response = await client.post(TRANSCRIPTION_API_URL, files=files, data=payload)
|
||||
|
||||
if response.status_code == 200:
|
||||
log_menu_stats(user_id, "Speech-to-Text", f"Отправка встречи на расшифровку ({ext})")
|
||||
await msg.answer(transcription_success_received(user_email), parse_mode="html")
|
||||
else:
|
||||
await msg.answer(transcription_error_api(response.status_code), parse_mode="html")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription Upload Error: {e}")
|
||||
await msg.answer(transcription_error_system(), parse_mode="html")
|
||||
return
|
||||
|
||||
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
|
||||
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
|
||||
@@ -0,0 +1,36 @@
|
||||
from flask import Flask, request, jsonify
|
||||
import os
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Папка назначения
|
||||
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "trash_api_uploads")
|
||||
|
||||
# Проверяем наличие папки
|
||||
if not os.path.exists(UPLOAD_DIR):
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
@app.route('/upload', methods=['POST'])
|
||||
def upload_file():
|
||||
# Проверяем, есть ли файл в запросе
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"status": "error", "message": "Файл не найден в запросе"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
# Если пользователь не выбрал файл
|
||||
if file.filename == '':
|
||||
return jsonify({"status": "error", "message": "Имя файла пустое"}), 400
|
||||
|
||||
if file:
|
||||
file_path = os.path.join(UPLOAD_DIR, file.filename)
|
||||
file.save(file_path)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"path": file_path
|
||||
}), 200
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Запускаем на всех интерфейсах (0.0.0.0) на порту 8000
|
||||
app.run(host='0.0.0.0', port=8000)
|
||||
@@ -0,0 +1,26 @@
|
||||
import requests
|
||||
import config.config as config
|
||||
|
||||
def get_embedding(text, model_name="multilingual-e5-base"):
|
||||
"""
|
||||
Заменяет model.encode(text). Вместо локальной загрузки
|
||||
отправляет запрос к API-серверу.
|
||||
"""
|
||||
url = f"http://127.0.0.1:8010/embed"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
# Если в конфиге задан токен, добавляем его
|
||||
if hasattr(config, 'EMBEDDING_API_TOKEN') and config.EMBEDDING_API_TOKEN:
|
||||
headers["X-Embedding-Token"] = config.EMBEDDING_API_TOKEN
|
||||
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"input_type": "query", # или "passage", зависит от того, что кодируешь
|
||||
"texts": [text]
|
||||
}
|
||||
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
if response.status_code == 200:
|
||||
return response.json()["embeddings"][0]
|
||||
else:
|
||||
raise Exception(f"Ошибка API эмбеддингов: {response.text}")
|
||||
@@ -0,0 +1,79 @@
|
||||
# /opt/trueconf_bot/utils/menu.py
|
||||
import config.config as config
|
||||
|
||||
MENU_MAP = {}
|
||||
MENU_TEXT = ""
|
||||
WELCOME_TEXT = "" # 💡 Добавили шаблон для развернутого приветствия
|
||||
|
||||
def build_dynamic_menu():
|
||||
global MENU_MAP, MENU_TEXT, WELCOME_TEXT
|
||||
MENU_MAP.clear()
|
||||
|
||||
# Старый формат keycap-цифр (как в VK)
|
||||
emoji_digits = {
|
||||
1: "1⃣",
|
||||
2: "2⃣",
|
||||
3: "3⃣",
|
||||
4: "4⃣",
|
||||
5: "5⃣",
|
||||
6: "6⃣",
|
||||
7: "7⃣",
|
||||
8: "8⃣",
|
||||
9: "9⃣",
|
||||
10: "🔟",
|
||||
}
|
||||
|
||||
dynamic_items = []
|
||||
counter = 1
|
||||
|
||||
# Безопасно проверяем флаги из конфига и наполняем массив пунктов
|
||||
if getattr(config, 'ENABLE_LK', True):
|
||||
num_char = emoji_digits.get(counter, str(counter))
|
||||
MENU_MAP[str(counter)] = "LK"
|
||||
MENU_MAP[num_char] = "LK"
|
||||
dynamic_items.append(f"{num_char} — 👤 Личный кабинет")
|
||||
counter += 1
|
||||
|
||||
if getattr(config, 'ENABLE_TRANSCRIPTION', True):
|
||||
num_char = emoji_digits.get(counter, str(counter))
|
||||
MENU_MAP[str(counter)] = "TRANSCRIPTION"
|
||||
MENU_MAP[num_char] = "TRANSCRIPTION"
|
||||
dynamic_items.append(f"{num_char} — 🎙 Расшифровка совещаний")
|
||||
counter += 1
|
||||
|
||||
if getattr(config, 'ENABLE_SERVICE_DESK', True):
|
||||
num_char = emoji_digits.get(counter, str(counter))
|
||||
MENU_MAP[str(counter)] = "SD"
|
||||
MENU_MAP[num_char] = "SD"
|
||||
dynamic_items.append(f"{num_char} — 🎫 Создать заявку в Service Desk")
|
||||
counter += 1
|
||||
|
||||
if getattr(config, 'ENABLE_PHOTO_BOT', True):
|
||||
num_char = emoji_digits.get(counter, str(counter))
|
||||
MENU_MAP[str(counter)] = "PHOTO"
|
||||
MENU_MAP[num_char] = "PHOTO"
|
||||
dynamic_items.append(f"{num_char} — Изменить фото профиля")
|
||||
counter += 1
|
||||
|
||||
if getattr(config, 'ENABLE_SEARCH_BOT', True):
|
||||
num_char = emoji_digits.get(counter, str(counter))
|
||||
MENU_MAP[str(counter)] = "SEARCH"
|
||||
MENU_MAP[num_char] = "SEARCH"
|
||||
dynamic_items.append(f"{num_char} — Поиск по регламентам")
|
||||
counter += 1
|
||||
|
||||
if counter == 1:
|
||||
dynamic_items.append("<i>В данный момент бот находится на техническом обслуживании.</i>")
|
||||
|
||||
# 1️⃣ Сценарий Б: Короткий шаблон для возврата из подмодулей
|
||||
MENU_TEXT = "🍀 <b>Главное меню:</b>\n\n" + "\n".join(dynamic_items)
|
||||
|
||||
# 2️⃣ Сценарий А: Длинный приветственный шаблон для старта/таймаута (с исправленной грамматикой)
|
||||
WELCOME_TEXT = (
|
||||
"🍀 <b>Здравствуйте! Я КЛЕВЕР — ваш персональный цифровой ассистент.</b>\n\n"
|
||||
"С моей помощью вы можете быстро получить доступ к нижеперечисленным корпоративным сервисам.\n\n"
|
||||
"👇 Пожалуйста, отправьте мне в чат цифру, соответствующую нужному пункту меню:\n\n"
|
||||
) + "\n".join(dynamic_items)
|
||||
|
||||
# Gенерируем меню один раз при импорте файла
|
||||
build_dynamic_menu()
|
||||
@@ -0,0 +1,133 @@
|
||||
# /opt/trueconf_bot/utils/otp_service.py
|
||||
import re
|
||||
import secrets
|
||||
import logging
|
||||
import httpx
|
||||
import urllib3
|
||||
from ldap3 import Server, Connection, ALL
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
import config.config as config
|
||||
|
||||
# Отключаем во внутреннем контуре предупреждения об SSL
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def generate_otp_code(length: int = 4) -> str:
|
||||
"""Генерирует криптографически безопасный цифровой код"""
|
||||
return "".join(secrets.choice("0123456789") for _ in range(length))
|
||||
|
||||
async def send_otp_via_api(phone: str, code: str) -> tuple:
|
||||
"""
|
||||
Асинхронно отправляет код на шлюз apisms.
|
||||
Парсит XML-ответ и определяет фактический канал доставки (MAX, Telegram, СМС).
|
||||
Возвращает: (True/False, "канал_доставки или текст_ошибки")
|
||||
"""
|
||||
url = "https://mobileapp.sibcem.ru/MobDriveService/MobDriveService/MobService.asmx/SendSMS"
|
||||
params = {
|
||||
"_number": phone,
|
||||
"_message": f"Код авторизации Сибцем : {code}"
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=False) as client:
|
||||
response = await client.get(url, params=params, timeout=40.0)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
xml_content = response.text
|
||||
logger.info(f"Сырой ответ OTP-шлюза: {xml_content}")
|
||||
|
||||
# Извлекаем текст внутри тега <string>...</string>
|
||||
match = re.search(r'<string[^>]*>(.*?)</string>', xml_content, re.DOTALL)
|
||||
if match:
|
||||
inner_text = match.group(1).strip()
|
||||
inner_lower = inner_text.lower()
|
||||
|
||||
# Умное определение канала на основе ключевых слов шлюза Сибцема
|
||||
if "max" in inner_lower:
|
||||
channel_info = "в MAX"
|
||||
elif "telegram" in inner_lower or "телеграм" in inner_lower:
|
||||
channel_info = "в Telegram"
|
||||
elif "смс" in inner_lower or "sms" in inner_lower or "yeastar" in inner_lower or "шлюз" in inner_lower:
|
||||
channel_info = "по СМС"
|
||||
else:
|
||||
channel_info = "по СМС" # Дефолтный фоллбэк, если текст изменится
|
||||
else:
|
||||
channel_info = "по СМС"
|
||||
|
||||
return True, channel_info
|
||||
|
||||
logger.error(f"❌ Шлюз API вернул ошибку {response.status_code}: {response.text}")
|
||||
return False, f"Ошибка сервера шлюза ({response.status_code})"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критический сбой сети при отправке OTP на {phone}: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def get_card_id_from_ad(user_id: str) -> str:
|
||||
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2)"""
|
||||
try:
|
||||
server = Server(config.AD_SERVER, get_info=ALL)
|
||||
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
|
||||
safe_user_id = escape_filter_chars(user_id)
|
||||
short_username = user_id.split('@')[0]
|
||||
|
||||
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
|
||||
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
|
||||
|
||||
if conn.entries and 'extensionAttribute2' in conn.entries[0] and conn.entries[0].extensionAttribute2.value:
|
||||
return str(conn.entries[0].extensionAttribute2.value).strip()
|
||||
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения CARD_ID из AD для {user_id}: {e}")
|
||||
raise Exception(f"Не удалось извлечь CARD_ID из AD: {e}")
|
||||
|
||||
def get_phone_from_db(card_id: str) -> str:
|
||||
"""Синхронно вытягивает телефон сотрудника из БД BossCopy (таблица UOV_SELFSERVICE_PR_CARD) по CARD_ID"""
|
||||
import pymssql
|
||||
try:
|
||||
conn = pymssql.connect(
|
||||
server=config.SQL_SERVER,
|
||||
user=config.DB_USER,
|
||||
password=config.DB_PASSWORD,
|
||||
database=config.SQL_DB_NAME,
|
||||
charset="cp1251",
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Телефон берётся из поля PHONE таблицы UOV_SELFSERVICE_PR_CARD
|
||||
query = """
|
||||
SELECT PHONE FROM UOV_SELFSERVICE_PR_CARD WHERE ID = %s
|
||||
"""
|
||||
cursor.execute(query, (card_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row or not row[0]:
|
||||
raise Exception(f"Телефон не найден для CARD_ID {card_id} в UOV_SELFSERVICE_PR_CARD")
|
||||
|
||||
phone = str(row[0]).strip()
|
||||
# Очищаем номер от пробелов, дефисов, скобок
|
||||
phone = phone.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
|
||||
return phone
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения телефона из БД для CARD_ID {card_id}: {e}")
|
||||
raise Exception(f"Не удалось извлечь номер телефона из БД BossCopy: {e}")
|
||||
|
||||
def get_user_phone_from_ad(user_id: str) -> str:
|
||||
"""
|
||||
Синхронно вытягивает телефон сотрудника:
|
||||
1. Получает CARD_ID из Active Directory (extensionAttribute2)
|
||||
2. Получает телефон из БД BossCopy (таблица UOV_SELFSERVICE_PR_CARD) по CARD_ID
|
||||
"""
|
||||
try:
|
||||
# Шаг 1: Получаем CARD_ID из AD
|
||||
card_id = get_card_id_from_ad(user_id)
|
||||
logger.info(f"✅ CARD_ID для {user_id}: {card_id}")
|
||||
|
||||
# Шаг 2: Получаем телефон из БД по CARD_ID
|
||||
phone = get_phone_from_db(card_id)
|
||||
logger.info(f"✅ Телефон для CARD_ID {card_id}: {phone}")
|
||||
|
||||
return phone
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения телефона для {user_id}: {e}")
|
||||
raise Exception(f"Не удалось извлечь номер телефона: {e}")
|
||||
@@ -0,0 +1,47 @@
|
||||
# /opt/trueconf_bot/utils/states.py
|
||||
import time
|
||||
import config.config as config
|
||||
|
||||
user_states = {}
|
||||
tc_bot = None # Общая переменная для экземпляра бота
|
||||
|
||||
# --- Константы состояний ---
|
||||
PHOTO_MODE = "PHOTO_MODE"
|
||||
SD_MODE = "SD_MODE"
|
||||
TRANSCRIPTION_MODE = "TRANSCRIPTION_MODE"
|
||||
SEARCH_MODE = "SEARCH_MODE"
|
||||
|
||||
def set_state(user_id, state):
|
||||
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
|
||||
user_states[user_id] = {
|
||||
"state": state,
|
||||
"last_active": time.time()
|
||||
}
|
||||
|
||||
def get_state(user_id):
|
||||
"""
|
||||
Возвращает текущее состояние пользователя.
|
||||
Если время неактивности превысило лимит из config.py, сессия сбрасывается.
|
||||
"""
|
||||
if user_id in user_states:
|
||||
session = user_states[user_id]
|
||||
|
||||
# Динамически забираем значение таймаута из конфига (дефолт — 1800 сек / 30 мин)
|
||||
session_timeout = getattr(config, 'SESSION_TIMEOUT', 1800)
|
||||
|
||||
# Проверяем, сколько секунд прошло с момента последнего сообщения
|
||||
if time.time() - session["last_active"] > session_timeout:
|
||||
# Время вышло! Аппаратно сносим сессию навигации
|
||||
del user_states[user_id]
|
||||
return None
|
||||
|
||||
# Если время не вышло, обновляем таймер (пользователь проявил активность)
|
||||
session["last_active"] = time.time()
|
||||
return session["state"]
|
||||
|
||||
return None
|
||||
|
||||
def clear_state(user_id):
|
||||
"""Принудительно очищает состояние пользователя (выход в корень)"""
|
||||
if user_id in user_states:
|
||||
del user_states[user_id]
|
||||
@@ -0,0 +1,24 @@
|
||||
# utils/stats_logger.py
|
||||
import os
|
||||
import csv
|
||||
from datetime import datetime
|
||||
|
||||
def log_menu_stats(user_id: str, main_menu: str, sub_menu: str = ""):
|
||||
"""Записывает действие пользователя в лог статистики динамически в зависимости от среды"""
|
||||
try:
|
||||
# Динамически определяем корень проекта (на один уровень выше папки utils)
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
log_dir = os.path.join(base_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, "menu_stats.csv")
|
||||
|
||||
file_exists = os.path.isfile(log_file)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with open(log_file, "a", newline="", encoding="utf-8-sig") as f:
|
||||
writer = csv.writer(f, delimiter=";")
|
||||
if not file_exists:
|
||||
writer.writerow(["Timestamp", "User_ID", "Main_Menu", "Sub_Menu"])
|
||||
writer.writerow([timestamp, user_id, main_menu, sub_menu])
|
||||
except Exception as e:
|
||||
print(f"Ошибка записи статистики меню: {e}")
|
||||
+571
@@ -0,0 +1,571 @@
|
||||
# /opt/trueconf_bot/utils/texts.py
|
||||
# Все текстовые сообщения бота, отправляемые пользователю
|
||||
|
||||
# =========================================================
|
||||
# GLOBAL CONSTANTS
|
||||
# =========================================================
|
||||
|
||||
# --- Main Bot ---
|
||||
# Общее для главного бота
|
||||
UNKNOWN_MAIN_CMD_TEXT = (
|
||||
"⚠️ <b>Неизвестная команда.</b>\n\n"
|
||||
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
|
||||
)
|
||||
|
||||
# --- Emoji Digits ---
|
||||
# Используются для навигации и выбора пунктов меню
|
||||
EMOJI_DIGITS = {
|
||||
"0": "0⃣",
|
||||
"1": "1⃣",
|
||||
"2": "2⃣",
|
||||
"3": "3⃣",
|
||||
"4": "4⃣",
|
||||
"5": "5⃣",
|
||||
"6": "6⃣",
|
||||
"7": "7⃣",
|
||||
"8": "8⃣",
|
||||
"9": "9⃣",
|
||||
"10": "🔟"
|
||||
}
|
||||
|
||||
# --- Footer Templates ---
|
||||
# Шаблоны подвалов сообщений для навигации
|
||||
|
||||
def _footer(emojis: dict) -> str:
|
||||
"""Стандартный футер с кнопками Назад и Меню"""
|
||||
return f"\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _footer_main_only(emojis: dict) -> str:
|
||||
"""Футер только с кнопкой Меню"""
|
||||
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
|
||||
|
||||
|
||||
# =========================================================
|
||||
# TRANSCRIPTION BOT
|
||||
# =========================================================
|
||||
# Модуль для расшифровки аудио и видео файлов
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _transcription_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"🎙 <b>Добро пожаловать! Я — Ассистент расшифровки совещаний.</b>\n\n"
|
||||
f"Я помогу вам перевести аудио или видео в текст, а также подготовлю краткое резюме вашей встречи.\n\n"
|
||||
f"📎 Для начала работы просто отправьте нужный файл прямо в этот чат.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _transcription_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n\n"
|
||||
f"Пожалуйста, отправьте в сообщении мне нужную цифру соответствующего пункта меню в чат."
|
||||
)
|
||||
|
||||
def _transcription_bad_extension(ext: str, emojis: dict) -> str:
|
||||
return (
|
||||
f"❌ <b>Ошибка: Недопустимый формат файла ({ext}).</b>\n\n"
|
||||
f"Пожалуйста, отправьте поддерживаемый аудио или видео файл.\n\n"
|
||||
f"{_footer(emojis)}"
|
||||
)
|
||||
|
||||
def _transcription_uploading() -> str:
|
||||
return "⏳ <b>Загружаю файл...</b>"
|
||||
|
||||
def _transcription_success(email: str, emojis: dict) -> str:
|
||||
return (
|
||||
f"✅ <b>Хорошо, файл принят в обработку.</b>\n\n"
|
||||
f"Как только всё будет готово, я отправлю вам результаты на почту (<code>{email}</code>).\n\n"
|
||||
f"⏳ Обычно это занимает от 5 до 30 минут.\n\n"
|
||||
f"{_footer(emojis)}"
|
||||
)
|
||||
|
||||
def _transcription_api_error(status_code: int, emojis: dict) -> str:
|
||||
return f"❌ <b>Ошибка API сервера ({status_code}).</b>{_footer(emojis)}"
|
||||
|
||||
def _transcription_system_error(emojis: dict) -> str:
|
||||
return f"❌ <b>Произошла системная ошибка при передаче файла.</b>{_footer(emojis)}"
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
TRANSCRIPTION_MAIN_MENU_TEXT = _transcription_welcome(EMOJI_DIGITS)
|
||||
TRANSCRIPTION_UNKNOWN_CMD_TEXT = _transcription_unknown(EMOJI_DIGITS)
|
||||
|
||||
def transcription_error_bad_extension(ext: str) -> str:
|
||||
return _transcription_bad_extension(ext, EMOJI_DIGITS)
|
||||
|
||||
TRANSCRIPTION_UPLOADING_TEXT = _transcription_uploading()
|
||||
|
||||
def transcription_success_received(email: str) -> str:
|
||||
return _transcription_success(email, EMOJI_DIGITS)
|
||||
|
||||
def transcription_error_api(status_code: int) -> str:
|
||||
return _transcription_api_error(status_code, EMOJI_DIGITS)
|
||||
|
||||
def transcription_error_system() -> str:
|
||||
return _transcription_system_error(EMOJI_DIGITS)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# SERVICE DESK BOT
|
||||
# =========================================================
|
||||
# Модуль для создания и управления заявками в Service Desk
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _sd_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"🎫 <b>Режим создания заявки в Service Desk</b>\n\n"
|
||||
f"• Опишите проблему текстом.\n"
|
||||
f"• Можно прикрепить фото или документ.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _sd_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
|
||||
f"{emojis['9']} — Назад\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _sd_text_required(emojis: dict) -> str:
|
||||
return f"⚠️ <b>Сначала опишите проблему текстом.</b>"
|
||||
|
||||
def _sd_creating() -> str:
|
||||
return "⏳ Создаю заявку, пожалуйста, подождите..."
|
||||
|
||||
def _sd_ticket_created(ticket_id: int, subject: str, description: str, emojis: dict) -> str:
|
||||
clean_desc = description.replace("\n", " ").strip()
|
||||
if len(clean_desc) > 150:
|
||||
clean_desc = clean_desc[:147] + "..."
|
||||
return (
|
||||
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
|
||||
|
||||
f"📌 <b>Тема: {subject}</b>\n\n"
|
||||
f"📝 <b>Текст: {clean_desc}</b>\n\n"
|
||||
|
||||
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
|
||||
f"{_footer(emojis)}"
|
||||
)
|
||||
|
||||
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
|
||||
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
|
||||
|
||||
def _sd_system_error(emojis: dict) -> str:
|
||||
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
|
||||
|
||||
def _sd_uploading_file(ticket_id: int, emojis: dict) -> str:
|
||||
return f"⏳ Загружаю файл в заявку #{ticket_id}..."
|
||||
|
||||
def _sd_file_attached(emojis: dict) -> str:
|
||||
return f"✅ <b>Файл успешно прикреплен!</b>{_footer(emojis)}"
|
||||
|
||||
def _sd_file_upload_error(emojis: dict) -> str:
|
||||
return f"❌ <b>Ошибка загрузки файла.</b>{_footer_main_only(emojis)}"
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
SD_MAIN_MENU_TEXT = _sd_welcome(EMOJI_DIGITS)
|
||||
SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
|
||||
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
|
||||
SD_CREATING = _sd_creating()
|
||||
|
||||
def sd_ticket_create_error() -> str:
|
||||
return _sd_ticket_create_error(EMOJI_DIGITS)
|
||||
|
||||
def sd_system_error() -> str:
|
||||
return _sd_system_error(EMOJI_DIGITS)
|
||||
|
||||
def sd_uploading_file(ticket_id: int) -> str:
|
||||
return _sd_uploading_file(ticket_id, EMOJI_DIGITS)
|
||||
|
||||
def sd_file_attached() -> str:
|
||||
return _sd_file_attached(EMOJI_DIGITS)
|
||||
|
||||
def sd_file_upload_error() -> str:
|
||||
return _sd_file_upload_error(EMOJI_DIGITS)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# PHOTO BOT
|
||||
# =========================================================
|
||||
# Модуль для обновления фото профиля
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _photo_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"🖼 <b>Режим обновления фото профиля</b>\n\n"
|
||||
f"📸 Пришлите мне вашу фотографию.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
|
||||
f"{emojis['9']} — Назад\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_error_handling(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
||||
f"Информация о проблеме передана ответственным.\n"
|
||||
f"Приносим извинения за доставленные неудобства.</i>\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_uploading() -> str:
|
||||
return "⏳ Отправка..."
|
||||
|
||||
def _photo_success_sent() -> str:
|
||||
return "📩 <b>Фото отправлено на утверждение.</b>"
|
||||
|
||||
def _photo_cancel(emojis: dict) -> str:
|
||||
return (
|
||||
f"🚫 Отменено. Пришлите другое фото.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_processing() -> str:
|
||||
return "⏳ Обработка..."
|
||||
|
||||
def _photo_preview_ready(emojis: dict) -> str:
|
||||
return (
|
||||
f"... <b>Фото готово!</b>\n\n"
|
||||
f"{emojis['1']} — Отправить на утверждение\n"
|
||||
f"{emojis['2']} — Попробовать другое\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _photo_error_processing(error_text: str, emojis: dict) -> str:
|
||||
return (
|
||||
f"❌ <b>Ошибка:</b> {error_text}\n\n"
|
||||
f"Пожалуйста, отправьте другое фото.\n\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
|
||||
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
|
||||
|
||||
def photo_error_handling() -> str:
|
||||
return _photo_error_handling(EMOJI_DIGITS)
|
||||
|
||||
PHOTO_UPLOADING_TEXT = _photo_uploading()
|
||||
|
||||
def photo_success_sent() -> str:
|
||||
return _photo_success_sent()
|
||||
|
||||
def photo_cancel_text() -> str:
|
||||
return _photo_cancel(EMOJI_DIGITS)
|
||||
|
||||
PHOTO_PROCESSING_TEXT = _photo_processing()
|
||||
|
||||
def photo_preview_ready() -> str:
|
||||
return _photo_preview_ready(EMOJI_DIGITS)
|
||||
|
||||
def photo_error_processing(error_text: str) -> str:
|
||||
return _photo_error_processing(error_text, EMOJI_DIGITS)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# SEARCH BOT
|
||||
# =========================================================
|
||||
# Модуль поиска по регламентам
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _search_welcome(emojis: dict) -> str:
|
||||
return (
|
||||
f"📚 <b>Поиск по регламентам</b>\n\n"
|
||||
f"Введите ваш вопрос (например: <i>'время хранения электронных документов'</i> ).\n"
|
||||
f"Бот найдет наиболее подходящие абзацы из нормативных документов.\n\n"
|
||||
f"Для возврата в главное меню отправьте {emojis['0']}."
|
||||
)
|
||||
|
||||
def _search_unknown(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
|
||||
f"{emojis['9']} — Назад\n"
|
||||
f"{emojis['0']} — В главное меню"
|
||||
)
|
||||
|
||||
def _search_searching() -> str:
|
||||
return "🔍 <b>Ищу информацию в регламентах...</b>"
|
||||
|
||||
def _search_not_found(emojis: dict) -> str:
|
||||
return (
|
||||
f"🤖 Точной информации в регламентах не найдено.\n\n"
|
||||
f"Для выхода отправьте {emojis['0']}."
|
||||
)
|
||||
|
||||
def _search_feedback_thanks(emojis: dict) -> str:
|
||||
return (
|
||||
f"🙏 <b>Спасибо за ваш отзыв!</b> Мы передали его разработчикам.\n\n"
|
||||
f"🔍 <b>Можете сразу задать следующий вопрос по регламентам.</b>\n\n"
|
||||
f"Для выхода отправьте {emojis['0']}."
|
||||
)
|
||||
|
||||
def _search_feedback_info(emojis: dict) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Информация передана.</b>\n\n"
|
||||
f"Если желаете, напишите прямо сейчас текстовым сообщением, что конкретно вам не понравилось в ответе.\n\n"
|
||||
f"{emojis['1']} — Задать ещё вопрос\n"
|
||||
f"{emojis['0']} — Выйти в главное меню"
|
||||
)
|
||||
|
||||
def _search_unavailable(emojis: dict) -> str:
|
||||
return (
|
||||
"⚠️ <i>В данный момент выполнить это действие невозможно. "
|
||||
"Информация о проблеме передана ответственным.\n"
|
||||
"Приносим извинения за доставленные неудобства.</i>\n\n"
|
||||
f"<i>{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
|
||||
SEARCH_UNKNOWN_CMD_TEXT = _search_unknown(EMOJI_DIGITS)
|
||||
|
||||
def search_searching_text() -> str:
|
||||
return _search_searching()
|
||||
|
||||
def search_not_found_text(emojis: dict) -> str:
|
||||
return _search_not_found(emojis)
|
||||
|
||||
def search_feedback_thanks_text(emojis: dict) -> str:
|
||||
return _search_feedback_thanks(emojis)
|
||||
|
||||
def search_feedback_info_text(emojis: dict) -> str:
|
||||
return _search_feedback_info(emojis)
|
||||
|
||||
def search_unavailable_action(emojis: dict) -> str:
|
||||
return _search_unavailable(emojis)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# LK BOT
|
||||
# =========================================================
|
||||
# Модуль Личного Кабинета (финансовые документы, табель и др.)
|
||||
|
||||
# --- Constants ---
|
||||
|
||||
DOC_TYPES = {
|
||||
"1": "Справка о доходах физического лица (2-НДФЛ)",
|
||||
"2": "Справка об удержаниях за ДМС",
|
||||
"3": "Справка о средней заработной плате",
|
||||
"4": "Справка о получении единовременного пособия по рождению ребенка",
|
||||
"5": "Справка о получении ежемесячного пособия и назначении отпуска по уходу за ребенком",
|
||||
"6": "Справка об удержаниях по исполнительным листам",
|
||||
"7": "Копия трудовой книжки"
|
||||
}
|
||||
|
||||
# --- Private functions (Internal logic) ---
|
||||
|
||||
def _lk_error_handling(emojis: dict) -> str:
|
||||
return (
|
||||
"⚠️ <i>В данный момент выполнить это действие невозможно. Информация о проблеме передана ответственным. Приносим извинения за доставленные неудобства.</i>\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_auth_processing(emojis: dict) -> str:
|
||||
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
|
||||
|
||||
def _lk_auth_success(emojis: dict) -> str:
|
||||
return "⏳ <i>Авторизация успешна. Загружаю детализацию расчетного листа...</i>"
|
||||
|
||||
def _lk_auth_2fa_expired(emojis: dict) -> str:
|
||||
return f"⏱ <b>Время действия кода подтверждения изменилось или истекло.</b>\nПожалуйста, запросите расчетный лист заново.{emojis['9']} — Назад"
|
||||
|
||||
def _lk_auth_2fa_wrong_code(emojis: dict, attempts_left: int) -> str:
|
||||
return (
|
||||
f"⚠️ <b>Неверный код подтверждения.</b>\nОсталось попыток: {attempts_left}. Попробуйте еще раз или вернитесь назад.\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_auth_2fa_blocked_max(emojis: dict, minutes: int) -> str:
|
||||
return (
|
||||
f"❌ <b>Превышено максимальное количество попыток ввода кода.</b>\nДоступ к финансовым документам заблокирован на <b>{minutes} минут</b>.<br>\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_payslip_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Формирую архив расчетных листков...</i>"
|
||||
|
||||
def _lk_vacation_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Формирую справку по отпусках...</i>"
|
||||
|
||||
def _lk_sick_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Загружаю историю больничных листов...</i>"
|
||||
|
||||
def _lk_missions_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Загружаю историю командировок...</i>"
|
||||
|
||||
def _lk_rewards_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Ищу информацию о ваших наградах...</i>"
|
||||
|
||||
def _lk_transfers_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Загружаю историю кадровых перемещений...</i>"
|
||||
|
||||
def _lk_tabel_loading(emojis: dict) -> str:
|
||||
return "⏳ <i>Рассчитываю баланс рабочего времени...</i>"
|
||||
|
||||
def _lk_auth_sending(emojis: dict) -> str:
|
||||
return "⏳ <i>Отправка заявки...</i>"
|
||||
|
||||
def _lk_success_submission(emojis: dict) -> str:
|
||||
return (
|
||||
"✅ <b>Ваша заявка успешно отправлена!</b>\n"
|
||||
"Специалисты свяжутся с вами по готовности документа.\n\n"
|
||||
f"<i>{emojis['9']} — Назад в список справок\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_main_menu_text(header: str, emojis: dict) -> str:
|
||||
return (
|
||||
f"{header}\n"
|
||||
f"{emojis['1']} — Расчётный лист\n"
|
||||
f"{emojis['2']} — Информация о днях отпуска\n"
|
||||
f"{emojis['3']} — Больничные\n"
|
||||
f"{emojis['4']} — Командировки\n"
|
||||
f"{emojis['5']} — Награды\n"
|
||||
f"{emojis['6']} — Переводы\n"
|
||||
f"{emojis['7']} — Табель учёта рабочего времени\n"
|
||||
f"{emojis['8']} — Справки\n\n"
|
||||
f"<i>{emojis['0']} — Вернуться в главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis: dict) -> str:
|
||||
return (
|
||||
"⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"
|
||||
f"{emojis['1']} — {months_ru[prev_1.month]} {prev_1.year} г.\n"
|
||||
f"{emojis['2']} — {months_ru[prev_2.month]} {prev_2.year} г.\n\n"
|
||||
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def _lk_payslip_months_menu_text(months_data: list, months_ru: dict, emojis: dict) -> str:
|
||||
menu_text = "📅 <b>Выберите месяц для просмотра:</b>\n\n"
|
||||
for idx, cmonth in enumerate(months_data, start=1):
|
||||
month_name = months_ru.get((cmonth - 1) % 12 + 1, '00')
|
||||
year = (cmonth - 1) // 12
|
||||
menu_text += f"{emojis[str(idx)]} — {month_name} {year} г.\n"
|
||||
menu_text += f"\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
return menu_text
|
||||
|
||||
def _lk_docs_quantity_prompt(emojis: dict) -> str:
|
||||
return f"Укажите необходимое количество экземпляров (максимум — 5 шт.):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_year_prompt(emojis: dict) -> str:
|
||||
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_avg_salary_prompt(emojis: dict) -> str:
|
||||
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_child_age_prompt(emojis: dict) -> str:
|
||||
return f"👶 <b>Выберите возраст ребенка (до скольки лет):</b>\n\n{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_period_prompt(year: int, emojis: dict) -> str:
|
||||
return f"за {year} год\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_child_age_options(emojis: dict) -> str:
|
||||
return f"{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_docs_period_options(emojis: dict) -> str:
|
||||
return f"{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
def _lk_nav_text(emojis: dict) -> str:
|
||||
return f"\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
|
||||
|
||||
# --- Public constants and functions ---
|
||||
|
||||
LK_DOCS_MENU_TEXT = (
|
||||
"\n".join([f"{EMOJI_DIGITS[k]} — {v}" for k, v in DOC_TYPES.items()]) +
|
||||
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||
)
|
||||
LK_UNKNOWN_CMD_TEXT = (
|
||||
"⚠️ <b>Неизвестная команда.</b>\n"
|
||||
"Пожалуйста, выберите пункт меню, используя цифры или кнопки.<br>\n"
|
||||
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
|
||||
)
|
||||
|
||||
def lk_error_handling(emojis: dict) -> str:
|
||||
return _lk_error_handling(emojis)
|
||||
|
||||
def lk_auth_processing(emojis: dict) -> str:
|
||||
return _lk_auth_processing(emojis)
|
||||
|
||||
def lk_auth_success(emojis: dict) -> str:
|
||||
return _lk_auth_success(emojis)
|
||||
|
||||
def lk_auth_2fa_expired(emojis: dict) -> str:
|
||||
return _lk_auth_2fa_expired(emojis)
|
||||
|
||||
def lk_auth_2fa_wrong_code(emojis: dict, attempts_left: int) -> str:
|
||||
return _lk_auth_2fa_wrong_code(emojis, attempts_left)
|
||||
|
||||
def lk_auth_2fa_blocked_max(emojis: dict, minutes: int) -> str:
|
||||
return _lk_auth_2fa_blocked_max(emojis, minutes)
|
||||
|
||||
def lk_payslip_loading(emojis: dict) -> str:
|
||||
return _lk_payslip_loading(emojis)
|
||||
|
||||
def lk_vacation_loading(emojis: dict) -> str:
|
||||
return _lk_vacation_loading(emojis)
|
||||
|
||||
def lk_sick_loading(emojis: dict) -> str:
|
||||
return _lk_sick_loading(emojis)
|
||||
|
||||
def lk_missions_loading(emojis: dict) -> str:
|
||||
return _lk_missions_loading(emojis)
|
||||
|
||||
def lk_rewards_loading(emojis: dict) -> str:
|
||||
return _lk_rewards_loading(emojis)
|
||||
|
||||
def lk_transfers_loading(emojis: dict) -> str:
|
||||
return _lk_transfers_loading(emojis)
|
||||
|
||||
def lk_tabel_loading(emojis: dict) -> str:
|
||||
return _lk_tabel_loading(emojis)
|
||||
|
||||
def lk_auth_sending(emojis: dict) -> str:
|
||||
return _lk_auth_sending(emojis)
|
||||
|
||||
def lk_success_submission(emojis: dict) -> str:
|
||||
return _lk_success_submission(emojis)
|
||||
|
||||
def lk_main_menu_text(header: str, emojis: dict) -> str:
|
||||
return _lk_main_menu_text(header, emojis)
|
||||
|
||||
def lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis: dict) -> str:
|
||||
return _lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis)
|
||||
|
||||
def lk_payslip_months_menu_text(months_data: list, months_ru: dict, emojis: dict) -> str:
|
||||
return _lk_payslip_months_menu_text(months_data, months_ru, emojis)
|
||||
|
||||
def lk_docs_quantity_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_quantity_prompt(emojis)
|
||||
|
||||
def lk_docs_year_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_year_prompt(emojis)
|
||||
|
||||
def lk_docs_avg_salary_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_avg_salary_prompt(emojis)
|
||||
|
||||
def lk_docs_child_age_prompt(emojis: dict) -> str:
|
||||
return _lk_docs_child_age_prompt(emojis)
|
||||
|
||||
def lk_docs_period_prompt(year: int, emojis: dict) -> str:
|
||||
return _lk_docs_period_prompt(year, emojis)
|
||||
|
||||
def lk_docs_child_age_options(emojis: dict) -> str:
|
||||
return _lk_docs_child_age_options(emojis)
|
||||
|
||||
def lk_docs_period_options(emojis: dict) -> str:
|
||||
return _lk_docs_period_options(emojis)
|
||||
|
||||
def lk_nav_text(emojis: dict) -> str:
|
||||
return _lk_nav_text(emojis)
|
||||
|
||||
def lk_years_menu(current_year: int, emojis: dict) -> str:
|
||||
return lk_years_menu(current_year, emojis)
|
||||
Reference in New Issue
Block a user