32 Commits

Author SHA1 Message Date
Dennis Krivochenko 119f77b1e5 feat: add document optimization script for batch processing 2026-07-18 23:29:24 +07:00
Dennis Krivochenko 9fb276f6d7 feat: add document optimization script 2026-07-18 12:17:07 +07:00
dddennnisss b0ce571b3d Обновить parse_pol177.py 2026-07-16 17:08:41 +07:00
Hermes f3ecc8cf06 Update parse_pol177.py: use Unstructured API for tables, remove pypdf 2026-07-16 17:04:59 +07:00
dddennnisss b7e5fffee0 Fix: use yolox model (available on 103) + hi_res + GPU for maximum quality 2026-07-16 16:16:05 +07:00
dddennnisss aa0923f4e1 Fix: use yolox model (available on 103) + hi_res + GPU for maximum quality 2026-07-16 16:15:05 +07:00
dddennnisss f8085d9ec2 Fix: use hi_res strategy + chipper model + GPU for maximum quality 2026-07-16 16:00:35 +07:00
dddennnisss 35d5f48c5e Add clean_chunk_pol177.py - Clean and chunk parsed PDF data 2026-07-16 15:06:35 +07:00
dddennnisss 9f36374351 Add parse_pol177.py - PDF parser with Unstructured API 2026-07-16 15:06:34 +07:00
dddennnisss bae21d08ef Add parse_pol177.py - PDF parser with Unstructured API + pypdf 2026-07-16 10:24:19 +07:00
Денис Кривоченко 5325b43c95 fix: add service_category from template to SD API request 2026-07-15 22:49:26 +07:00
Денис Кривоченко 50e9aa12e4 fix: use template instead of request_template for SD API 2026-07-15 22:40:55 +07:00
Денис Кривоченко 4df52d48f5 fix: use params instead of content for SD API requests 2026-07-15 22:28:15 +07:00
dddennnisss e2c0a2572d fix: добавить Content-Type: application/json и исправить input_data 2026-07-15 22:21:52 +07:00
dddennnisss d9f5ec16e1 fix: double-encode input_data для API Service Desk 2026-07-15 17:01:08 +07:00
dddennnisss eb35fcec82 fix: content= вместо data= для raw JSON body 2026-07-15 16:51:28 +07:00
dddennnisss d4dde34d90 fix: raw JSON body для API Service Desk 2026-07-15 16:40:58 +07:00
dddennnisss f09b693218 fix: добавить логирование ответа API для отладки 2026-07-15 16:16:14 +07:00
dddennnisss 88fca32156 fix: исправить API call (json вместо data) и добавить _sd_ticket_create_error 2026-07-15 16:08:56 +07:00
dddennnisss c4827ce559 feat: встроить шаблоны в SD_MAIN_MENU_TEXT, после выбора показать "Опишите проблему текстом" 2026-07-15 15:54:00 +07:00
dddennnisss 1a4f21cc20 fix: добавить sd_workflow_logger и относительный путь к config 2026-07-15 14:48:15 +07:00
dddennnisss 066f057003 feat: шаблоны показываются в main.py при нажатии 3, sd_module_handler только обрабатывает выбор 2026-07-15 14:37:14 +07:00
dddennnisss 067c8846b0 fix: добавить msg.handled=True чтобы sd_module_handler не перезаписал 2026-07-15 14:19:20 +07:00
dddennnisss 666435be3e feat: показать шаблоны вместо SD_MAIN_MENU_TEXT при нажатии 3 2026-07-15 13:58:24 +07:00
dddennnisss 914c964e08 fix: импортировать и инициализировать sd_sessions в main.py 2026-07-15 13:06:36 +07:00
dddennnisss 8267466ee6 fix: восстановить выбор шаблона после SD_MAIN_MENU_TEXT 2026-07-15 13:03:59 +07:00
dddennnisss b0439bd725 feat: встроить шаблоны в SD_MAIN_MENU_TEXT при нажатии 3 2026-07-15 13:02:38 +07:00
dddennnisss 39a2700555 fix: убрать SD_MAIN_MENU_TEXT из главного меню, показывать шаблоны 2026-07-15 12:52:30 +07:00
dddennnisss 691c112ace fix: инициализация шаблонов до need_text 2026-07-15 12:42:31 +07:00
dddennnisss f7aed57df4 fix: обновить тексты выбора шаблона 2026-07-15 12:38:30 +07:00
dddennnisss e07b546f08 feat: пагинация шаблонов Service Desk по 10 на страницу 2026-07-15 12:33:56 +07:00
dddennnisss efda2c8ae9 feat: добавить выбор шаблона Service Desk перед созданием заявки 2026-07-15 12:24:23 +07:00
35 changed files with 1545 additions and 2226 deletions
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env python3
"""
Проверка наличия пользователя в группе 2FA через Active Directory.
Использует credentials и настройки из config/config.py.
Запуск:
python check_ad_2fa.py
"""
import sys
import os
from ldap3 import Server, Connection, ALL, SUBTREE
# Подтягиваем конфиг
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASES
from utils.ad_search import search_by_filter as ad_search_all_bases
def find_user(login: str) -> dict | None:
"""Найти пользователя в AD по логину (sAMAccountName или mail).
Ищет по всем OU из AD_BASES."""
filter_login = login.lower().replace("\\", "\\5c").replace("*", "\\2a").replace("(", "\\28").replace(")", "\\29")
if "@" in filter_login:
ldap_filter = "(|{}{})".format("(mail={})".format(filter_login), "(sAMAccountName={})".format(filter_login))
else:
ldap_filter = "(|{}{})".format("(sAMAccountName={})".format(filter_login), "(mail={})".format(filter_login))
entries = ad_search_all_bases(ldap_filter, ["cn", "sAMAccountName", "memberOf"])
if not entries:
return None
entry = entries[0]
return {
"dn": entry.entry_dn,
"cn": str(entry.cn),
"sam": str(entry.sAMAccountName),
"groups": [str(g) for g in entry.memberOf],
}
def check_2fa_group(login: str) -> dict:
"""Проверить, входит ли пользователь в группу 2FA."""
user = find_user(login)
if user is None:
return {"found": False, "error": f"Пользователь '{login}' не найден в AD."}
# Ищем группу, содержащую "2FA" в имени
groups_2fa = [g for g in user["groups"] if "2FA" in g.upper()]
return {
"found": True,
"user": user,
"in_2fa_group": len(groups_2fa) > 0,
"matching_groups": groups_2fa,
}
def main():
print("\n" + "=" * 60)
print(" Проверка пользователя в группе 2FA (Active Directory)")
print("=" * 60)
print(f"\n Сервер AD: {AD_SERVER}")
print(f" Базы поиска: {config.AD_BASES}")
print(f" Подключено как: {AD_USER}")
print()
while True:
login = input(" Введите логин (sAMAccountName или email) или 'q' для выхода:\n > ").strip()
if not login or login.lower() == "q":
print("\n До свидания!\n")
break
result = check_2fa_group(login)
if not result["found"]:
print(f"\n{result['error']}")
continue
u = result["user"]
print(f"\n ✅ Пользователь найден:")
print(f" CN: {u['cn']}")
print(f" sAM: {u['sam']}")
print(f" DN: {u['dn']}")
print(f" Групп: {len(u['groups'])}")
if result["in_2fa_group"]:
print(f"\n 🟢 Пользователь ВХОДИТ в группу 2FA:")
for g in result["matching_groups"]:
print(f" {g}")
else:
print(f"\n 🔴 Пользователь НЕ найден в группе 2FA")
if u["groups"]:
print(f"\n Все группы пользователя ({len(u['groups'])}):")
for g in u["groups"]:
print(f" {g}")
print()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n Прервано пользователем.\n")
except Exception as e:
print(f"\n ❌ Ошибка: {e}")
sys.exit(1)
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env python3
"""
Проверочный скрипт: user_id -> CARD_ID -> телефон.
Интерактивный режим: после каждого запроса спрашивает,
хочешь ли продолжить.
config.py сам подтягивает все секреты из Passwork при импорте.
"""
import sys
import re
import logging
# ============================================================
# config.py при импорте заполняет globals():
# AD_USER, AD_PASSWORD, AD_SERVER, AD_BASES
# DB_USER, DB_PASSWORD, SQL_SERVER, SQL_DB_NAME
# ============================================================
sys.path.insert(0, "/opt/trueconf_bot")
from config import config
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger(__name__)
# ============================================================
# AD: user_id -> CARD_ID
# ============================================================
def get_card_id_from_ad(user_id: str) -> str:
"""Ищем пользователя в AD по всем OU из AD_BASES, возвращаем extensionAttribute2 (CARD_ID)."""
from utils.ad_search import search_by_user_id
entries = search_by_user_id(user_id, ["extensionAttribute2"])
if not entries:
raise Exception("Пользователь не найден в AD")
entry = entries[0]
if "extensionAttribute2" in entry and entry.extensionAttribute2.value:
card_id = str(entry.extensionAttribute2.value).strip()
log.info("CARD_ID: {}".format(card_id))
return card_id
raise Exception("extensionAttribute2 пуст для этого пользователя в AD")
# ============================================================
# SQL: CARD_ID -> телефон в PR_CARD
# ============================================================
def get_phone_from_db(card_id: str) -> str:
"""Ищем сотрудника в UOV_SELFSERVICE_PR_CARD по CARD_ID."""
import pymssql
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()
query = """
SELECT c.ID, c.PHONE, c.F_NAME, c.L_NAME, c.M_NAME
FROM UOV_SELFSERVICE_PR_CARD c
WHERE c.ID = %s
"""
cursor.execute(query, (card_id,))
row = cursor.fetchone()
conn.close()
if not row:
raise Exception("CARD_ID {} не найден в PR_CARD".format(card_id))
card_id_db = row[0]
phone = str(row[1]) if row[1] else "Не указан"
f_name = str(row[2]) if row[2] else ""
l_name = str(row[3]) if row[3] else ""
m_name = str(row[4]) if row[4] else ""
log.info("PR_CARD.ID: {}".format(card_id_db))
log.info("ФИО: {} {} {}".format(l_name, f_name, m_name))
log.info("PHONE: {}".format(phone))
return phone
# ============================================================
# MAIN
# ============================================================
def run_one():
"""Выполнить один запрос: ввод user_id -> CARD_ID -> телефон."""
user_id = input("\nВведите user_id (email или sAMAccountName): ").strip()
if not user_id:
print("user_id пустой, выход.")
sys.exit(1)
print()
print("[1] Поиск CARD_ID в Active Directory")
print(" Сервер: {}".format(config.AD_SERVER))
print(" Базы поиска: {}".format(config.AD_BASES))
print(" user_id: {}".format(user_id))
print()
try:
card_id = get_card_id_from_ad(user_id)
except Exception as e:
print("Ошибка AD: {}".format(e))
return None
print()
print("[2] Поиск телефона в базе BossCopy")
print(" Сервер: {}".format(config.SQL_SERVER))
print(" CARD_ID: {}".format(card_id))
print()
try:
phone = get_phone_from_db(card_id)
except Exception as e:
print("Ошибка SQL: {}".format(e))
return card_id
print()
print("=" * 70)
print(" ИТОГ")
print("=" * 70)
print(" user_id: {}".format(user_id))
print(" CARD_ID: {}".format(card_id))
print(" Телефон: {}".format(phone))
print("=" * 70)
print()
return card_id
def main():
print()
print("=" * 70)
print(" Проверка: user_id -> CARD_ID -> телефон")
print("=" * 70)
print()
while True:
run_one()
again = input("Продолжить? (y/n): ").strip().lower()
if again not in ("y", "yes", "д", "да"):
print("Готово.")
break
if __name__ == "__main__":
main()
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
Clean and chunk parsed PDF data from unstructured-api.
Filters noise, preserves structure, chunks to 1500-1800 chars.
"""
import json
import re
def load_parsed_data(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def clean_text(text):
if not text:
return ""
text = re.sub(r'\s+', ' ', text.strip())
return text.strip()
def is_noise(text):
"""Check if text is noise."""
text = clean_text(text)
if not text:
return True
# Company name header/footer
if text == "АО «ХК «Сибцем»":
return True
# Page numbers: "Стр. 2 из 10"
if re.match(r'^Стр\.\s+\d+\s+из\s+\d+$', text):
return True
# Standalone numbers: "4."
if re.match(r'^\d+\.$', text):
return True
# Table of contents
lines = text.split('\n')
if len(lines) > 3:
page_pattern = re.compile(r'\d+\s*$')
page_lines = sum(1 for l in lines if page_pattern.search(l.strip()))
if page_lines > len(lines) * 0.5:
return True
# Specific noise
noise_patterns = [
r'^Оглавление$',
r'^Содержание$',
r'^Редакция \d+$',
r'^Тип документа:',
r'^Наименование процесса:',
r'^Ведущее подразделение:',
r'^Дата утверждения:',
]
for pattern in noise_patterns:
if re.search(pattern, text):
return True
return False
def is_section_title(text):
"""Check if text is a section title like '4. Термины, определения и сокращения'."""
text = clean_text(text)
return bool(re.match(r'^\d+\.\s+\w', text))
def is_section_subtitle(text):
"""Check if text is a section subtitle like '6.7. Электронные...'."""
text = clean_text(text)
return bool(re.match(r'^\d+\.\d+\.\s+\w', text))
def is_section_header(text):
"""Check if text starts with a section/subsection number."""
text = clean_text(text)
return bool(re.match(r'^\d+(?:\.\d+)*[\.\s]', text))
def merge_elements(elements):
"""
Merge short fragments with context.
Strategy: merge consecutive non-title elements into text blocks.
Keep titles and tables as separate blocks.
"""
if not elements:
return []
# Filter noise first
filtered = [el for el in elements if not is_noise(el.get('text', ''))]
# Separate into blocks: text blocks, title blocks, table blocks
blocks = []
current_text_block = []
for el in filtered:
text = el.get('text', '')
el_type = el.get('type', '')
# Skip Image (OCR noise from cover page)
if el_type == 'Image':
continue
# If this is a title/section header, flush text block and add title
if el_type in ('Title',) and is_section_header(text):
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
current_text_block = []
blocks.append({
'text': text,
'type': 'Title',
'metadata': el.get('metadata', {})
})
continue
# If this is a table, flush text block and add table
if el_type == 'Table':
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
current_text_block = []
blocks.append({
'text': text,
'type': 'Table',
'metadata': el.get('metadata', {})
})
continue
# Otherwise, accumulate into text block
current_text_block.append(el)
# Flush remaining text block
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
# Now merge short text blocks with adjacent content
# Specifically: merge short blocks that are continuations of previous sections
result = []
for i, block in enumerate(blocks):
text = block['text']
el_type = block['type']
clean = clean_text(text)
# If this is a short text block, try to merge with previous
if len(clean) < 60 and el_type == 'NarrativeText' and result:
# Check if it looks like a continuation (section subtitle or fragment)
if is_section_subtitle(clean) or not clean[0].isdigit():
# Merge with previous block
result[-1]['text'] = f"{result[-1]['text']} {clean}"
continue
result.append(block)
return result
def chunk_text(text, max_chunk_size=1800):
"""Split text into chunks of ~1500-1800 characters."""
if len(text) <= max_chunk_size:
return [text]
# Try to split by paragraphs first
paragraphs = re.split(r'\n+', text)
if len(paragraphs) > 1:
chunks = []
current = ""
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(current) + len(para) + 1 <= max_chunk_size:
current = f"{current}\n{para}"
else:
if current:
chunks.append(current)
current = para
if current:
chunks.append(current)
return chunks
# Split by sentences
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) + 1 <= max_chunk_size:
current = f"{current} {sentence}"
else:
if current:
chunks.append(current)
current = sentence
if current:
chunks.append(current)
return chunks
def process_elements(elements, max_chunk_size=1800):
"""Process elements into chunks."""
# Merge blocks
merged = merge_elements(elements)
# Create chunks
chunks = []
chunk_index = 0
for el in merged:
text = el.get('text', '')
el_type = el.get('type', 'Unknown')
page = el.get('metadata', {}).get('page_number', '?')
# Skip very short chunks
if len(text.strip()) < 10:
continue
# Split long texts
parts = chunk_text(text, max_chunk_size)
for part in parts:
chunk_index += 1
chunks.append({
'index': chunk_index,
'type': el_type,
'page': page,
'text': part,
'size': len(part)
})
return chunks, merged
def main():
parsed_path = '/tmp/pol177_parsed.json'
output_path = '/tmp/pol177_clean_chunk.json'
print("Loading parsed data...")
elements = load_parsed_data(parsed_path)
print(f"Loaded {len(elements)} elements")
print("Processing...")
chunks, merged = process_elements(elements)
print(f"Generated {len(chunks)} chunks")
# Stats
if chunks:
sizes = [c['size'] for c in chunks]
print(f"Chunk sizes: min={min(sizes)}, max={max(sizes)}, avg={sum(sizes)/len(sizes):.0f}")
# Type distribution
types = {}
for c in chunks:
types[c['type']] = types.get(c['type'], 0) + 1
print(f"Chunk types: {types}")
# Show all chunks
print("\n=== All chunks ===")
for c in chunks:
print(f"[{c['index']}] {c['type']} (page {c['page']}, {c['size']} chars)")
print(f" {c['text'][:120]}")
print()
if __name__ == '__main__':
main()
Binary file not shown.
Binary file not shown.
+2 -14
View File
@@ -85,7 +85,6 @@ ENABLE_SERVICE_DESK = True # Модуль создания заявок в
ENABLE_PHOTO_BOT = True # Модуль обработки фото/сканов
ENABLE_TRANSCRIPTION = True # Модуль расшифровки голосовых сообщений (Whisper)
ENABLE_SEARCH_BOT = True # Модуль интеллектуального поиска по регламентам (RAG)
ENABLE_INSTRUCT = True # Модуль инструкций (Trueconf, Почта)
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# =========================================================
@@ -95,9 +94,7 @@ ENABLE_WHITELIST = True # Включить режим закрытого
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
"ds.krivochenko@tcs.sibcem.ru",
"mm.norenberg@tcs.sibcem.ru",
"a.kirpichev@tcs.sibcem.ru",
"bot_test@tcs.sibcem.ru",
"v.s.sholohov@tcs.sibcem.ru"
"a.kirpichev@tcs.sibcem.ru"
]
# =========================================================
@@ -106,10 +103,7 @@ ALLOWED_USERS = [ # Список TrueConf ID разрешенны
TC_SERVER = os.getenv("TC_SERVER", "") # Адрес сервера TrueConf
AD_SERVER = "ldap://172.16.20.20" # Адрес контроллера домена Active Directory
AD_BASES = ( # Базовые пути поиска пользователей в AD (несколько OU)
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru" # Базовый путь поиска пользователей в AD
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru" # Сервер MS SQL для работы Личного кабинета
SQL_DB_NAME = "BossCopy" # База данных с информацией по кадрам и ЗП
@@ -223,9 +217,3 @@ OTP_MAX_ATTEMPTS = 3
OTP_BLOCK_DURATION_SEC = 1800
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
SESSION_TIMEOUT = 1800
# =========================================================
# 11. МОДУЛЬ INSTRUCT: НАСТРОЙКИ SERVICE DESK: ГРУППА И ИСПОЛНИТЕЛЬ
# =========================================================
SD_INSTRUCT_GROUP_NAME = "Техподдержка ХК" # Название группы в ServiceDesk
SD_INSTRUCT_TECHNICIAN_NAME = "Кривоченко Денис Сергеевич" # ФИО техника в ServiceDesk
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""Debug: print the exact LDAP filter that works in lk/handlers.py"""
import sys
sys.path.insert(0, "/opt/trueconf_bot")
from config.config import AD_USER, AD_PASSWORD, AD_SERVER, AD_BASES
from utils.ad_search import search_by_filter as ad_search_all_bases
from ldap3.utils.conv import escape_filter_chars
# Simulate lk/handlers.py filter
user_id = "ds.krivochenko@tcs.sibcem.ru"
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
short_username = search_id.split("@")[0]
safe_user_id = escape_filter_chars(search_id)
print(f"user_id: {user_id}")
print(f"search_id: {search_id}")
print(f"short_username: {short_username}")
print(f"safe_user_id: {safe_user_id}")
# lk/handlers.py filter
filter_lk = "(&(objectClass=user)(|(mail={})(userPrincipalName={})(userPrincipalName={}{}{}))({}))".format(
safe_user_id,
safe_user_id,
escape_filter_chars(short_username),
"@sibcem.ru",
"",
escape_filter_chars(short_username),
)
print(f"\nFilter (lk format):")
print(filter_lk)
# Count parens
opens = filter_lk.count('(')
closes = filter_lk.count(')')
print(f"\nOpen parens: {opens}, Close parens: {closes}")
# Now test with the connection
print(f"\nSearching with filter across all AD_BASES...")
entries = ad_search_all_bases(filter_lk, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
if entries:
entry = entries[0]
print(f"FOUND: cn={entry.cn}, mail={entry.mail if 'mail' in entry else 'N/A'}, sam={entry.sAMAccountName if 'sAMAccountName' in entry else 'N/A'}")
else:
print("NOT FOUND in any AD_BASE")
# Try root
root_base = "DC=sibcem,DC=ru"
print(f"\nSearching in {root_base}...")
from utils.ad_search import search_by_filter
entries2 = search_by_filter(filter_lk, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
if entries2:
entry = entries2[0]
print(f"FOUND: cn={entry.cn}, mail={entry.mail if 'mail' in entry else 'N/A'}, sam={entry.sAMAccountName if 'sAMAccountName' in entry else 'N/A'}")
else:
print("NOT FOUND in root")
View File
-557
View File
@@ -1,557 +0,0 @@
import logging
import asyncio
import os
import sys
import json
import httpx
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from trueconf import Router, Message
from trueconf.types import FSInputFile
from utils.texts import (
UNKNOWN_MAIN_CMD_TEXT,
EMOJI_DIGITS,
INSTRUCT_MAIN_MENU_TEXT,
INSTRUCT_TRUECONF_TEXT,
INSTRUCT_TRUECONF_WITH_AD,
INSTRUCT_TRUECONF_PENDING_TEXT,
INSTRUCT_EMAIL_AVAILABLE,
INSTRUCT_EMAIL_HISTORY_ASK,
INSTRUCT_EMAIL_NOT_HAD,
INSTRUCT_EMAIL_UNAVAILABLE,
INSTRUCT_ERROR_MSG,
)
from utils.states import get_state, set_state, clear_state
from utils.stats_logger import log_menu_stats
# Безопасный импорт конфигурации
try:
from config.config import (
DEFAULT_REQUESTER, SD_URL, SD_TOKEN,
SD_INSTRUCT_GROUP_NAME, SD_INSTRUCT_TECHNICIAN_NAME,
SMTP_SERVER, SMTP_PORT, DEFAULT_EMAIL_FROM, ALERTS_SUPPORT_EMAILS
)
except ImportError:
import importlib.util
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py")
spec = importlib.util.spec_from_file_location("custom_config", config_path)
custom_config = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_config)
DEFAULT_REQUESTER = custom_config.DEFAULT_REQUESTER
SD_URL = custom_config.SD_URL
SD_TOKEN = custom_config.SD_TOKEN
SD_INSTRUCT_GROUP_NAME = custom_config.SD_INSTRUCT_GROUP_NAME
SD_INSTRUCT_TECHNICIAN_NAME = custom_config.SD_INSTRUCT_TECHNICIAN_NAME
SMTP_SERVER = getattr(custom_config, "SMTP_SERVER", "srvkem-mail.sibcem.ru")
SMTP_PORT = getattr(custom_config, "SMTP_PORT", 25)
DEFAULT_EMAIL_FROM = getattr(custom_config, "DEFAULT_EMAIL_FROM", "ai@sibcem.ru")
ALERTS_SUPPORT_EMAILS = getattr(custom_config, "ALERTS_SUPPORT_EMAILS", ["ds.krivochenko@sibcem.ru"])
logger = logging.getLogger(__name__)
router = Router()
TRUECONF_INSTRUCT_PATH = os.path.join(
os.path.dirname(__file__),
"Проверка_наличия_и_авторизация_на_мобильном_устройстве_Trueconf.docx"
)
GMAIL_ANDROID_INSTRUCT_PATH = os.path.join(
os.path.dirname(__file__),
"Инструкция по настройке электронной почты GMAIL на Android.docx"
)
APPLE_INSTRUCT_PATH = os.path.join(
os.path.dirname(__file__),
"Инструкция по настройке электронной почты на устройствах Apple.docx"
)
# ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ОТПРАВКИ EMAIL УВЕДОМЛЕНИЙ
def _send_email_sync(to_emails: list[str], subject: str, body_html: str):
"""Синхронная отправка письма по SMTP"""
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = DEFAULT_EMAIL_FROM
msg["To"] = ", ".join(to_emails)
part_html = MIMEText(body_html, "html", "utf-8")
msg.attach(part_html)
with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
server.sendmail(DEFAULT_EMAIL_FROM, to_emails, msg.as_string())
logger.info(f"📧 Уведомление о заявке 2FA успешно отправлено на {to_emails}")
except Exception as e:
logger.error(f"❌ Ошибка отправки почтового уведомления 2FA: {e}", exc_info=True)
async def send_2fa_alert_email(cn: str, user_id: str, ticket_id: str):
"""Фоновая асинхронная задача формирования и отправки уведомления"""
if not ALERTS_SUPPORT_EMAILS:
logger.warning("ALERTS_SUPPORT_EMAILS пуст. Уведомление на почту пропущено.")
return
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
subject = f"🎫 [2FA Request] Создана заявка #{ticket_id} на доступ к Trueconf ({cn})"
ticket_link = f"{SD_URL.rstrip('/')}/WorkOrder.do?woMode=viewWO&woID={ticket_id}"
body_html = f"""
<html>
<body style="font-family: Arial, sans-serif; color: #333;">
<h2>🎫 Создана новая заявка на доступ к Trueconf (2FA)</h2>
<p><b>Сотрудник:</b> {cn}</p>
<p><b>Логин / Email:</b> {clean_user_id}</p>
<p><b>Номер заявки в ServiceDesk:</b> <a href="{ticket_link}">#{ticket_id}</a></p>
<p><b>Назначенная группа:</b> {SD_INSTRUCT_GROUP_NAME}</p>
<p><b>Исполнитель:</b> {SD_INSTRUCT_TECHNICIAN_NAME}</p>
<hr style="border: 0; border-top: 1px solid #ccc;">
<p style="font-size: 12px; color: #777;"><i>Сообщение сгенерировано автоматически цифровым ассистентом КЛЕВЕР.</i></p>
</body>
</html>
"""
await asyncio.to_thread(_send_email_sync, ALERTS_SUPPORT_EMAILS, subject, body_html)
# ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ ПРОВЕРКИ ЗАКРЫТОГО СТАТУСА ЗАЯВКИ
def _is_ticket_closed(status_data) -> bool:
"""
Проверяет, является ли статус заявки закрытым / исполненным.
Использует поиск по основам слов для улавливания всех форматов и склонений.
"""
if not status_data:
return False
if isinstance(status_data, dict):
status_str = str(status_data.get("name", "")).strip().lower()
else:
status_str = str(status_data).strip().lower()
closed_keywords = [
"closed", "close", "resolved", "resolv", "solved", "solv",
"rejected", "reject", "canceled", "cancel", "completed", "complete",
"закрыт", "решен", "исполн", "выполн", "отмен", "отклон", "заверш"
]
return any(kw in status_str for kw in closed_keywords)
# ОСНОВНАЯ ЛОГИКА АКТИВНОСТИ
def get_user_cn(user_id: str) -> str | None:
"""Получить CN пользователя из AD по его user_id."""
from utils.ad_search import search_by_user_id
try:
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
short_username = search_id.split("@")[0] if "@" in search_id else user_id
logger.info(f"AD lookup CN: user_id={user_id}, search_id={search_id}, short={short_username}")
entries = search_by_user_id(search_id, ["cn", "sAMAccountName", "mail", "userPrincipalName", "l", "userAccountControl"])
if entries:
entry = entries[0]
cn = str(entry.cn)
mail = str(entry.mail) if 'mail' in entry else ""
sam = str(entry.sAMAccountName) if 'sAMAccountName' in entry else "N/A"
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else "N/A"
logger.info(f"AD found: cn={cn}, mail={mail}, sam={sam}, upn={upn}")
return cn
logger.warning(f"AD not found for user_id={user_id}, search_id={search_id}, short={short_username}")
except Exception as e:
logger.error(f"Ошибка получения CN из AD для {user_id}: {e}", exc_info=True)
return None
def get_ad_user_info(user_id: str) -> dict | None:
"""Получить полную информацию о пользователе из AD."""
from utils.ad_search import search_by_user_id
try:
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
entries = search_by_user_id(search_id, ["cn", "mail", "l", "userAccountControl", "userPrincipalName"])
if entries:
entry = entries[0]
cn = str(entry.cn)
raw_mail = str(entry.mail) if 'mail' in entry else ""
if raw_mail in ("[]", "N/A", ""):
raw_mail = None
mail = raw_mail if raw_mail else None
if not mail:
raw_upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
if raw_upn and raw_upn not in ("[]", "N/A", ""):
mail = raw_upn
city = str(entry.l) if 'l' in entry and entry.l.value else "Кемерово"
return {"cn": cn, "mail": mail, "raw_mail": raw_mail, "city": city}
except Exception as e:
logger.error(f"Ошибка получения AD info для {user_id}: {e}", exc_info=True)
return None
async def _send_sd_api_request(
requester_cn: str,
requester_email: str,
subject: str,
description: str,
city: str
) -> str | None:
"""Автономная прямая отправка HTTP-запроса в Service Desk API v3."""
if requester_cn and requester_cn != "N/A":
requester_payload = {"name": requester_cn}
elif requester_email and requester_email not in (DEFAULT_REQUESTER, "ai@sibcem.ru"):
requester_payload = {"email_id": requester_email}
else:
requester_payload = {"name": "Искусственный Интеллект"}
payload = {
"request": {
"subject": subject,
"description": description,
"requester": requester_payload,
"udf_fields": {
"udf_pick_301": city
}
}
}
if SD_INSTRUCT_GROUP_NAME:
payload["request"]["group"] = {"name": SD_INSTRUCT_GROUP_NAME}
if SD_INSTRUCT_TECHNICIAN_NAME:
payload["request"]["technician"] = {"name": SD_INSTRUCT_TECHNICIAN_NAME}
url = f"{SD_URL.rstrip('/')}/api/v3/requests"
headers = {"TECHNICIAN_KEY": SD_TOKEN}
data = {"input_data": json.dumps(payload)}
async with httpx.AsyncClient(verify=False) as client:
response = await client.post(url, headers=headers, data=data, timeout=15.0)
if response.status_code in (200, 201):
res_json = response.json()
ticket_id = res_json.get("request", {}).get("id")
return str(ticket_id) if ticket_id else None
elif response.status_code == 400 and requester_payload != {"name": "Искусственный Интеллект"}:
logger.warning(
f"ServiceDesk не смог привязать requester={requester_payload}. "
f"Повторяем запрос от имени системного заявителя..."
)
payload["request"]["requester"] = {"name": "Искусственный Интеллект"}
payload["request"]["description"] = f"[Пользователь: {requester_cn}]\n\n{description}"
data_fb = {"input_data": json.dumps(payload)}
resp_fb = await client.post(url, headers=headers, data=data_fb, timeout=15.0)
if resp_fb.status_code in (200, 201):
ticket_id = resp_fb.json().get("request", {}).get("id")
return str(ticket_id) if ticket_id else None
logger.error(f"Service Desk API Error ({response.status_code}): {response.text}")
return None
async def _find_existing_trueconf_ticket(cn: str, requester_email: str = None) -> str | None:
"""Находит открытую или находящуюся в работе заявку по Trueconf."""
subject_keyword = "запрос доступа к trueconf"
try:
url = f"{SD_URL.rstrip('/')}/api/v3/requests"
headers = {"TECHNICIAN_KEY": SD_TOKEN}
input_data = {
"list_info": {
"row_count": 100,
"sort_field": "created_time",
"sort_order": "desc"
}
}
async with httpx.AsyncClient(verify=False) as client:
resp = await client.get(url, headers=headers, params={"input_data": json.dumps(input_data)}, timeout=10.0)
if resp.status_code != 200:
logger.warning(f"SD GET list_info returned status {resp.status_code}. Retrying pure GET...")
resp = await client.get(url, headers=headers, timeout=10.0)
if resp.status_code == 200:
data = resp.json()
requests_list = data.get("requests", [])
logger.info(f"Scanning {len(requests_list)} recent tickets for CN='{cn}', Email='{requester_email}'")
for req in requests_list:
req_subject = str(req.get("subject", "")).lower()
if subject_keyword not in req_subject:
continue
req_id = req.get("id")
req_status = req.get("status")
# 1. Проверяем статус в общем списке
if _is_ticket_closed(req_status):
logger.info(f"Skipping closed ticket #{req_id} (list status: '{req_status}')")
continue
logger.info(f"Checking candidate open ticket #{req_id}")
detail_url = f"{SD_URL.rstrip('/')}/api/v3/requests/{req_id}"
detail_resp = await client.get(detail_url, headers=headers, timeout=10.0)
if detail_resp.status_code == 200:
detail = detail_resp.json().get("request", {})
# 2. Повторно проверяем статус в деталях заявки
detail_status = detail.get("status")
if _is_ticket_closed(detail_status):
logger.info(f"Skipping closed ticket #{req_id} (detail status: '{detail_status}')")
continue
requester = detail.get("requester", {})
req_name = str(requester.get("name", "") or requester.get("display_name", "")).strip().lower()
req_email = str(requester.get("email", "") or requester.get("email_id", "")).strip().lower()
req_desc = str(detail.get("description", "") or "").lower()
cn_clean = cn.strip().lower()
if cn_clean and cn_clean in req_name:
logger.info(f"Matched ticket #{req_id} by requester name ('{req_name}')")
return str(req_id)
if (
requester_email
and requester_email.lower() not in (DEFAULT_REQUESTER.lower(), "ai@sibcem.ru")
and req_email == requester_email.lower()
):
logger.info(f"Matched ticket #{req_id} by email ('{req_email}')")
return str(req_id)
if cn_clean and (f"[пользователь: {cn_clean}]" in req_desc or cn_clean in req_desc):
logger.info(f"Matched ticket #{req_id} by user mention in description")
return str(req_id)
else:
logger.error(f"Failed to fetch SD requests list completely, HTTP {resp.status_code}: {resp.text[:200]}")
except Exception as e:
logger.warning(f"Failed to search existing tickets: {e}", exc_info=True)
return None
async def create_sd_ticket_for_trueconf(user_id: str, cn: str) -> str | None:
"""Создать заявку в Service Desk для запроса доступа к Trueconf."""
subject = "Запрос доступа к Trueconf"
clean_user_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
ad_info = get_ad_user_info(user_id)
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
city = ad_info.get("city", "Кемерово") if ad_info else "Кемерово"
# Защита от дублей
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
if existing_ticket:
logger.info(f"Ticket creation skipped: open ticket #{existing_ticket} already exists for {cn}")
return existing_ticket
description = (
f"Пользователь {cn} ({clean_user_id}) запросил инструкцию по Trueconf, "
f"но не имеет группы 2FA. Требуется выдача доступа."
)
try:
logger.info(f"Calling SD API directly: cn='{cn}', email='{requester_email}', city='{city}'")
ticket_id = await _send_sd_api_request(cn, requester_email, subject, description, city)
if ticket_id:
logger.info(f"SD ticket created for {cn}: #{ticket_id}")
# 📧 ОТПРАВЛЯЕМ ПОЧТОВОЕ УВЕДОМЛЕНИЕ В ФОНЕ
asyncio.create_task(send_2fa_alert_email(cn, user_id, ticket_id))
else:
logger.error(f"Failed to create SD ticket for {cn}")
return ticket_id
except Exception as e:
logger.error(f"Ошибка создания SD заявки для {cn}: {e}", exc_info=True)
return None
@router.message()
async def instruct_router_handler(msg: Message):
user_id = msg.from_user.id
user_name = getattr(msg.from_user, 'name', 'N/A')
user_username = getattr(msg.from_user, 'username', 'N/A')
logger.info(f"Instruct: user_id={user_id}, name={user_name}, username={user_username}")
current_state = get_state(user_id)
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) == "INSTRUCT":
msg.handled = True
set_state(user_id, "INSTRUCT_MODE")
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
return
return
if current_state == "INSTRUCT_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":
log_menu_stats(user_id, "Инструкции", "Trueconf")
cn = get_user_cn(user_id)
if cn is None:
cn = user_id
from utils.ad_checker import check_group_membership
ad_check = check_group_membership(cn, "2FA")
if ad_check.get("in_group", False):
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, None)
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
await msg.bot.send_document(
chat_id=msg.chat_id,
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
caption=text,
parse_mode="html"
)
else:
await msg.answer(text, parse_mode="html")
else:
ad_info = get_ad_user_info(user_id)
requester_email = (ad_info.get("mail") if ad_info else None) or DEFAULT_REQUESTER
logger.info(f"[DEBUG] Checking existing ticket for cn={cn}, email={requester_email}")
existing_ticket = await _find_existing_trueconf_ticket(cn, requester_email)
logger.info(f"[DEBUG] existing_ticket result: {existing_ticket}")
if existing_ticket:
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, existing_ticket)
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
await msg.bot.send_document(
chat_id=msg.chat_id,
file=FSInputFile(TRUECONF_INSTRUCT_PATH, file_name="Инструкция.docx"),
caption=text,
parse_mode="html"
)
else:
await msg.answer(text, parse_mode="html")
else:
set_state(user_id, "INSTRUCT_TRUECONF_PENDING")
text = INSTRUCT_TRUECONF_PENDING_TEXT(EMOJI_DIGITS, cn)
await msg.answer(text, parse_mode="html")
elif cmd == "2":
log_menu_stats(user_id, "Инструкции", "Почта")
cn = get_user_cn(user_id)
if cn is None:
cn = user_id
ad_info = get_ad_user_info(user_id)
raw_mail = ad_info.get("raw_mail") if ad_info else None
if raw_mail:
# Пользователь с доступом к почте — спрашиваем про прошлый доступ
set_state(user_id, "INSTRUCT_EMAIL_HISTORY")
text = INSTRUCT_EMAIL_HISTORY_ASK(EMOJI_DIGITS, cn)
await msg.answer(text, parse_mode="html")
else:
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
text = INSTRUCT_EMAIL_UNAVAILABLE(EMOJI_DIGITS, cn)
await msg.answer(text, parse_mode="html")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "INSTRUCT_TRUECONF_PENDING":
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, "INSTRUCT_MODE")
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
elif cmd == "1":
cn = get_user_cn(user_id)
if cn is None:
cn = user_id
ticket_id = await create_sd_ticket_for_trueconf(user_id, cn)
if ticket_id:
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, ticket_id)
await msg.answer(text, parse_mode="html")
else:
await msg.answer(INSTRUCT_ERROR_MSG, parse_mode="html")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "INSTRUCT_EMAIL_HISTORY":
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, "INSTRUCT_MODE")
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
elif cmd == "1":
# Да, был доступ — показываем инструкции
log_menu_stats(user_id, "Инструкции", "Почта (был доступ)")
cn = get_user_cn(user_id)
if cn is None:
cn = user_id
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
text = INSTRUCT_EMAIL_AVAILABLE(EMOJI_DIGITS, cn, "")
await msg.answer(text, parse_mode="html")
for instruct_path, file_name in [
(GMAIL_ANDROID_INSTRUCT_PATH, "Инструкция Android.docx"),
(APPLE_INSTRUCT_PATH, "Инструкция Apple.docx"),
]:
if os.path.isfile(instruct_path):
await msg.bot.send_document(
chat_id=msg.chat_id,
file=FSInputFile(instruct_path, file_name=file_name),
)
elif cmd == "2":
# Нет, не было доступа — показываем текст про заявку
log_menu_stats(user_id, "Инструкции", "Почта (не было доступа)")
cn = get_user_cn(user_id)
if cn is None:
cn = user_id
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
text = INSTRUCT_EMAIL_NOT_HAD(EMOJI_DIGITS, cn)
await msg.answer(text, parse_mode="html")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state in ["INSTRUCT_TRUECONF_VIEW", "INSTRUCT_EMAIL_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":
set_state(user_id, "INSTRUCT_MODE")
await msg.answer(INSTRUCT_MAIN_MENU_TEXT, parse_mode="html")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
+35 -98
View File
@@ -57,7 +57,7 @@ EMOJI_DIGITS = {
"10": "🔟"
}
# Список из 7 справок строго по структуре портала
# Список из 7 справок строго по структуру портала
DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС",
@@ -74,7 +74,7 @@ LK_DOCS_MENU_TEXT = (
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
USER_COOLDOWN_SEC = 0.5
USER_COOLDOWN_SEC = 1.5
EMAIL_COOLDOWN_SEC = 10.0
user_last_request = {}
@@ -122,24 +122,26 @@ def log_and_notify_error(user_id: str, action: str, error_details: str):
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 = system_error_text(EMOJI_DIGITS)
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:
from utils.ad_search import search_by_user_id
try:
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
entries = search_by_user_id(search_id, attributes=['extensionAttribute2'])
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)})))"
if entries and 'extensionAttribute2' in entries[0] and entries[0].extensionAttribute2.value:
return str(entries[0].extensionAttribute2.value).strip()
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}")
except Exception as e: raise Exception(f"AD Card Query Error: {e}")
# =========================================================
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
@@ -198,64 +200,18 @@ async def send_dynamic_lk_main_menu(msg: Message, user_id: str):
)
except Exception as e:
logger.error(f"Не удалось собрать динамический заголовок ЛК: {e}")
await handle_lk_error(msg, user_id, "Сборка заголовка ЛК", str(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):
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
months_ru = {
1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь",
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"
}
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: "Декабрь"}
now = datetime.now()
# Генерируем список из 4 месяцев: текущий, прошлый, позапрошлый, за два месяца
candidates = []
m = now
for _ in range(4):
candidates.append((m.year, m.month))
m = m.replace(day=1) - timedelta(days=1)
# Проверяем какие месяцы имеют данные в БД
import lk.info_service as info_service
conn = info_service.get_db_connection()
cursor = conn.cursor()
try:
available = []
for year, month in candidates:
cursor.execute(
"SELECT TOP 1 1 FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID IN (SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID=%s) AND YEAR(D)=%s AND MONTH(D)=%s",
(card_id, year, month)
)
if cursor.fetchone():
available.append((year, month))
finally:
conn.close()
if not available:
await msg.answer("⚠️ <b>Данные табеля отсутствуют.</b>\n\nЗа указанный период данные в системе не найдены.", parse_mode="html")
return
# Строим динамическое меню
lines = ["⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"]
for idx, (year, month) in enumerate(available, 1):
digit = EMOJI_DIGITS.get(str(idx), str(idx))
lines.append(f"{digit}{months_ru[month]} {year} г.")
lines.append(f"\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>")
menu_text = "\n".join(lines)
# Сохраняем доступные месяцы в сессию для обработчика
user_page_sessions[user_id] = {
"tabel_months": available,
"current_page": 0,
"per_page": 5,
"static_header": "",
"items": []
}
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")
@@ -311,22 +267,13 @@ async def build_confirmation_text(user_id: str, doc: dict) -> str:
async def lk_router_handler(msg: Message):
user_id = msg.from_user.id
current_time = time.time()
current_state = get_state(user_id)
# Если пользователь находится внутри меню ЛК, помечаем сообщение обработанным,
# чтобы оно не улетало в ServiceDesk / Instruct роутеры
if current_state and current_state.startswith("LK_"):
msg.handled = True
# Проверка на слишком частые клики (кулдаун)
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC:
return
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
if not msg.text: return
cmd = msg.text.strip().lower()
for raw_num, emoji_num in EMOJI_DIGITS.items():
@@ -425,6 +372,7 @@ async def lk_router_handler(msg: Message):
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":
@@ -436,31 +384,17 @@ async def lk_router_handler(msg: Message):
elif cmd == "9":
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
elif cmd.isdigit():
session = user_page_sessions.get(user_id)
if not session or not session.get("tabel_months"):
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
idx = int(cmd) - 1
available = session["tabel_months"]
if idx < 0 or idx >= len(available):
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
target_year, target_month = available[idx]
log_menu_stats(user_id, "Личный кабинет", f"Просмотр табеля ({target_month}.{target_year})")
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)
tabel_report = await asyncio.to_thread(
get_tabel_report_text,
card_id,
target_year,
target_month
)
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))
except Exception as e: await handle_lk_error(msg, user_id, "Формирование табеля", str(e))
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
@@ -506,8 +440,7 @@ async def lk_router_handler(msg: Message):
return
if not session:
logger.warning(f"Сессия авторизации не найдена для пользователя {user_id}")
await handle_lk_error(msg, user_id, "Сессия авторизации не найдена", f"user_id={user_id}")
await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
return
@@ -621,8 +554,7 @@ async def lk_router_handler(msg: Message):
else:
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
else:
logger.warning(f"Неверный индекс месяца для пользователя {user_id}: cmd={cmd}")
await handle_lk_error(msg, user_id, "Неверный индекс месяца", f"user_id={user_id}, cmd={cmd}")
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")
@@ -646,6 +578,7 @@ async def lk_router_handler(msg: Message):
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":
@@ -673,6 +606,7 @@ async def lk_router_handler(msg: Message):
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":
@@ -685,12 +619,14 @@ async def lk_router_handler(msg: Message):
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":
@@ -734,6 +670,7 @@ async def lk_router_handler(msg: Message):
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":
@@ -741,5 +678,5 @@ async def lk_router_handler(msg: Message):
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")
-265
View File
@@ -1,265 +0,0 @@
#!/usr/bin/env python3
"""Диагностика данных сотрудника для модуля ЛК (расчетnye listki)."""
import os
import sys
BASE_DIR = "/opt/trueconf_bot"
os.chdir(BASE_DIR)
# Загрузка .env
def load_env(filepath):
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
value = value.strip(' "')
value = value.strip("'")
value = value.strip()
os.environ[key.strip()] = value
load_env(os.path.join(BASE_DIR, "config", ".env"))
# Загрузка passwork
for pw_path in ["/opt/passwork", os.path.join(BASE_DIR, "passwork")]:
if os.path.isdir(pw_path) and pw_path not in sys.path:
sys.path.insert(0, pw_path)
from passwork import get_passwork_secrets
card_name = os.getenv("PW_ID_SQL", "").strip()
passwork_pool = get_passwork_secrets(required_cards=[card_name])
card_data = passwork_pool[card_name]
DB_USER = card_data["login"]
DB_PASSWORD = card_data["password"]
SQL_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
SQL_DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
import pymssql
import ldap3
import calendar
# --- Подключение к AD ---
print("=" * 60)
print("Диагностика сотрудника: la.guseynova@sibcem.ru")
print("=" * 60)
email = "la.guseynova@sibcem.ru"
search_id = email.replace("@tcs.sibcem.ru", "@sibcem.ru")
short_name = search_id.split("@")[0]
print(f"\n--- 0. Поиск в AD ---")
print(f" Email: {email}")
print(f" Search ID: {search_id}")
print(f" Short (без домена): {short_name}")
AD_SERVER = "172.16.20.20"
AD_BASE = "DC=sibcem,DC=ru"
try:
conn = ldap3.Connection(
ldap3.Server(AD_SERVER, connect_timeout=5),
auto_bind=True,
read_only=True
)
search_filter = f"(|(mail={search_id}*)(sAMAccountName={short_name})(userPrincipalName={search_id}*)(anr={short_name}))"
print(f" LDAP-фильтр: {search_filter}")
conn.search(
search_base=AD_BASE,
search_filter=search_filter,
attributes=["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2", "mobile", "personalPager"]
)
if conn.entries:
entry = conn.entries[0]
cn = str(entry.cn)
sam = str(entry.sAMAccountName)
mail = str(entry.mail) if 'mail' in entry else ""
upn = str(entry.userPrincipalName) if 'userPrincipalName' in entry else ""
ext2 = str(entry.extensionAttribute2) if 'extensionAttribute2' in entry else ""
mobile = str(entry.mobile) if 'mobile' in entry else ""
pager = str(entry.personalPager) if 'personalPager' in entry else ""
print(f"\n НАЙДЕН:")
print(f" CN: {cn}")
print(f" sAMAccountName: {sam}")
print(f" mail: {mail}")
print(f" UPN: {upn}")
print(f" extensionAttribute2 (CARD_ID): {ext2}")
print(f" mobile: {mobile}")
print(f" pager: {pager}")
else:
print(" Не найден!")
conn.unbind()
sys.exit(0)
conn.unbind()
except Exception as e:
print(f" Ошибка подключения к AD: {e}")
sys.exit(0)
# --- Подключение к SQL ---
print(f"\n--- 1. Подключение к SQL ---")
try:
conn_sql = pymssql.connect(
server=SQL_SERVER,
user=DB_USER,
password=DB_PASSWORD,
database=SQL_DB_NAME,
charset="cp1251",
login_timeout=10
)
cursor = conn_sql.cursor()
print(f" Подключено к {SQL_SERVER}/{SQL_DB_NAME}")
except Exception as e:
print(f" Ошибка подключения к SQL: {e}")
sys.exit(0)
# --- Получаем CARD_ID ---
card_id = None
print(f"\n--- 2. Поиск CARD_ID в PR_CARD ---")
cursor.execute(
"SELECT c.ID FROM UOV_SELFSERVICE_PR_CARD c "
"JOIN UOV_SELFSERVICE_PR_EMP e ON e.CARD_ID = c.ID "
"WHERE e.EMAIL_ID LIKE %s OR e.USERNAME LIKE %s",
(f"%{short_name}%", f"%{short_name}%")
)
rows = cursor.fetchall()
if rows:
for r in rows:
print(f" CARD_ID (по username): {r[0]}")
card_id = rows[0][0]
else:
print(f" Не найден по username")
if not card_id:
cursor.execute(
"SELECT c.ID FROM UOV_SELFSERVICE_PR_CARD c "
"JOIN UOV_SELFSERVICE_PR_EMP e ON e.CARD_ID = c.ID "
"WHERE e.EMAIL_ID LIKE %s",
(f"%{search_id.split('@')[0]}%",)
)
rows = cursor.fetchall()
if rows:
for r in rows:
print(f" CARD_ID (по email): {r[0]}")
card_id = rows[0][0]
else:
print(f" CARD_ID не найден!")
conn_sql.close()
sys.exit(0)
print(f"\n Используем CARD_ID: {card_id}")
# --- PR_EMP ---
print(f"\n--- 3. PR_EMP (профиль сотрудника) ---")
cursor.execute(
"SELECT ID, FIRM_ID, EMAIL_ID, USERNAME FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID = %s",
(card_id,)
)
emp_rows = cursor.fetchall()
if emp_rows:
for r in emp_rows:
print(f" EMP_ID: {r[0]}, FIRM_ID: {r[1]}, EMAIL: {r[2]}, USERNAME: {r[3]}")
else:
print(" ПУСТО - нет записей в PR_EMP")
# --- PR_TRANS ---
print(f"\n--- 4. PR_TRANS (кадровые назначения) ---")
if emp_rows:
emp_ids = [r[0] for r in emp_rows]
placeholders = ",".join(["%s"] * len(emp_ids))
cursor.execute(
f"SELECT EMP_ID, D_FROM, D_TO, TP_ABBR, SYSPAY_ID "
f"FROM UOV_SELFSERVICE_PR_TRANS "
f"WHERE EMP_ID IN ({placeholders}) "
f"ORDER BY D_FROM DESC",
emp_ids
)
trans_rows = cursor.fetchall()
if trans_rows:
print(f" Найдено записей: {len(trans_rows)}")
for r in trans_rows[:10]:
print(f" EMP_ID: {r[0]}, D_FROM: {r[1]}, D_TO: {r[2]}, TP: {r[3]}, SYSPAY: {r[4]}")
else:
print(" ПУСТО - нет записей в PR_TRANS")
else:
print(" Пропуск (нет PR_EMP)")
# --- SL_YTAX ---
print(f"\n--- 5. SL_YTAX (периоды с данными) ---")
cursor.execute(
"SELECT DISTINCT TYEAR, TMONTH, CMONTH FROM UOV_SELFSERVICE_SL_YTAX "
"WHERE CARD_ID = %s ORDER BY CMONTH DESC",
(card_id,)
)
tax_rows = cursor.fetchall()
if tax_rows:
print(f" Найдено периодов: {len(tax_rows)}")
for r in tax_rows:
print(f" {r[0]}-{r[1]:02d} (CMONTH={r[2]})")
else:
print(" ПУСТО - нет периодов в SL_YTAX")
# --- 6. Проверка q_firm для ВСЕХ периодов ---
print(f"\n--- 6. Проверка q_firm для КАЖДОГО периода ---")
if tax_rows:
for tax_row in tax_rows:
tyear = tax_row[0]
tmonth = tax_row[1]
cmonth = tax_row[2]
_, 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)
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 firm_row:
print(f" {tyear}-{tmonth:02d} (CMONTH={cmonth}): ✅ FIRM_ID={firm_row[0]}, SNAME={firm_row[1]}")
else:
print(f" {tyear}-{tmonth:02d} (CMONTH={cmonth}): ❌ q_firm НЕ НАШЁЛ FIRM_ID!")
# Фоллбэк
q_firm_fb = """
select 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
"""
cursor.execute(q_firm_fb, (card_id,))
fb_row = cursor.fetchone()
if fb_row:
print(f" Фоллбэк: FIRM_ID={fb_row[0]}")
else:
print(f" Фоллбэк НЕ НАШЁЛ - критическая ошибка!")
else:
print(" Нет периодов для проверки")
# --- 7. Сводка ---
print(f"\n--- 7. Сводка ---")
cursor.execute(
"SELECT COUNT(DISTINCT CMONTH), MIN(CMONTH), MAX(CMONTH) FROM UOV_SELFSERVICE_SL_YTAX WHERE CARD_ID = %s",
(card_id,)
)
summary = cursor.fetchone()
if summary:
print(f" Периодов в SL_YTAX: {summary[0]}")
print(f" Диапазон: {summary[1]} - {summary[2]}")
print(f"\n{'=' * 60}")
print("Диагностика завершена")
print("=" * 60)
conn_sql.close()
+13 -22
View File
@@ -13,10 +13,7 @@ from ldap3.utils.conv import escape_filter_chars
TEST_USER = "man.bogov@tcs.sibcem.ru"
AD_SERVER = "ldap://172.16.20.20"
AD_BASES = (
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
AD_USER = "sdesk-mail"
AD_PASSWORD = "fne?e!q.m8phcrGVAcqr"
@@ -35,6 +32,7 @@ DB_PASSWORD = "Bav:fX#UwH8%atv4"
def get_phone_from_ad(user_id: str) -> str:
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(
server,
user=AD_USER,
@@ -43,29 +41,22 @@ def get_phone_from_ad(user_id: str) -> str:
)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split("@")[0]
search_filter = (
"(&(objectClass=user)"
"(|(mail={})"
"(userPrincipalName={})"
"(userPrincipalName={}))"
"(sAMAccountName={}))".format(
safe_user_id,
safe_user_id,
escape_filter_chars(short_username) + "@sibcem.ru",
escape_filter_chars(short_username),
)
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)})))"
)
for base in AD_BASES:
conn.search(
base,
search_filter,
attributes=['mobile', 'telephoneNumber']
)
if conn.entries:
break
conn.search(
AD_BASE,
search_filter,
attributes=['mobile', 'telephoneNumber']
)
if not conn.entries:
raise Exception("Пользователь не найден в AD")
+71 -18
View File
@@ -1,6 +1,7 @@
import sys
import asyncio
import logging
from logging.handlers import RotatingFileHandler
import os
import psutil
import time
@@ -17,6 +18,13 @@ logging.basicConfig(
)
logging.getLogger('trueconf.client.chatbot').propagate = False
logger = logging.getLogger(__name__)
sd_workflow_logger = logging.getLogger('sd_workflow')
sd_workflow_logger.setLevel(logging.INFO)
if not sd_workflow_logger.handlers:
handler = RotatingFileHandler(os.path.join(os.path.dirname(__file__), 'logs', '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)
# Добавляем путь к проекту в sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -71,7 +79,7 @@ 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
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT, sd_sessions
log_ram_usage("После импорта service_desk")
from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT
@@ -80,9 +88,6 @@ log_ram_usage("После импорта transcription_router (Виспер)")
from search_bot.handlers import router as search_router, SEARCH_MAIN_MENU_TEXT
log_ram_usage("После импорта search_router")
from instruct.handlers import router as instruct_router
log_ram_usage("После импорта instruct_router")
from lk.handlers import router as lk_router
log_ram_usage("После импорта lk_router (Личный кабинет)")
@@ -115,7 +120,68 @@ async def main_menu(msg: Message):
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")
# Помечаем сообщение как обработанное, чтобы sd_module_handler не перезаписал
msg.handled = True
# Инициализируем сессию для service_desk
if user_id not in sd_sessions:
sd_sessions[user_id] = {"step": "choose_template", "files_queue": [], "post_create_queue": [], "templates": [], "template_names": {}, "template_menu_text": ""}
# Загружаем шаблоны и встраиваем их в текст
try:
import json, asyncio, httpx, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# Импортируем SD_URL и SD_TOKEN из config
import importlib.util
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.abspath(__file__)), "config", "config.py"))
custom_config = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_config)
SD_URL = custom_config.SD_URL
SD_TOKEN = custom_config.SD_TOKEN
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
input_data = {"list_info": {"row_count": 100, "start_index": 1}}
params = {"input_data": json.dumps(input_data)}
url = f"{SD_URL}/api/v3/request_templates"
async with httpx.AsyncClient(verify=False) as client:
resp = await client.get(url, headers=headers, params=params, timeout=30.0)
if resp.status_code == 200:
data = resp.json()
all_templates = data.get("request_templates", [])
pre_filtered = [
t for t in all_templates
if t.get("status") == "ACTIVE"
and not t.get("inactive", False)
and str(t.get("id")) != "2"
]
async def check_template(t, cl):
try:
t_resp = await client.get(url + "/" + str(t["id"]), headers=headers, timeout=15.0)
if t_resp.status_code == 200:
detailed = t_resp.json().get("request_template", {})
return t if detailed.get("show_to_requester") is True else None
except Exception:
pass
return None
tasks = [check_template(t, client) for t in pre_filtered]
results = await asyncio.gather(*tasks)
templates = [t for t in results if t is not None]
template_lines = ""
for idx, t in enumerate(templates):
num = idx + 1
digit = f"{num}" if num <= 9 else "0⃣" if num == 10 else str(num)
template_lines += f"{digit} - {t['name']}\n"
# Сохраняем шаблоны в sd_sessions для sd_module_handler
sd_sessions[user_id]["templates"] = templates
sd_sessions[user_id]["template_names"] = {str(i+1): str(t["id"]) for i, t in enumerate(templates)}
sd_sessions[user_id]["template_menu_text"] = template_lines.strip()
template_text = f"🎫 Режим создания заявки в Service Desk\n\nВыберите, пожалуйста, шаблон подходящий под вашу проблему\n\n{template_lines}"
await msg.answer(template_text, parse_mode="html")
msg.handled = True
else:
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
msg.handled = True
except Exception as e:
sd_workflow_logger.error(f"Ошибка шаблонов: {e}")
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
msg.handled = True
return
elif action == "PHOTO":
USER_MENU_SHOWN[user_id] = True
@@ -142,18 +208,6 @@ async def main_menu(msg: Message):
from lk.handlers import send_dynamic_lk_main_menu
await send_dynamic_lk_main_menu(msg, user_id)
return
elif action == "INSTRUCT":
USER_MENU_SHOWN[user_id] = True
set_state(user_id, "INSTRUCT_MODE")
log_menu_stats(user_id, "Инструкции", "Вход")
await msg.answer(
"📋 <b>Выберите инструкцию:</b>\n\n"
"1⃣ — 🖥 Trueconf\n"
"2⃣ — 📧 Почта\n\n"
"<i>0⃣ — В главное меню</i>",
parse_mode="html"
)
return
else:
if not menu_shown:
USER_MENU_SHOWN[user_id] = True
@@ -171,7 +225,6 @@ async def main():
dp.include_router(sd_router)
dp.include_router(transcription_router)
dp.include_router(search_router)
dp.include_router(instruct_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)
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Парсинг регламента через Unstructured API.
Делает один проход (strategy=hi_res) и разделяет элементы на текст и таблицы.
"""
import json
import re
import requests
from pathlib import Path
# === НАСТРОЙКИ ===
UNSTRUCTURED_API = "http://192.168.1.103:8005/general/v0/general"
PDF_PATH = "/opt/documents_xk/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.pdf"
OUTPUT_PATH = "/opt/okf-regulations/concepts/pol123_parsed.json"
def parse_document(pdf_path: str) -> dict:
"""Отправляет PDF в API (hi_res) и возвращает рассортированные данные."""
print(f" Отправка файла {Path(pdf_path).name} в Unstructured API (hi_res)...")
with open(pdf_path, "rb") as f:
files = {"files": (Path(pdf_path).name, f, "application/pdf")}
data = {
"strategy": "hi_res",
"coordinates": "true"
}
try:
response = requests.post(UNSTRUCTURED_API, files=files, data=data, timeout=300)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f" ❌ Ошибка Unstructured API: {e}")
return {"text": [], "tables": []}
elements = response.json()
if isinstance(elements, dict):
elements = elements.get("elements", [])
text_elements = []
real_tables = []
print(" Сортировка и фильтрация элементов...")
for el in elements:
el_type = el.get("type")
if el_type == "Table":
meta = el.get("metadata", {})
html = meta.get("text_as_html", "")
# Строгий фильтр на наличие HTML-таблицы
if html and "<table" in html.lower():
row_count = len(re.findall(r'<tr[^>]*>', html, re.IGNORECASE)) if html else None
col_count = len(re.findall(r'<td[^>]*>|<th[^>]*>', html[:500], re.IGNORECASE)) if html else None
real_tables.append({
"page": meta.get("page_number"),
"text": el.get("text", ""),
"html": html,
"row_count": row_count,
"col_count": col_count,
})
else:
# Если таблица оказалась фейковой, отправляем её текст в общий котел
text_elements.append(el)
else:
# Всё остальное (Title, NarrativeText, ListItem и т.д.)
text_elements.append(el)
return {"text": text_elements, "tables": real_tables}
def main():
print("=" * 60)
print(f"📄 Парсинг документа: {Path(PDF_PATH).name}")
print("=" * 60)
parsed_data = parse_document(PDF_PATH)
print(f"\n[Итоги]")
print(f" ✅ Получено текстовых элементов: {len(parsed_data['text'])}")
print(f" ✅ Найдено валидных таблиц: {len(parsed_data['tables'])}")
result = {
"document": "ПОЛ-123",
"title": "Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2",
"text": parsed_data["text"],
"tables": parsed_data["tables"],
"metadata": {
"unstructured_api": UNSTRUCTURED_API,
"strategy": "hi_res",
"timestamp": "2026-07-06",
},
}
Path(OUTPUT_PATH).write_text(json.dumps(result, ensure_ascii=False, indent=2))
print(f"\n✅ Сохранено в {OUTPUT_PATH}")
print("=" * 60)
if __name__ == "__main__":
main()
+13 -15
View File
@@ -5,39 +5,37 @@ 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
from utils.ad_search import search_by_login as ad_search_all_bases
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.
Ищет пользователя по всем OU из AD_BASES.
"""
from utils.ad_search import search_by_filter
try:
# Читаем сырые байты картинки
with open(photo_path, "rb") as f:
photo_bytes = f.read()
# Ищем пользователя в AD по всем OU
# Формируем фильтр для поиска
if '@' in target_user:
ldap_filter = "(mail={})".format(target_user)
ldap_filter = f"(mail={target_user})"
else:
ldap_filter = "(sAMAccountName={})".format(target_user)
ldap_filter = f"(sAMAccountName={target_user})"
entries = search_by_filter(ldap_filter, ["cn"])
if not entries:
return {"success": False, "error": "Пользователь {} не найден в AD.".format(target_user)}
user_dn = entries[0].entry_dn
# Подключаемся для модификации
# Подключаемся к 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])]})
+13 -6
View File
@@ -7,8 +7,7 @@ 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
from utils.ad_search import search_by_filter as ad_search_all_bases
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
logging.basicConfig(level=logging.INFO, format="%(message)s")
@@ -22,14 +21,22 @@ def check_user_photo(target_user: str):
ldap_filter = f"(sAMAccountName={target_user})"
try:
# Подключаемся к AD и ищем по всем OU из AD_BASES
entries = ad_search_all_bases(ldap_filter, ["cn", "thumbnailPhoto"])
# Подключаемся к AD
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
if not entries:
# Ищем пользователя и просим вернуть атрибуты: имя (cn) и фото (thumbnailPhoto)
conn.search(
search_base=AD_BASE,
search_filter=ldap_filter,
attributes=["cn", "thumbnailPhoto"]
)
if not conn.entries:
print("❌ Пользователь с такими данными не найден в AD.")
return
user = entries[0]
user = conn.entries[0]
user_name = user.cn.value if 'cn' in user else target_user
print(f"👤 Найден сотрудник: {user_name}")
+2 -3
View File
@@ -19,7 +19,7 @@ from photo_bot.photo_processor import prepare_ad_photo
from utils.texts import (
EMOJI_DIGITS,
PHOTO_MAIN_MENU_TEXT,
system_error_text,
PHOTO_UNKNOWN_CMD_TEXT,
photo_error_handling,
PHOTO_UPLOADING_TEXT,
photo_success_sent,
@@ -188,7 +188,7 @@ async def photo_module_handler(msg: Message):
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
else:
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
await msg.answer(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
return
# --- ПРИЕМ НОВОГО ФОТО ---
@@ -240,4 +240,3 @@ async def photo_module_handler(msg: Message):
except Exception as cleanup_err:
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
return
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""
Универсальный скрипт для улучшения MD-файлов документов АО «ХК «Сибцем».
Применяет паттерны:
1. Удаляет футеры страниц
2. Форматирует блок "УТВЕРЖДАЮ"
3. Выравнивает иерархию заголовков
4. Объединяет таблицы терминов
5. Добавляет разделители между разделами
6. Приводит таблицы к единому виду
Использование:
python3 optimize_document.py <путь_к_оригиналу_на_сервере>
Пример:
python3 optimize_document.py "/opt/documents_xk/АОХКСибцем/07 Служба.../08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.md"
"""
import subprocess
import re
import sys
import time
import requests
import urllib.parse
# Конфигурация
SSH_KEY = "/home/hermes/.ssh/192.168.1.106"
SSH_USER = "administrator@192.168.1.106"
SERVER_BASE = "/opt/documents_xk/АОХКСибцем"
FILEBROWSER_URL = "https://smb.dddennnisss.ru"
FILEBROWSER_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjoyMzAyNzM1NzA3LCJpYXQiOjE3ODQzMzU3MDcsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOmZhbHNlLCJkZWxldGUiOnRydWUsImNyZWF0ZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9fQ.QBKlh3NQJeh4aeMiKLkhg0tF8hxk-Oh2KLsXLSbuvm8"
SOURCE_NAME = "SSD-Storage"
def run_ssh(command):
"""Выполнить команду на сервере через SSH."""
result = subprocess.run(
["ssh", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no",
SSH_USER, command],
capture_output=True, text=True, timeout=30
)
return result
def scp_download(remote_path, local_path):
"""Скачать файл с сервера."""
result = subprocess.run(
["scp", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no",
f"{SSH_USER}:{remote_path}", local_path],
capture_output=True, text=True, timeout=60
)
return result.returncode == 0
def fix_headings(content):
"""Выровнять иерархию заголовков: все на уровень ##."""
content = re.sub(r'^(#+)\s+(.+)$', r'## \2', content, flags=re.MULTILINE)
return content
def add_dividers(content):
"""Добавить разделители между основными разделами."""
# Разделитель перед Оглавлением
content = re.sub(
r'(\n)(## )(Оглавление)',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед разделами 1-9
content = re.sub(
r'(\n)(## )(\s*(?:1\.|2\.|3\.|4\.|5\.|6\.|7\.|8\.|9\.))',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед Приложениями
content = re.sub(
r'(\n)(## )(Приложение)',
r'\1\n---\n\1\2\3',
content
)
# Разделитель перед Листом согласований
content = re.sub(
r'(\n)(## )(Лист согласований)',
r'\1\n---\n\1\2\3',
content
)
return content
def fix_tables(content):
"""Привести все таблицы к единому виду с thead/tbody."""
def fix_table(table):
if '<thead>' in table and '<tbody>' in table:
return table
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
if not rows:
return table
header = '<thead><tr>' + rows[0] + '</tr></thead><tbody>'
body = ''
for row in rows[1:]:
body += '<tr>' + row + '</tr>'
body += '</tbody>'
return '<table>' + header + body + '</table>'
return re.sub(r'<table>.*?</table>', lambda m: fix_table(m.group(0)), content, flags=re.DOTALL)
def remove_footers(content):
"""Удалить футеры страниц."""
# Удалить "АО «ХК «Сибцем»\nТип документа: ...\nНаименование документа: ..."
content = re.sub(
r'АО «ХК «Сибцем»\nТип документа:.*?\nНаименование документа:.*?\n\n',
'',
content,
flags=re.DOTALL
)
# Удалить "Стр. X из Y"
content = re.sub(r'Стр\. \d+ из \d+\n\n', '', content)
# Удалить "Дата утверждения: DD.MM.YYYY"
content = re.sub(r'Дата утверждения: \d{2}\.\d{2}\.\d{4}\n\n', '', content)
# Удалить "Ведущее подразделение: ..."
content = re.sub(r'Ведущее подразделение:.*?\n\n', '', content)
return content
def fix_approval_block(content):
"""Форматировать блок УТВЕРЖДАЮ."""
# Паттерн 1: "УТВЕРЖДАЮ\nПрезидент\n\nО.В. Шарыкин\n\n«XX» месяц год г."
approval_patterns = [
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
]
for pattern in approval_patterns:
match = re.search(pattern, content)
if match:
old_text = match.group(0)
new_text = f'''<div align="right">
<strong>УТВЕРЖДАУ</strong><br>
Президент<br>
О.В. Шарыкин<br>
{old_text.split("«")[1].split("г.")[0]} г.
</div>'''
content = content.replace(old_text, new_text)
break
return content
def merge_terms_tables(content):
"""Объединить разбитые таблицы терминов."""
# Поиск таблиц терминов
terms_pattern = r'(<table>.*?Термины.*?</table>)'
matches = re.findall(terms_pattern, content, re.DOTALL)
if len(matches) > 1:
# Объединяем все таблицы терминов в одну
merged = '<table><thead><tr><th>Термин</th><th>Определение</th></tr></thead><tbody>'
for table in matches:
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
for row in rows[1:]: # Пропускаем заголовок
merged += row
merged += '</tbody></table>'
content = content.replace(matches[0], merged)
return content
def optimize_document(md_path):
"""Основная функция оптимизации документа."""
start_time = time.time()
print(f"📄 Обработка документа: {md_path}")
print("=" * 60)
# Шаг 1: Скачать MD с сервера
print("\n📥 Шаг 1: Скачивание MD...")
result = run_ssh(f"cat '{md_path}'")
if result.returncode != 0:
print(f"✗ Ошибка при скачивании: {result.stderr}")
return
md_content = result.stdout
print(f"✓ MD скачан: {len(md_content)} символов")
# Шаг 2: Применить паттерны
print("\n🔧 Шаг 2: Применение паттернов...")
# 2.1 Удалить футеры
content = remove_footers(md_content)
print(" ✓ Футеры удалены")
# 2.2 Выровнять иерархию заголовков
content = fix_headings(content)
print(" ✓ Иерархия заголовков выровнена")
# 2.3 Добавить разделители
content = add_dividers(content)
print(" ✓ Разделители добавлены")
# 2.4 Привести таблицы к единому виду
content = fix_tables(content)
print(" ✓ Таблицы приведены к единому виду")
# 2.5 Объединить таблицы терминов
content = merge_terms_tables(content)
print(" ✓ Таблицы терминов объединены")
# 2.6 Форматировать блок УТВЕРЖДАЮ
content = fix_approval_block(content)
print(" ✓ Блок УТВЕРЖДАЮ отформатирован")
# Сохранить улучшенную версию
improved_path = "/tmp/improved_document.md"
with open(improved_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"\n📊 Результаты:")
print(f" Исходный размер: {len(md_content)} символов")
print(f" Улучшенный размер: {len(content)} символов")
# Показать структуру
sections = re.findall(r'^(##)\s+(.+)$', content, re.MULTILINE)
print(f" Разделов: {len(sections)}")
tables = re.findall(r'<table>', content)
print(f" Таблиц: {len(tables)}")
# Показать первые 300 символов
print(f"\n📝 Начало файла:")
print(content[:300].replace('\n', '\\n'))
# Загрузить в filebrowser
print("\n📤 Шаг 3: Загрузка в filebrowser...")
upload_to_filebrowser(content, md_path)
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Готово! Время выполнения: {elapsed:.1f} сек")
print(f"{'=' * 60}")
def upload_to_filebrowser(content, md_path):
"""Загрузить файл в filebrowser."""
# Извлечь имя файла из пути
filename = md_path.split('/')[-1]
# Добавить "_improved" перед расширением
if filename.endswith('.md'):
improved_filename = filename[:-3] + '_improved.md'
else:
improved_filename = filename + '_improved'
session = requests.Session()
session.cookies.set("filebrowser_quantum_jwt", FILEBROWSER_TOKEN, domain="smb.dddennnisss.ru")
url = f"{FILEBROWSER_URL}/api/resources"
params = {"path": improved_filename, "source": SOURCE_NAME}
r = session.put(url, params=params, data=content)
if r.status_code == 200:
print(f"✓ Файл загружен: {improved_filename}")
# Проверить
r = session.get(f"{FILEBROWSER_URL}/api/resources", params={"path": "/", "source": SOURCE_NAME})
if r.status_code == 200:
data = r.json()
items = data.get("files", []) + data.get("folders", [])
for item in items:
if improved_filename in item["name"]:
print(f"✓ Размер: {item['size']} байт")
break
else:
print(f"✗ Ошибка загрузки: {r.status_code} - {r.text[:200]}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование:")
print(" python3 optimize_document.py <путь_к_оригиналу_на_сервере>")
print("\nПример:")
print(" python3 optimize_document.py \"/opt/documents_xk/АОХКСибцем/07 Служба.../08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.md\"")
sys.exit(1)
md_path = sys.argv[1]
optimize_document(md_path)
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
Универсальный скрипт для улучшения MD-файлов документов АО «ХК «Сибцем».
Выполняется на сервере 192.168.1.106.
Применяет паттерны:
1. Удаляет футеры страниц
2. Форматирует блок "УТВЕРЖДАЮ"
3. Выравнивает иерархию заголовков
4. Объединяет таблицы терминов
5. Добавляет разделители между разделами
6. Приводит таблицы к единому виду
Использование:
python3 /opt/trueconf_bot/search_bot/optimize_documents.py <путь_к_каталогу>
Пример:
python3 /opt/trueconf_bot/search_bot/optimize_documents.py "/opt/documents_xk/ready_md/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/"
"""
import os
import re
import sys
import time
import requests
import base64
# Конфигурация
FILEBROWSER_URL = "https://smb.dddennnisss.ru"
FILEBROWSER_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjoyMzAyNzM1NzA3LCJpYXQiOjE3ODQzMzU3MDcsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOmZhbHNlLCJkZWxldGUiOnRydWUsImNyZWF0ZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9fQ.QBKlh3NQJeh4aeMiKLkhg0tF8hxk-Oh2KLsXLSbuvm8"
SOURCE_NAME = "SSD-Storage"
def remove_footers(content):
"""Удалить футеры страниц."""
# Удалить "АО «ХК «Сибцем»\nТип документа: ...\nНаименование документа: ..."
content = re.sub(
r'АО «ХК «Сибцем»\nТип документа:.*?\nНаименование документа:.*?\n\n',
'',
content,
flags=re.DOTALL
)
# Удалить "Стр. X из Y"
content = re.sub(r'Стр\. \d+ из \d+\n\n', '', content)
# Удалить "Дата утверждения: DD.MM.YYYY"
content = re.sub(r'Дата утверждения: \d{2}\.\d{2}\.\d{4}\n\n', '', content)
# Удалить "Ведущее подразделение: ..."
content = re.sub(r'Ведущее подразделение:.*?\n\n', '', content)
return content
def fix_headings(content):
"""Выровнять иерархию заголовков: все на уровень ##."""
content = re.sub(r'^(#+)\s+(.+)$', r'## \2', content, flags=re.MULTILINE)
return content
def add_dividers(content):
"""Добавить разделители между основными разделами."""
# Разделитель перед Оглавлением
content = re.sub(
r'(\n)(## )(Оглавление)',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед разделами 1-9
content = re.sub(
r'(\n)(## )(\s*(?:1\.|2\.|3\.|4\.|5\.|6\.|7\.|8\.|9\.))',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед Приложениями
content = re.sub(
r'(\n)(## )(Приложение)',
r'\1\n---\n\1\2\3',
content
)
# Разделитель перед Листом согласований
content = re.sub(
r'(\n)(## )(Лист согласований)',
r'\1\n---\n\1\2\3',
content
)
return content
def fix_tables(content):
"""Привести все таблицы к единому виду с thead/tbody."""
def fix_table(table):
if '<thead>' in table and '<tbody>' in table:
return table
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
if not rows:
return table
header = '<thead><tr>' + rows[0] + '</tr></thead><tbody>'
body = ''
for row in rows[1:]:
body += '<tr>' + row + '</tr>'
body += '</tbody>'
return '<table>' + header + body + '</table>'
return re.sub(r'<table>.*?</table>', lambda m: fix_table(m.group(0)), content, flags=re.DOTALL)
def merge_terms_tables(content):
"""Объединить разбитые таблицы терминов."""
# Поиск таблиц терминов
terms_pattern = r'(<table>.*?Термины.*?</table>)'
matches = re.findall(terms_pattern, content, re.DOTALL)
if len(matches) > 1:
# Объединяем все таблицы терминов в одну
merged = '<table><thead><tr><th>Термин</th><th>Определение</th></tr></thead><tbody>'
for table in matches:
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
for row in rows[1:]: # Пропускаем заголовок
merged += row
merged += '</tbody></table>'
content = content.replace(matches[0], merged)
return content
def fix_approval_block(content):
"""Форматировать блок УТВЕРЖДАЮ."""
# Паттерн 1: "УТВЕРЖДАЮ\nПрезидент\n\nО.В. Шарыкин\n\n«XX» месяц год г."
approval_patterns = [
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
]
for pattern in approval_patterns:
match = re.search(pattern, content)
if match:
old_text = match.group(0)
new_text = f'''<div align="right">
<strong>УТВЕРЖДАУ</strong><br>
Президент<br>
О.В. Шарыкин<br>
{old_text.split("«")[1].split("г.")[0]} г.
</div>'''
content = content.replace(old_text, new_text)
break
return content
def upload_to_filebrowser(content, filename):
"""Загрузить файл в filebrowser."""
# Добавить "_improved" перед расширением
if filename.endswith('.md'):
improved_filename = filename[:-3] + '_improved.md'
else:
improved_filename = filename + '_improved'
session = requests.Session()
session.cookies.set("filebrowser_quantum_jwt", FILEBROWSER_TOKEN, domain="smb.dddennnisss.ru")
url = f"{FILEBROWSER_URL}/api/resources"
params = {"path": improved_filename, "source": SOURCE_NAME}
r = session.put(url, params=params, data=content)
if r.status_code == 200:
print(f"✓ Файл загружен: {improved_filename}")
# Проверить
r = session.get(f"{FILEBROWSER_URL}/api/resources", params={"path": "/", "source": SOURCE_NAME})
if r.status_code == 200:
data = r.json()
items = data.get("files", []) + data.get("folders", [])
for item in items:
if improved_filename in item["name"]:
print(f"✓ Размер: {item['size']} байт")
break
else:
print(f"✗ Ошибка загрузки: {r.status_code} - {r.text[:200]}")
return improved_filename
def optimize_document(md_path):
"""Основная функция оптимизации документа."""
start_time = time.time()
print(f"\n📄 Обработка документа: {md_path.split('/')[-1]}")
print("=" * 60)
# Шаг 1: Читать MD файл
print("\n📥 Шаг 1: Чтение MD...")
try:
with open(md_path, 'r', encoding='utf-8') as f:
md_content = f.read()
except Exception as e:
print(f"✗ Ошибка при чтении: {e}")
return
print(f"✓ MD прочитан: {len(md_content)} символов")
# Шаг 2: Применить паттерны
print("\n🔧 Шаг 2: Применение паттернов...")
# 2.1 Удалить футеры
content = remove_footers(md_content)
print(" ✓ Футеры удалены")
# 2.2 Выровнять иерархию заголовков
content = fix_headings(content)
print(" ✓ Иерархия заголовков выровнена")
# 2.3 Добавить разделители
content = add_dividers(content)
print(" ✓ Разделители добавлены")
# 2.4 Привести таблицы к единому виду
content = fix_tables(content)
print(" ✓ Таблицы приведены к единому виду")
# 2.5 Объединить таблицы терминов
content = merge_terms_tables(content)
print(" ✓ Таблицы терминов объединены")
# 2.6 Форматировать блок УТВЕРЖДАЮ
content = fix_approval_block(content)
print(" ✓ Блок УТВЕРЖДАУ отформатирован")
# Сохранить улучшенную версию
improved_path = md_path.replace('.md', '_improved.md')
with open(improved_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"\n📊 Результаты:")
print(f" Исходный размер: {len(md_content)} символов")
print(f" Улучшенный размер: {len(content)} символов")
# Показать структуру
sections = re.findall(r'^(##)\s+(.+)$', content, re.MULTILINE)
print(f" Разделов: {len(sections)}")
tables = re.findall(r'<table>', content)
print(f" Таблиц: {len(tables)}")
# Показать первые 300 символов
print(f"\n📝 Начало файла:")
print(content[:300].replace('\n', '\\n'))
# Загрузить в filebrowser
print("\n📤 Шаг 3: Загрузка в filebrowser...")
filename = md_path.split('/')[-1]
upload_to_filebrowser(content, filename)
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Готово! Время выполнения: {elapsed:.1f} сек")
print(f"{'=' * 60}")
def process_directory(directory):
"""Обработать все MD файлы в каталоге."""
start_time = time.time()
print(f"📂 Обработка каталога: {directory}")
print("=" * 60)
# Найти все MD файлы
md_files = []
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith('.md'):
md_files.append(os.path.join(root, file))
print(f"Найдено {len(md_files)} MD файлов")
# Обработать каждый файл
for md_path in md_files:
try:
optimize_document(md_path)
except Exception as e:
print(f"✗ Ошибка при обработке {md_path}: {e}")
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Обработка каталога завершена! Общее время: {elapsed:.1f} сек")
print(f"{'=' * 60}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование:")
print(" python3 optimize_documents.py <путь_к_каталогу>")
print("\nПример:")
print(' python3 optimize_documents.py "/opt/documents_xk/ready_md/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/"')
sys.exit(1)
directory = sys.argv[1]
process_directory(directory)
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Комбинированный парсинг ПОЛ-177:
- Текст: Unstructured API (192.168.1.103:8005)
- Таблицы: pypdf (локально)
Сохраняет результат в /opt/okf-regulations/concepts/pol177_parsed.json
"""
import json
import subprocess
from pathlib import Path
# === НАСТРОЙКИ ===
UNSTRUCTURED_API = "http://192.168.1.103:8005/general/v0/general"
PDF_PATH = "/opt/documents_xk/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/10 Положение Хранение электронных документов от 03.04.2024 № ПОЛ-177.pdf"
OUTPUT_PATH = "/opt/okf-regulations/concepts/pol177_parsed.json"
def fetch_unstructured(pdf_path: str) -> list[dict]:
"""Получаем элементы текста из Unstructured API (103)."""
cmd = [
"curl", "-X", "POST", UNSTRUCTURED_API,
"-F", f"files=@{pdf_path}",
"-F", "strategy=fast",
"-F", "output_format=json",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ Unstructured API error: {result.stderr}")
return []
return json.loads(result.stdout)
def extract_tables_pypdf(pdf_path: str) -> list[dict]:
"""Извлекаем таблицы через pypdf (локально на 106)."""
try:
from pypdf import PdfReader
except ImportError:
import subprocess
subprocess.run(["pip3", "install", "pypdf", "--user"], check=True)
from pypdf import PdfReader
reader = PdfReader(pdf_path)
tables = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text and ("таблица" in text.lower() or "стр" in text.lower()):
tables.append({"page": i + 1, "text": text})
return tables
def main():
print("=" * 60)
print("📄 Парсинг ПОЛ-177")
print("=" * 60)
# 1. Текст через Unstructured API
print("\n[1/2] Запрос текста через Unstructured API (103)...")
elements = fetch_unstructured(PDF_PATH)
print(f" ✅ Получено {len(elements)} элементов")
# 2. Таблицы через pypdf
print("\n[2/2] Извлечение таблиц через pypdf (106)...")
tables = extract_tables_pypdf(PDF_PATH)
print(f" ✅ Найдено {len(tables)} страниц с текстом")
# 3. Сохраняем комбинированный результат
result = {
"document": "ПОЛ-177",
"title": "Положение Хранение электронных документов от 03.04.2024 № ПОЛ-177",
"unstructured": elements,
"tables": tables,
"metadata": {
"unstructured_api": UNSTRUCTURED_API,
"strategy": "fast",
"timestamp": "2026-07-06",
},
}
Path(OUTPUT_PATH).write_text(json.dumps(result, ensure_ascii=False, indent=2))
print(f"\n✅ Сохранено в {OUTPUT_PATH}")
print("=" * 60)
if __name__ == "__main__":
main()
+219 -166
View File
@@ -44,9 +44,9 @@ from utils.texts import (
sd_uploading_file,
sd_file_attached,
sd_file_upload_error,
system_error_text,
UNKNOWN_MAIN_CMD_TEXT,
)
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
# 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats
# Таймаут ожидания вложений после текста (секунды)
@@ -67,15 +67,61 @@ logger = logging.getLogger(__name__)
router = Router()
sd_sessions = {}
async def get_sd_templates():
url = f"{SD_URL}/api/v3/request_templates"
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
input_data = {"list_info": {"row_count": 100, "start_index": 1}}
params = {"input_data": json.dumps(input_data)}
try:
async with httpx.AsyncClient(verify=False) as client:
response = await client.get(url, headers=headers, params=params, timeout=30.0)
if response.status_code != 200:
sd_workflow_logger.error(f"Ошибка API шаблонов: HTTP {response.status_code} - {response.text}")
return []
data = response.json()
all_templates = data.get("request_templates", [])
pre_filtered = [
t for t in all_templates
if t.get("status") == "ACTIVE"
and not t.get("inactive", False)
and str(t.get("id")) != "2"
]
sd_workflow_logger.info(f"Шаблонов в системе: {len(all_templates)} | Активных: {len(pre_filtered)}")
async def check_template(t):
try:
t_resp = await client.get(url + "/" + str(t["id"]), headers=headers, timeout=15.0)
if t_resp.status_code == 200:
detailed = t_resp.json().get("request_template", {})
if detailed.get("show_to_requester") is True:
sc = detailed.get("service_category", {})
t["service_category"] = sc
t["request"] = detailed.get("request", {})
return t if detailed.get("show_to_requester") is True else None
except Exception as e:
sd_workflow_logger.warning(f"Ошибка деталей шаблона ID {t['id']}: {e}")
return None
tasks = [check_template(t) for t in pre_filtered]
results = await asyncio.gather(*tasks)
active_templates = [t for t in results if t is not None]
sd_workflow_logger.info(f"Шаблоны с show_to_requester=True: {len(active_templates)}")
for temp in active_templates:
sd_workflow_logger.info(f" ID: {temp['id']} | Name: {temp['name']}")
return active_templates
except Exception as e:
sd_workflow_logger.error(f"Ошибка загрузки шаблонов: {e}")
return []
# ================================================
# БИЗНЕС-ЛОГИКА
# ================================================
def get_ad_user_sync(login: str):
try:
from utils.ad_search import search_by_login
entries = search_by_login(login, ["displayName", "mail", "l", "userAccountControl"])
if entries:
user = entries[0]
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,
@@ -127,31 +173,39 @@ async def generate_smart_subject(text: str) -> str:
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"}
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str, template_id: str | None = None, templates: list | None = None):
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/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 def _send_req(email, current_city, template_id, templates):
req_payload = {"request": {"subject": subject, "description": html_desc, "requester": {"name": email}}}
if template_id:
req_payload["request"]["template"] = {"id": template_id}
# Найти service_category по шаблону
for t in templates:
if str(t.get("id")) == str(template_id):
sc = t.get("service_category", {})
if sc:
req_payload["request"]["service_category"] = sc
break
if "udf_pick_301" in req_payload["request"]:
req_payload["request"]["udf_fields"] = {"udf_pick_301": current_city}
elif "udf_fields" in req_payload["request"] and current_city:
req_payload["request"]["udf_fields"]["udf_pick_301"] = current_city
else:
req_payload["request"]["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)
# Безопасная обработка сбоев сети / отсуствия доступа к серверу SD
try:
resp = await _send_req(requester_email, city)
resp = await client.post(endpoint, headers=headers, params={"input_data": json.dumps(req_payload)}, timeout=15.0)
if resp.status_code not in [200, 201]:
sd_workflow_logger.error(f"API Error: status={resp.status_code} body={resp.text}")
return resp
resp = await _send_req(requester_email, city, template_id, templates)
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, "Кемерово", template_id, templates)
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")
except (httpx.RequestError, httpx.HTTPStatusError, Exception) as e:
logger.error(f"❌ [SD Network Error] Не удалось подключиться к ServiceDesk: {e}")
sd_workflow_logger.error(f"❌ [SD Network Error] {e}")
_sd_log_and_notify_email("Подключение к ServiceDesk", str(e))
return None
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):
@@ -229,7 +283,8 @@ def _extract_attachments_from_content(content, is_attachment_type=False):
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
session["step"] = "creating_ticket"
template_id = session.get("template_id")
sd_workflow_logger.info(f"[Create] Template ID: {template_id}")
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}")
@@ -238,13 +293,13 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
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)
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city, template_id, session.get("templates", []))
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
if ticket_id:
session["ticket_id"] = ticket_id
# 1. Загрузка основных вложений
# 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}")
@@ -252,7 +307,7 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
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-запросов
# 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}")
@@ -270,20 +325,19 @@ async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login
log_menu_stats(user_id, "Service Desk", f"Создание заявки #{ticket_id}")
else:
session["step"] = "need_text"
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
await msg.answer(sd_ticket_create_error(), parse_mode="html")
except Exception as e:
logger.exception(f"❌ [SD Ticket Error] {user_id}: {e}")
sd_workflow_logger.error(f"❌ [SD Ticket Error] {user_id}: {e}")
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
logger.exception(f"Ошибка: {e}")
sd_workflow_logger.error(f"❌ [SD Error] {e}")
session["step"] = "need_text"
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
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
return # Таймер сброшен, прерываем выполнение текущей таски
if session.get("step") != "waiting_for_attachments":
return
@@ -292,145 +346,144 @@ async def _wait_for_attachments_and_create(user_id, session):
msg = session.get("msg")
login = session.get("login")
if msg:
try:
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
except Exception as e:
logger.exception(f"❌ [SD Ticket Failed] user={user_id}: {e}")
sd_workflow_logger.error(f"❌ [SD Ticket] {e}")
_sd_log_and_notify_email(f"Создание заявки для {user_id}", str(e))
session["step"] = "need_text"
try:
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
except Exception:
sd_workflow_logger.error(f"❌ [SD] Failed to send error message to {user_id}")
def _on_sd_timer_done(task: asyncio.Task, user_id: str):
"""Безопасный коллбэк завершения таймера создания заявки"""
try:
task.result()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"❌ Ошибка в таске таймера SD для {user_id}: {e}")
sd_workflow_logger.error(f"❌ [SD Timer Error] {user_id}: {e}")
_sd_log_and_notify_email(f"Таймер создания заявки для {user_id}", str(e))
await _create_ticket_and_attach_files(user_id, session["msg_text"], session, msg, login)
@router.message()
async def sd_module_handler(msg: Message):
try:
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":
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": [], "templates": [], "template_names": {}}
session = sd_sessions[user_id]
# --- ШАГ 0: ВЫБОР ШАБЛОНА ---
if session["step"] == "choose_template":
if cmd in ["skip", "пропустить", "без шаблона"]:
sd_workflow_logger.info("[Template] User skipped template selection")
session["step"] = "need_text"
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
if cmd in session.get("template_names", {}):
template_id = session["template_names"][cmd]
template_name = next((t["name"] for t in session.get("templates", []) if str(t["id"]) == str(template_id)), "Неизвестно")
sd_workflow_logger.info(f"[Template] User selected: {template_id} ({template_name})")
session["template_id"] = template_id
session["step"] = "need_text"
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
else:
await msg.answer(
f"Неверный номер. Выберите из списка:\n\n"
f"{session.get('template_menu_text', '')}",
parse_mode="html"
)
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
# --- ВЫХОД ---
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")
# --- ШАГ 1: ОЖИДАНИЕ ТЕКСТА ---
if session["step"] == "need_text":
# Пользователь только что вошёл в SD_MODE — показываем меню
if len(msg_text.strip()) == 0 and not inline_attachments:
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
if not msg_text.strip() and not inline_attachments:
await msg.answer(SD_UNKNOWN_CMD_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")
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))
session["timer_task"].add_done_callback(lambda t: _on_sd_timer_done(t, user_id))
sd_workflow_logger.info(f"⏳ [SD] Timer started: waiting {ATTACHMENT_WAIT_TIMEOUT}s for potentially more attachments...")
# Если только вложение без текста — сохраняем и ждем текст
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
# --- ШАГ 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 inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
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))
session["timer_task"].add_done_callback(lambda t: _on_sd_timer_done(t, user_id))
sd_workflow_logger.info(f"🔄 [SD] Timer reset due to user activity. Waiting another {ATTACHMENT_WAIT_TIMEOUT}s")
return
# Настраиваем параметры сессии для ожидания
session["step"] = "waiting_for_attachments"
session["msg_text"] = msg_text
session["msg"] = msg
session["user_id"] = user_id
session["login"] = login
# --- ШАГ 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
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
await msg.answer(SD_CREATING, parse_mode="html")
# --- ШАГ 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:
_sd_log_and_notify_email(f"Загрузка файла к заявке #{ticket_id}", f"file={att.get('file_name', 'unknown')}")
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
elif msg_text.strip():
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
except Exception as e:
user_id = getattr(msg.from_user, 'id', 'unknown')
logger.exception(f"❌ [SD Fatal Error] user={user_id}: {e}")
sd_workflow_logger.error(f"❌ [SD Fatal] {e}")
_sd_log_and_notify_email(f"Обработка сообщения от {user_id}", str(e))
try:
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
except Exception:
pass
# Запускаем фоновый таймер с механизмом сброса (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
-297
View File
@@ -1,297 +0,0 @@
#!/usr/bin/env python3
"""
Проверка подключения к SQL Server и загрузки данных табеля
за последние 5 месяцев для ds.krivochenko@sibcem.ru.
Цепочка поиска сотрудника (как в LK модуле бота):
1. AD: search_by_user_id(email) → extensionAttribute2 = CARD_ID
2. SQL: SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID = %s → EMP_ID
3. SQL: SELECT ... FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID = %s → табель
Запуск:
cd /opt/trueconf_bot
python3 test_tabel_check.py
"""
import sys
import os
import calendar
from datetime import datetime
# ============================================================
# 1. Загрузка .env
# ============================================================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ENV_PATH = os.path.join(BASE_DIR, "config", ".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)
# Карта соответствия: Имя переменной в боте -> Название карточки в сейфе
CREDENTIALS_MAP = {
"DB_PASSWORD": os.getenv("PW_ID_SQL", "").strip(' "\'\r\n'),
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'),
}
# ============================================================
# 2. Загрузка паролей из Passwork
# ============================================================
DB_USER = ""
DB_PASSWORD = ""
AD_USER = ""
AD_PASSWORD = ""
for pw_path in ["/opt/passwork", os.path.join(BASE_DIR, "passwork"), os.path.join(BASE_DIR, "config", "passwork")]:
if os.path.isdir(pw_path) and pw_path not in sys.path:
sys.path.insert(0, pw_path)
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
try:
from passwork import get_passwork_secrets
print("🔐 Загрузка секретов из Passwork...")
required_cards = [name for name in CREDENTIALS_MAP.values() if name]
passwork_pool = get_passwork_secrets(required_cards=required_cards)
for var_name, card_name in CREDENTIALS_MAP.items():
if not card_name:
continue
clean_card = card_name.strip(' "\'\r\n')
card_data = passwork_pool.get(clean_card)
if card_data is None:
print(f" ⚠️ Карточка '{clean_card}' не найдена")
continue
globals()[var_name] = card_data["password"].strip(' "\'\r\n')
if 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}'")
except ImportError:
print("⚠️ Модуль passwork не найден")
except Exception as e:
print(f"⚠️ Ошибка Passwork: {e}")
if not DB_PASSWORD:
DB_PASSWORD = os.getenv("DB_PASSWORD", "").strip()
DB_USER = os.getenv("DB_USER", DB_USER).strip()
if not AD_PASSWORD:
AD_PASSWORD = os.getenv("AD_PASSWORD", "").strip()
AD_USER = os.getenv("AD_USER", AD_USER).strip()
# ============================================================
# 3. Настройки подключения
# ============================================================
DB_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
AD_SERVER = os.getenv("AD_SERVER", "ldap://172.16.20.20")
AD_BASES = (
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
print()
print("=" * 60)
print(" Проверка подключения к SQL Server")
print("=" * 60)
print(f" Сервер : {DB_SERVER}")
print(f" База : {DB_NAME}")
print(f" Логин : {DB_USER}")
print(f" Пароль : {'*' * len(DB_PASSWORD) if DB_PASSWORD else '(не задан)'}")
print()
# ============================================================
# 4. Проверка pymssql
# ============================================================
try:
import pymssql
print(f"✅ pymssql загружен (версия {pymssql.__version__})")
except ImportError:
print("❌ pymssql не установлен!")
sys.exit(1)
# ============================================================
# 5. Подключение к SQL Server
# ============================================================
try:
conn = pymssql.connect(
server=DB_SERVER,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME,
charset='cp1251',
login_timeout=10
)
print("✅ Подключение к SQL Server успешно!")
cursor = conn.cursor()
except Exception as e:
print(f"❌ Ошибка подключения: {e}")
sys.exit(1)
# ============================================================
# 6. Поиск сотрудника в AD по email → CARD_ID (как в LK модуле)
# ============================================================
print()
print("=" * 60)
print(" Поиск сотрудника в Active Directory")
print("=" * 60)
TARGET_EMAIL = "ds.krivochenko@sibcem.ru"
print(f" Ищем: {TARGET_EMAIL}")
try:
from ldap3 import Server, Connection, ALL
server = Server(AD_SERVER, get_info=ALL)
ldap_conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
# Как в ad_search.py: ищем по mail, userPrincipalName, sAMAccountName
short_username = TARGET_EMAIL.split("@")[0] if "@" in TARGET_EMAIL else TARGET_EMAIL
or_parts = [
"(mail={})".format(TARGET_EMAIL),
"(userPrincipalName={})".format(TARGET_EMAIL),
"(userPrincipalName={})".format(short_username + "@sibcem.ru"),
"(sAMAccountName={})".format(short_username),
]
or_filter = "(|{})".format("".join(or_parts))
search_filter = "(&{}{})".format("(objectClass=user)", or_filter)
print(f" LDAP-фильтр: {search_filter}")
card_id_from_ad = None
for base in AD_BASES:
ldap_conn.search(
search_base=base,
search_filter=search_filter,
attributes=["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2"],
)
if ldap_conn.entries:
entry = ldap_conn.entries[0]
print(f" ✅ Найден в OU: {base}")
print(f" CN : {entry.cn}")
print(f" sAMAccountName : {entry.sAMAccountName}")
print(f" mail : {entry.mail}")
ext2 = str(entry.extensionAttribute2) if hasattr(entry, 'extensionAttribute2') and entry.extensionAttribute2 else "(не задан)"
print(f" extensionAttribute2: {ext2}")
card_id_from_ad = ext2
break
if not ldap_conn.entries:
print(f" ❌ Сотрудник не найден в AD по email {TARGET_EMAIL}")
except ImportError:
print("❌ ldap3 не установлен")
sys.exit(1)
except Exception as e:
print(f"❌ Ошибка LDAP: {e}")
sys.exit(1)
# ============================================================
# 7. Поиск EMP_ID по CARD_ID в SQL (как в LK модуле)
# ============================================================
print()
print("=" * 60)
print(" Поиск EMP_ID по CARD_ID в SQL")
print("=" * 60)
emp_id = None
card_id = None
if card_id_from_ad:
card_id = card_id_from_ad
print(f" CARD_ID из AD: {card_id}")
# Как в info_service.py: SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
row = cursor.fetchone()
if row:
emp_id = row[0]
print(f" ✅ Найден: emp_id={emp_id}")
else:
print(f" ❌ Сотрудник с CARD_ID={card_id} не найден в UOV_SELFSERVICE_PR_EMP")
# ============================================================
# 8. Загрузка табеля за последние 5 месяцев
# ============================================================
print()
print("=" * 60)
print(" Загрузка табеля за последние 5 месяцев")
print("=" * 60)
if not emp_id:
print(" ❌ Не удалось найти сотрудника — завершение")
conn.close()
sys.exit(1)
now = datetime.now()
months_back = 5
for i in range(months_back):
month = now.month - i
year = now.year
while month <= 0:
month += 12
year -= 1
month_name = calendar.month_name[month]
print(f"\n📅 {month_name} {year}")
print("-" * 40)
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
"""
try:
cursor.execute(query, (emp_id, year, month))
records = cursor.fetchall()
except Exception as e:
print(f" ❌ Ошибка запроса: {e}")
continue
if not records:
print(f" ⚠️ Данные табеля отсутствуют")
continue
print(f" Записей: {len(records)}")
total_days = 0
total_hours = 0.0
codes = {}
for r in records:
tday_id = str(r[1]).strip() if r[1] else "?"
hours = float(r[2]) if r[2] is not None else 0.0
codes[tday_id] = codes.get(tday_id, 0) + 1
if tday_id == 'Я':
total_days += 1
total_hours += hours
print(f" Явки (Я): {codes.get('Я', 0)} дн., часов: {total_hours:.1f}")
print(f" Коды: {', '.join(f'{k}={v}' for k, v in codes.items())}")
print(f" Первые 5 записей:")
for r in records[:5]:
d = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])
print(f" {d} | {str(r[1]).strip():>3} | {float(r[2]) if r[2] else 0:.1f} ч.")
# ============================================================
# Закрытие
# ============================================================
conn.close()
print()
print("=" * 60)
print(" ✅ Проверка завершена")
print("=" * 60)
+9 -71
View File
@@ -7,9 +7,7 @@ import httpx
from ldap3 import Server, Connection, ALL
from trueconf import Router, Message
import config.config as config
from config.config import *
from config.config import DEFAULT_EMAIL_FROM, SMTP_SERVER, SMTP_PORT, ALERTS_SUPPORT_EMAILS
from utils import states
from utils.menu import MENU_TEXT
from utils.texts import (
@@ -18,64 +16,14 @@ from utils.texts import (
transcription_error_bad_extension,
TRANSCRIPTION_UPLOADING_TEXT,
transcription_success_received,
system_error_text,
transcription_error_api,
transcription_error_system,
UNKNOWN_MAIN_CMD_TEXT,
)
# 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats
# =========================================================
# СИСТЕМА ОТПРАВКИ ОШИБОК НА ПОЧТУ
# =========================================================
global _last_transcription_email_time
_last_transcription_email_time = 0.0
def _transcription_log_and_notify_email(action: str, error_details: str):
global _last_transcription_email_time
current_time = time.time()
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
logger.info(f"📧 [EMAIL ALERT] Attempting to send alert for: {action}")
if current_time - _last_transcription_email_time < 10.0:
logger.info(f"📧 [EMAIL ALERT] Cooldown active, skipping. Last: {_last_transcription_email_time}")
return
_last_transcription_email_time = current_time
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
msg = MIMEMultipart()
default_from = getattr(config, 'DEFAULT_EMAIL_FROM', 'bot@noreply.com')
smtp_server = getattr(config, 'SMTP_SERVER', 'localhost')
smtp_port = getattr(config, 'SMTP_PORT', 25)
to_emails = getattr(config, 'ALERTS_SUPPORT_EMAILS', ['admin@example.com'])
logger.info(f"📧 [EMAIL ALERT] From={default_from}, To={to_emails}, SMTP={smtp_server}:{smtp_port}")
msg['From'] = str(default_from)
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Действие: {action}\nДетали:\n{error_details}'
msg.attach(MIMEText(body, 'plain', 'utf-8'))
logger.info(f"📧 [EMAIL ALERT] Connecting to SMTP...")
with smtplib.SMTP(smtp_server, int(smtp_port)) as server:
logger.info(f"📧 [EMAIL ALERT] Sending message...")
server.send_message(msg)
logger.info(f"📧 [EMAIL ALERT] SUCCESS!")
except Exception as e:
logger.error(f"📧 [EMAIL ALERT] FAILED: {e}")
import traceback
logger.error(f"📧 [EMAIL ALERT] Traceback: {traceback.format_exc()}")
pass
import time
from datetime import datetime
logger = logging.getLogger(__name__)
router = Router()
@@ -97,13 +45,13 @@ ALLOWED_EXTENSIONS = [
# --- Функция получения Email из Active Directory ---
def get_user_email_sync(login: str) -> str:
try:
from utils.ad_search import search_by_login
entries = search_by_login(login, ["mail"])
if entries and 'mail' in entries[0] and entries[0].mail.value:
return str(entries[0].mail.value)
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}")
_transcription_log_and_notify_email("Поиск email в AD", str(e))
return DEFAULT_REQUESTER
@router.message()
@@ -168,22 +116,12 @@ async def transcription_handler(msg: Message):
log_menu_stats(user_id, "Speech-to-Text", f"Отправка встречи на расшифровку ({ext})")
await msg.answer(transcription_success_received(user_email), parse_mode="html")
else:
_transcription_log_and_notify_email(f"API транскрипции вернул статус {response.status_code}", str(response.text[:500]))
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
await msg.answer(transcription_error_api(response.status_code), parse_mode="html")
except Exception as e:
logger.error(f"Transcription Upload Error: {e}")
_transcription_log_and_notify_email("Загрузка/отправка файла на транскрипцию", str(e))
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
await msg.answer(transcription_error_system(), parse_mode="html")
return
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
Binary file not shown.
Binary file not shown.
-59
View File
@@ -1,59 +0,0 @@
# /opt/trueconf_bot/utils/ad_checker.py
# Проверка членства пользователя в группах Active Directory
from ldap3 import Server, Connection, ALL
from utils.ad_search import search_by_filter as ad_search_all_bases
def get_user_groups(cn: str) -> list[str] | None:
"""
Найти пользователя по CN и вернуть список групп.
Ищет по всем OU из AD_BASES.
Возвращает None если пользователь не найден.
"""
entries = ad_search_all_bases("(cn={})".format(cn), ["cn", "sAMAccountName", "memberOf"])
if not entries:
return None
entry = entries[0]
return [str(g) for g in entry.memberOf]
def check_group_membership(cn: str, group_pattern: str = "2FA") -> dict:
"""
Проверить членство пользователя в группе.
Args:
cn: CN пользователя (например 'Krivochenko_Denis_Sergeevich')
group_pattern: подстрока для поиска в имени группы (по умолчанию '2FA')
Returns:
dict с полями:
found: bool найден ли пользователь
cn: str | None CN пользователя
sam: str | None sAMAccountName
in_group: bool в группе ли
groups: list[str] все группы
matching_groups: list[str] группы с паттерном
"""
groups = get_user_groups(cn)
if groups is None:
return {
"found": False,
"cn": None,
"sam": None,
"in_group": False,
"groups": [],
"matching_groups": [],
}
matching = [g for g in groups if group_pattern.upper() in g.upper()]
return {
"found": True,
"cn": cn,
"sam": groups[0].split(",")[0].split("=")[-1], # грубо из DN
"in_group": len(matching) > 0,
"groups": groups,
"matching_groups": matching,
}
-73
View File
@@ -1,73 +0,0 @@
# /opt/trueconf_bot/utils/ad_search.py
# Универсальный поиск пользователей по нескольким OU в AD.
# Каждая функция принимает user_id/email/логин и ищет по всем OU из AD_BASES.
# Возвращает первую найденную запись (ldap3 Entry) или None.
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_filter_chars
import config.config as config
def _get_conn():
"""Создать соединение с AD."""
server = Server(config.AD_SERVER, get_info=ALL)
return Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
def search_by_login(login: str, attributes: list[str] | None = None) -> list:
"""
Найти пользователя по sAMAccountName во всех OU из AD_BASES.
Возвращает список Entry (обычно 0 или 1).
"""
attrs = attributes or ["cn", "sAMAccountName"]
conn = _get_conn()
for base in config.AD_BASES:
conn.search(
search_base=base,
search_filter="({}={})".format("sAMAccountName", login),
attributes=attrs,
)
if conn.entries:
return list(conn.entries)
return []
def search_by_user_id(user_id: str, attributes: list[str] | None = None) -> list:
"""
Найти пользователя по email/UPN/sAMAccountName во всех OU из AD_BASES.
Возвращает список Entry (обычно 0 или 1).
"""
attrs = attributes or ["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2"]
safe_id = escape_filter_chars(user_id)
short_username = user_id.split("@")[0] if "@" in user_id else user_id
safe_sam = escape_filter_chars(short_username)
# Строим LDAP filter: (&(objectClass=user)(|(mail=...)(userPrincipalName=...)(sAMAccountName=...)))
or_parts = [
"(mail={})".format(safe_id),
"(userPrincipalName={})".format(safe_id),
"(userPrincipalName={})".format(short_username + "@sibcem.ru"),
"(sAMAccountName={})".format(safe_sam),
]
or_filter = "(|{})".format("".join(or_parts))
search_filter = "(&{}{})".format("(objectClass=user)", or_filter)
conn = _get_conn()
for base in config.AD_BASES:
conn.search(search_base=base, search_filter=search_filter, attributes=attrs)
if conn.entries:
return list(conn.entries)
return []
def search_by_filter(search_filter: str, attributes: list[str]) -> list:
"""
Найти по произвольному LDAP-фильтру во всех OU из AD_BASES.
Возвращает список Entry.
"""
conn = _get_conn()
for base in config.AD_BASES:
conn.search(search_base=base, search_filter=search_filter, attributes=attributes)
if conn.entries:
return list(conn.entries)
return []
-7
View File
@@ -62,13 +62,6 @@ def build_dynamic_menu():
dynamic_items.append(f"{num_char} — Поиск по регламентам")
counter += 1
if getattr(config, 'ENABLE_INSTRUCT', False):
num_char = emoji_digits.get(counter, str(counter))
MENU_MAP[str(counter)] = "INSTRUCT"
MENU_MAP[num_char] = "INSTRUCT"
dynamic_items.append(f"{num_char} — 📋 Инструкции")
counter += 1
if counter == 1:
dynamic_items.append("<i>В данный момент бот находится на техническом обслуживании.</i>")
+11 -9
View File
@@ -63,16 +63,18 @@ async def send_otp_via_api(phone: str, code: str) -> tuple:
return False, str(e)
def get_card_id_from_ad(user_id: str) -> str:
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2).
Ищет по всем OU из AD_BASES."""
from utils.ad_search import search_by_user_id
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2)"""
try:
entries = search_by_user_id(user_id, ["extensionAttribute2"])
if not entries:
raise Exception("Пользователь не найден в AD.")
entry = entries[0]
if 'extensionAttribute2' in entry and entry.extensionAttribute2.value:
return str(entry.extensionAttribute2.value).strip()
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}")
-1
View File
@@ -10,7 +10,6 @@ PHOTO_MODE = "PHOTO_MODE"
SD_MODE = "SD_MODE"
TRANSCRIPTION_MODE = "TRANSCRIPTION_MODE"
SEARCH_MODE = "SEARCH_MODE"
INSTRUCT_EMAIL_HISTORY = "INSTRUCT_EMAIL_HISTORY"
def set_state(user_id, state):
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
+41 -144
View File
@@ -2,16 +2,18 @@
# Все текстовые сообщения бота, отправляемые пользователю
# =========================================================
# GLOBAL CONSTANTS & HELPERS
# GLOBAL CONSTANTS
# =========================================================
# --- Main Bot ---
# Общее для главного бота
UNKNOWN_MAIN_CMD_TEXT = (
"⚠️ <b>Неизвестная команда.</b>\n\n"
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
)
# --- Emoji Digits ---
# Используются для навигации и выбора пунктов меню
EMOJI_DIGITS = {
"0": "0⃣",
"1": "1⃣",
@@ -27,6 +29,7 @@ EMOJI_DIGITS = {
}
# --- Footer Templates ---
# Шаблоны подвалов сообщений для навигации
def _footer(emojis: dict) -> str:
"""Стандартный футер с кнопками Назад и Меню"""
@@ -36,17 +39,6 @@ def _footer_main_only(emojis: dict) -> str:
"""Футер только с кнопкой Меню"""
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
# --- Universal System Error Stub ---
def system_error_text(emojis: dict = EMOJI_DIGITS, show_back: bool = True) -> str:
"""Универсальная системная заглушка ошибки для всех модулей бота."""
footer = _footer(emojis) if show_back else _footer_main_only(emojis)
return (
"⚠️ <i>В данный момент выполнить это действие невозможно. "
"Информация о проблеме передана ответственным.\n"
f"Приносим извинения за доставленные неудобства.</i>{footer}"
)
# =========================================================
# TRANSCRIPTION BOT
@@ -148,14 +140,16 @@ def _sd_ticket_created(ticket_id: int, subject: str, description: str, emojis: d
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_create_error(emojis: dict) -> str:
return f"❌ <b>Ошибка создания заявки.</b>{_footer_main_only(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)}"
@@ -176,8 +170,10 @@ SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
SD_CREATING = _sd_creating()
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
def _sd_ticket_create_error(emojis: dict) -> str:
return (
f"❌ <b>Не удалось создать заявку.</b>\n\n" f"Пожалуйста, повторите попытку позже.\n\n" f"{emojis['0']}В главное меню"
)
def sd_ticket_create_error() -> str:
return _sd_ticket_create_error(EMOJI_DIGITS)
@@ -217,6 +213,14 @@ def _photo_unknown(emojis: dict) -> str:
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 "⏳ Отправка..."
@@ -253,7 +257,7 @@ PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
def photo_error_handling() -> str:
return system_error_text(EMOJI_DIGITS, show_back=False)
return _photo_error_handling(EMOJI_DIGITS)
PHOTO_UPLOADING_TEXT = _photo_uploading()
@@ -319,6 +323,14 @@ def _search_feedback_info(emojis: dict) -> str:
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)
@@ -337,7 +349,7 @@ def search_feedback_info_text(emojis: dict) -> str:
return _search_feedback_info(emojis)
def search_unavailable_action(emojis: dict) -> str:
return system_error_text(emojis, show_back=False)
return _search_unavailable(emojis)
# =========================================================
@@ -345,6 +357,8 @@ def search_unavailable_action(emojis: dict) -> str:
# =========================================================
# Модуль Личного Кабинета (финансовые документы, табель и др.)
# --- Constants ---
DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС",
@@ -357,6 +371,12 @@ DOC_TYPES = {
# --- 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>"
@@ -380,7 +400,7 @@ def _lk_payslip_loading(emojis: dict) -> str:
return "⏳ <i>Формирую архив расчетных листков...</i>"
def _lk_vacation_loading(emojis: dict) -> str:
return "⏳ <i>Формирую справку по отпускам...</i>"
return "⏳ <i>Формирую справку по отпусках...</i>"
def _lk_sick_loading(emojis: dict) -> str:
return "⏳ <i>Загружаю историю больничных листов...</i>"
@@ -444,15 +464,6 @@ def _lk_docs_quantity_prompt(emojis: dict) -> str:
def _lk_docs_year_prompt(emojis: dict) -> str:
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_years_menu(current_year: int, emojis: dict) -> str:
lines = ["📅 <b>Выберите год запрашиваемого периода:</b>\n"]
for i in range(1, 9):
year = current_year - (i - 1)
emoji = emojis.get(str(i), f"{i}")
lines.append(f"{emoji}{year} год")
lines.append(f"\n<i>{emojis.get('9', '9⃣')} — Назад\n{emojis.get('0', '0⃣')} — В главное меню</i>")
return "\n".join(lines)
def _lk_docs_avg_salary_prompt(emojis: dict) -> str:
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
@@ -484,7 +495,7 @@ LK_UNKNOWN_CMD_TEXT = (
)
def lk_error_handling(emojis: dict) -> str:
return system_error_text(emojis, show_back=True)
return _lk_error_handling(emojis)
def lk_auth_processing(emojis: dict) -> str:
return _lk_auth_processing(emojis)
@@ -562,118 +573,4 @@ 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)
# =========================================================
# INSTRUCT BOT (Инструкции)
# =========================================================
# Модуль инструкций
# --- Private functions (Internal logic) ---
def _instruct_main_menu(emojis: dict) -> str:
return (
f"📋 <b>Выберите инструкцию:</b>\n\n"
f"{emojis['1']} — 🖥 Настройка TrueConf на мобильном устройстве\n"
f"{emojis['2']} — 📧 Настройка почты на мобильном устройстве\n\n"
f"<i>{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf(emojis: dict) -> str:
"""Статический текст Trueconf — без проверки группы."""
return (
"🖥 <b>Инструкция по TrueConf</b>\n\n"
"Данная функция находится в разработке.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf_pending(emojis: dict, cn: str) -> str:
"""Промпт для запроса доступа к Trueconf (когда заявка ещё не создана)."""
return (
f"⚠ <b>У Вас отсутствует доступ к TrueConf с мобильных устройств.</b>\n\n"
f"Если Вам требуется доступ, то напишите цифру {emojis['1']}, либо выберите другие пункты меню.\n\n"
f"{emojis['1']} — Запросить доступ\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf_no_ticket(emojis: dict, cn: str) -> str:
"""Текст Trueconf, когда пользователь не найден в AD."""
return (
f"🖥 <b>Инструкция по TrueConf</b>\n\n"
f"Пользователь <b>{cn}</b> не найден в Active Directory.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf_with_ad(emojis: dict, cn: str, ticket_id: str = None) -> str:
"""
Чистый шаблон сообщения Trueconf:
- Если ticket_id передан: показываем статус заявки в ServiceDesk.
- Если ticket_id is None: у пользователя есть доступ, выводим подпись к файлу.
"""
if ticket_id:
return (
f"⚠ <b>У Вас отсутствует доступ к TrueConf с мобильных устройств.</b>\n\n"
f"🎫 Заявка #{ticket_id} для настройки создана в ServiceDesk!\n\n"
f"После выполнения заявки воспользуйтесь приложенной к этому сообщению инструкцией.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
else:
return (
f"🖥 <b>Инструкция по настройке TrueConf на мобильном устройстве</b>\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_email_available(emojis: dict, cn: str, mail: str) -> str:
"""Текст когда почта доступна."""
return (
f"📧 <b>Инструкция по настройке Почты на мобильном устройстве</b>\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_email_history_ask(emojis: dict, cn: str) -> str:
"""Вопрос о наличии ранее доступа к мобильной почте."""
return (
f"📧 <b>Был ли у Вас ранее доступ к мобильной почте?</b>\n\n"
f"Напишите нужную цифру в ответ:\n\n"
f"{emojis['1']} — да, был\n"
f"{emojis['2']} — нет, не было\n\n"
f"{emojis['0']}В главное меню"
)
def _instruct_email_unavailable(emojis: dict, cn: str) -> str:
"""Текст когда почта недоступна (старый вариант для обратной совместимости)."""
return (
f"⚠️ <b>У Вас отсутствует доступ к Почте</b>\n\n"
"Создайте, пожалуйста, заявку в Directum RX на модификацию прав пользователя.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_email_not_had(emojis: dict, cn: str) -> str:
"""Текст когда пользователь ответил 'не было доступа к мобильной почте'."""
return (
f"⚠️ <b>У Вас отсутствует доступ к мобильной почте.</b>\n\n"
"Создайте, пожалуйста, заявку в Directum RX на \"Удалённый доступ к ИС\".\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
# --- Public constants and functions ---
INSTRUCT_MAIN_MENU_TEXT = _instruct_main_menu(EMOJI_DIGITS)
INSTRUCT_TRUECONF_TEXT = _instruct_trueconf(EMOJI_DIGITS)
INSTRUCT_TRUECONF_WITH_AD = _instruct_trueconf_with_ad
INSTRUCT_TRUECONF_PENDING_TEXT = _instruct_trueconf_pending
INSTRUCT_TRUECONF_NO_TICKET_TEXT = _instruct_trueconf_no_ticket
INSTRUCT_EMAIL_AVAILABLE = _instruct_email_available
INSTRUCT_EMAIL_HISTORY_ASK = _instruct_email_history_ask
INSTRUCT_EMAIL_NOT_HAD = _instruct_email_not_had
INSTRUCT_EMAIL_UNAVAILABLE = _instruct_email_unavailable
# Единая стандартная заглушка системной ошибки для модуля инструкций
INSTRUCT_ERROR_MSG = system_error_text(EMOJI_DIGITS, show_back=True)
return lk_years_menu(current_year, emojis)