Initial commit: TrueConf Chatbot КЛЕВЕР

This commit is contained in:
2026-07-15 12:00:27 +07:00
commit fc0288d13d
42 changed files with 7818 additions and 0 deletions
+36
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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
+219
View File
@@ -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
+42
View File
@@ -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()
+18
View File
@@ -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'
View File
+115
View File
@@ -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
View File
@@ -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")
+378
View File
@@ -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}")
+1
View File
@@ -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")
+322
View File
@@ -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
+213
View File
@@ -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()
+77
View File
@@ -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()
+18
View File
@@ -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 расчетного листа
1 Timestamp User_ID Main_Menu Sub_Menu
2 2026-07-02 18:59:49 ds.krivochenko@tcs.sibcem.ru Личный кабинет Расчётный лист
3 2026-07-02 18:59:53 ds.krivochenko@tcs.sibcem.ru Личный кабинет Запрос кода 2FA для расчетного листа (в MAX)
4 2026-07-02 18:59:58 ds.krivochenko@tcs.sibcem.ru Личный кабинет Успешная проверка 2FA расчетного листа
5 2026-07-02 19:00:15 ds.krivochenko@tcs.sibcem.ru Speech-to-Text Вход
6 2026-07-02 19:00:48 ds.krivochenko@tcs.sibcem.ru Speech-to-Text Отправка встречи на расшифровку (.mp4)
7 2026-07-02 19:01:06 ds.krivochenko@tcs.sibcem.ru Speech-to-Text Выход в главное меню
8 2026-07-02 19:01:10 ds.krivochenko@tcs.sibcem.ru Service Desk Вход
9 2026-07-02 19:01:35 ds.krivochenko@tcs.sibcem.ru Service Desk Создание заявки #23108
10 2026-07-03 14:55:32 ds.krivochenko@tcs.sibcem.ru Service Desk Вход
11 2026-07-03 15:00:29 ds.krivochenko@tcs.sibcem.ru Service Desk Вход
12 2026-07-03 15:00:39 ds.krivochenko@tcs.sibcem.ru Service Desk Создание заявки #23133
13 2026-07-03 15:07:19 ds.krivochenko@tcs.sibcem.ru Service Desk Вход
14 2026-07-03 15:07:30 ds.krivochenko@tcs.sibcem.ru Service Desk Создание заявки #23134
15 2026-07-08 02:23:18 ds.krivochenko@tcs.sibcem.ru Личный кабинет Расчётный лист
16 2026-07-08 02:35:13 ds.krivochenko@tcs.sibcem.ru Личный кабинет Расчётный лист
17 2026-07-08 02:35:15 ds.krivochenko@tcs.sibcem.ru Личный кабинет Запрос кода 2FA для расчетного листа (в MAX)
18 2026-07-08 02:35:20 ds.krivochenko@tcs.sibcem.ru Личный кабинет Успешная проверка 2FA расчетного листа
+181
View File
@@ -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("Бот остановлен вручную.")
View File
+49
View File
@@ -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."}
+181
View File
@@ -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("Бот остановлен пользователем.")
+83
View File
@@ -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)
+242
View File
@@ -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
+317
View File
@@ -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
+127
View File
@@ -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)}
+160
View File
@@ -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
+685
View File
@@ -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")
+173
View File
@@ -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)
+70
View File
@@ -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()
+650
View File
@@ -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()
+54
View File
@@ -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)
View File
+396
View File
@@ -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
+67
View File
@@ -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
View File
@@ -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()
+127
View File
@@ -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")
+36
View File
@@ -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)
View File
+26
View File
@@ -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}")
+79
View File
@@ -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()
+133
View File
@@ -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}")
+47
View File
@@ -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]
+24
View File
@@ -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
View File
@@ -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)