49 Commits

Author SHA1 Message Date
Денис Кривоченко dff39f025c Merge branch 'main' of http://192.168.1.108:3000/dddennnisss/trueconf_bot 2026-07-23 23:05:43 +07:00
Денис Кривоченко 523ed89519 Обновить instruct/handlers.py — файл с текстом в одном сообщении 2026-07-23 23:05:34 +07:00
dddennnisss d1ad0b16a1 Обновить handlers.py — универсальный текст заявки 2FA 2026-07-23 22:50:34 +07:00
Денис Кривоченко 6a6d59394a Обновить handlers.py — универсальный текст заявки 2FA 2026-07-23 22:27:50 +07:00
Денис Кривоченко 32f1cd9513 Обновить utils/texts.py — упростить текст Trueconf 2026-07-23 21:48:01 +07:00
dddennnisss 51b632ec7d Обновить instruct/handlers.py 2026-07-23 20:47:02 +07:00
dddennnisss 54b019f3b9 Fix: correct AD→CARD_ID→EMP_ID chain (no EMAIL column query) 2026-07-23 17:28:15 +07:00
dddennnisss 7c900e5502 Add AD lookup by email → CARD_ID → SQL tabel check for ds.krivochenko@sibcem.ru 2026-07-23 17:25:48 +07:00
dddennnisss 7d67e3ffc2 Fix: properly load DB credentials from Passwork + .env 2026-07-23 17:17:57 +07:00
dddennnisss af0caee963 Add test_tabel_check.py - SQL server table check script 2026-07-23 16:43:47 +07:00
dddennnisss 0dca28ef20 Обновить utils/texts.py 2026-07-23 15:55:48 +07:00
dddennnisss de47c39e76 Обновить lk/handlers.py 2026-07-23 15:50:52 +07:00
dddennnisss f0be37b18f Обновить instruct/handlers.py 2026-07-23 15:49:59 +07:00
dddennnisss 9aedbd4c1f Обновить config/config.py 2026-07-23 15:48:55 +07:00
Денис Кривоченко 977e7369e3 fix: use ai@sibcem.ru when user has no mail in AD 2026-07-23 10:41:17 +07:00
Денис Кривоченко 23a7d20722 fix: create SD ticket on Trueconf instruct access 2026-07-23 10:36:35 +07:00
Денис Кривоченко 1caae96a97 fix: импортируем create_ticket_in_sd из service_desk 2026-07-23 02:17:16 +07:00
Денис Кривоченко 90bac4e343 fix: sd_create_ticket — data=form-encoded как в service_desk 2026-07-23 02:14:17 +07:00
Денис Кривоченко 7517448508 fix: sd_create_ticket — content=json.dumps(input_data), DEFAULT_REQUESTER 2026-07-23 02:09:03 +07:00
Денис Кривоченко f98ba35a60 feat: instruct создаёт SD заявку при отсутствии 2FA 2026-07-23 01:54:56 +07:00
Денис Кривоченко 0ff328694c fix: add Trueconf instruction docx 2026-07-23 01:53:05 +07:00
Денис Кривоченко 04430e56ef feat: instruct создаёт SD заявку при отсутствии 2FA 2026-07-23 01:52:45 +07:00
dddennnisss 0b76fbaf17 Добавить файл инструкции Trueconf.docx в ответ бота 2026-07-23 01:20:56 +07:00
dddennnisss f4c21c825c Загрузить файлы в «instruct» 2026-07-23 01:08:54 +07:00
Денис Кривоченко 3fa7543477 fix: LDAP filter malformed — добавлен символ '|' в or_filter
search_by_user_id: or_filter был '(...)' вместо '(|...)' — LDAP требовал OR-оператор
2026-07-22 18:53:34 +07:00
Денис Кривоченко 064451dc7c add: check_card_id.py — проверка user_id -> CARD_ID -> телефон
Использует AD_BASES через utils.ad_search.search_by_user_id
2026-07-22 18:32:35 +07:00
Денис Кривоченко c76914444c fix: грамматика 'справка по отпускам' (было 'по отпусках') 2026-07-22 16:56:02 +07:00
Денис Кривоченко 09b19f9ced AD: поиск по нескольким OU (AD_BASES)
- config.py: AD_BASE -> AD_BASES (кортеж OU)
- utils/ad_search.py: новая утилита для поиска по всем OU
- utils/ad_checker.py: использовать ad_search
- utils/otp_service.py: использовать ad_search
- lk/handlers.py: использовать ad_search
- transcription_bot/handlers.py: использовать ad_search
- service_desk/handlers.py: использовать ad_search
- instruct/handlers.py: использовать ad_search
- photo_bot/ad_helper.py: использовать ad_search
- photo_bot/check_ad_photo.py: использовать ad_search
- check_ad_2fa.py: использовать ad_search
- debug_ad_filter.py: обновить для AD_BASES
- lk/test_lk_payslip.py: обновить для AD_BASES
2026-07-22 16:52:17 +07:00
Денис Кривоченко 9f76064826 fix: remove extra closing paren in LDAP filter 2026-07-22 00:49:21 +07:00
Денис Кривоченко 49a986fa65 fix: don't escape @ in email for LDAP filter 2026-07-22 00:41:53 +07:00
Денис Кривоченко 2c39e5b50b fix: use lk/handlers.py filter format, add debug script 2026-07-22 00:41:26 +07:00
Денис Кривоченко 26b58f2909 fix: search AD in full domain tree + debug logging 2026-07-22 00:34:40 +07:00
Денис Кривоченко d9528486ef fix: clean duplicate logging, use proper AD lookup with debug 2026-07-22 00:33:45 +07:00
Денис Кривоченко 525899557b add debug logging to AD lookup 2026-07-22 00:32:41 +07:00
Денис Кривоченко 03339885ff fix: replace tcs.sibcem.ru with sibcem.ru in AD user lookup 2026-07-22 00:02:16 +07:00
Денис Кривоченко 8fa42206aa add AD 2FA group check to instruct Trueconf module 2026-07-21 23:47:35 +07:00
Денис Кривоченко be12045fa8 add check_ad_2fa.py - interactive AD group membership checker 2026-07-21 23:27:36 +07:00
Денис Кривоченко 7c0aaf5392 fix: set state on instruct menu selection so back button works 2026-07-21 22:48:39 +07:00
Денис Кривоченко 55a95889b0 import EMOJI_DIGITS from utils/texts.py 2026-07-21 22:44:54 +07:00
Денис Кривоченко 8029f7fb2d move instruct text constants to utils/texts.py 2026-07-21 22:40:08 +07:00
Денис Кривоченко 2fa4ff2781 fix: replace <br> with real newlines for HTML parse mode 2026-07-21 22:36:03 +07:00
Денис Кривоченко aadbaab321 use UNKNOWN_MAIN_CMD_TEXT for instruct module (unified error style) 2026-07-21 22:32:38 +07:00
Денис Кривоченко 54f0d074ba unify INSTRUCT_UNKNOWN_CMD_TEXT with other modules 2026-07-21 22:28:18 +07:00
Денис Кривоченко 642f2bba6a Fix instruct: replace <br> with \n for HTML line breaks 2026-07-21 17:43:31 +07:00
Денис Кривоченко 77cbb1d352 Fix instruct: use <br> for HTML line breaks, fix unknown cmd text 2026-07-21 17:32:53 +07:00
Денис Кривоченко 8e686b1ca3 Fix instruct handlers: correct newline escaping in text strings 2026-07-21 17:28:16 +07:00
Денис Кривоченко d30ceeda6b Add blank line between instruct header and buttons 2026-07-21 17:24:46 +07:00
Денис Кривоченко 0168a8b0a5 Fix instruct menu: remove extra blank line after header 2026-07-21 17:21:18 +07:00
Денис Кривоченко 260b6c3a8a Add instruct module with Trueconf and Email guides 2026-07-21 17:13:05 +07:00
26 changed files with 1318 additions and 1374 deletions
+109
View File
@@ -0,0 +1,109 @@
#!/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
@@ -0,0 +1,154 @@
#!/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
@@ -1,268 +0,0 @@
#!/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()
+11 -9
View File
@@ -81,20 +81,19 @@ for var_name, card_name in CREDENTIALS_MAP.items():
# =========================================================
# 1. ГЛОБАЛЬНЫЕ ФЛАГИ МОДУЛЕЙ
# =========================================================
ENABLE_SERVICE_DESK = True # Модуль создания заявок в техподдержку
ENABLE_PHOTO_BOT = True # Модуль обработки фото/сканов
ENABLE_SERVICE_DESK = False # Модуль создания заявок в техподдержку
ENABLE_PHOTO_BOT = False # Модуль обработки фото/сканов
ENABLE_TRANSCRIPTION = True # Модуль расшифровки голосовых сообщений (Whisper)
ENABLE_SEARCH_BOT = True # Модуль интеллектуального поиска по регламентам (RAG)
ENABLE_SEARCH_BOT = False # Модуль интеллектуального поиска по регламентам (RAG)
ENABLE_INSTRUCT = False # Модуль инструкций (Trueconf, Почта)
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# =========================================================
# 2. КОНТРОЛЬ ДОСТУПА
# =========================================================
ENABLE_WHITELIST = True # Включить режим закрытого бета-тестирования
ENABLE_WHITELIST = False # Включить режим закрытого бета-тестирования
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
"ds.krivochenko@tcs.sibcem.ru",
"mm.norenberg@tcs.sibcem.ru",
"a.kirpichev@tcs.sibcem.ru"
]
# =========================================================
@@ -103,7 +102,10 @@ ALLOWED_USERS = [ # Список TrueConf ID разрешенны
TC_SERVER = os.getenv("TC_SERVER", "") # Адрес сервера TrueConf
AD_SERVER = "ldap://172.16.20.20" # Адрес контроллера домена Active Directory
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru" # Базовый путь поиска пользователей в AD
AD_BASES = ( # Базовые пути поиска пользователей в AD (несколько OU)
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru" # Сервер MS SQL для работы Личного кабинета
SQL_DB_NAME = "BossCopy" # База данных с информацией по кадрам и ЗП
@@ -201,7 +203,7 @@ DEBUG_TEXT_LIMIT = 250
# =========================================================
# 9. ПОДСИСТЕМА МОНИТОРИНГА И СТАТУСА БОТА (HEARTBEAT)
# =========================================================
ADMIN_CHAT_ID = "744bad93e58c4c498e15468562c6a2da3b610e9c"
ADMIN_CHAT_ID = "54966d1f56c2fda8e263a70c3a65ff0fad27ef7d"
ENABLE_HEARTBEAT_MONITOR = True
HEARTBEAT_CHECK_INTERVAL_SEC = 10.0
HEARTBEAT_MAX_FAILURES = 3
+59
View File
@@ -0,0 +1,59 @@
#!/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
+263
View File
@@ -0,0 +1,263 @@
import logging
import asyncio
import os
import sys
import json
import httpx
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_EMAIL_TEXT,
)
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
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
logger = logging.getLogger(__name__)
router = Router()
# Путь к файлу инструкции Trueconf
TRUECONF_INSTRUCT_PATH = os.path.join(
os.path.dirname(__file__),
"Проверка_наличия_и_авторизация_на_мобильном_устройстве_Trueconf.docx"
)
def get_user_cn(user_id: str) -> str | None:
"""
Получить CN пользователя из AD по его user_id (email или логин).
user_id из TrueConf — это обычно email, например 'ds.krivochenko@tcs.sibcem.ru'
"""
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.
Возвращает {cn, mail, city} или None.
"""
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"])
if entries:
entry = entries[0]
cn = str(entry.cn)
raw_mail = str(entry.mail) if 'mail' in entry else ""
# mail может быть "[]" (пустой LDAP объект), "" или "N/A" — фильтруем
mail = raw_mail if raw_mail and raw_mail not in ("[]", "N/A", "") else None
city = str(entry.l) if 'l' in entry and entry.l.value else "Кемерово"
return {"cn": cn, "mail": 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_email: str, subject: str, description: str, city: str) -> str | None:
"""
Автономная прямая отправка HTTP-запроса в Service Desk API v3.
"""
# Если запрашивает системный ИИ или не указан email — ищем по ИМЕНИ
if requester_email == DEFAULT_REQUESTER or requester_email == "ai@sibcem.ru":
requester_payload = {"name": "Искусственный Интеллект"}
else:
requester_payload = {"email_id": requester_email}
payload = {
"request": {
"subject": subject,
"description": description,
"requester": requester_payload,
"udf_fields": {
"udf_pick_301": city
}
}
}
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
else:
logger.error(f"Service Desk API Error ({response.status_code}): {response.text}")
return None
async def create_sd_ticket_for_trueconf(user_id: str, cn: str) -> str | None:
"""
Создать заявку в Service Desk для запроса доступа к Trueconf.
Полностью автономная функция.
"""
subject = "Запрос доступа к Trueconf"
# Пытаемся получить информацию из AD
ad_info = get_ad_user_info(user_id)
logger.info(f"AD search result for SD ticket ({user_id}): {ad_info}")
if ad_info and ad_info.get("mail"):
requester_email = ad_info["mail"]
city = ad_info.get("city", "Кемерово")
description = (
f"Пользователь {cn} ({user_id}) отсутствует группа 2FA."
)
else:
requester_email = DEFAULT_REQUESTER
city = "Кемерово"
logger.info(f"Using DEFAULT_REQUESTER ({requester_email}) for user {user_id} (cn={cn})")
if ad_info and not ad_info.get("mail"):
description = (
f"Пользователь {cn} ({user_id}) отсутствует группа 2FA."
)
else:
description = (
f"Пользователь {cn} ({user_id}) отсутствует группа 2FA."
)
try:
logger.info(f"Calling SD API directly: requester={requester_email}, city={city}")
ticket_id = await _send_sd_api_request(requester_email, subject, description, city)
if ticket_id:
logger.info(f"SD ticket created for {cn}: #{ticket_id} (requester={requester_email})")
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")
set_state(user_id, "INSTRUCT_TRUECONF_VIEW")
# Получаем CN пользователя
cn = get_user_cn(user_id)
if cn is None:
cn = user_id # fallback
# Проверяем наличие группы 2FA в AD перед созданием заявки
from utils.ad_checker import check_group_membership
ad_check = check_group_membership(cn, "2FA")
ticket_id = None
# Создаём заявку в SD ТОЛЬКО если у пользователя НЕТ группы 2FA (или он не найден)
if not ad_check.get("in_group", False):
ticket_id = await create_sd_ticket_for_trueconf(user_id, cn)
else:
logger.info(f"User {cn} already has 2FA group. SD ticket creation skipped.")
# Показываем текст с результатом и отправляем документ с caption
text = INSTRUCT_TRUECONF_WITH_AD(EMOJI_DIGITS, cn, ticket_id)
if os.path.isfile(TRUECONF_INSTRUCT_PATH):
await msg.bot.send_document(
chat_id=msg.chat_id,
file=FSInputFile(TRUECONF_INSTRUCT_PATH),
caption=text,
parse_mode="html"
)
elif cmd == "2":
log_menu_stats(user_id, "Инструкции", "Почта")
set_state(user_id, "INSTRUCT_EMAIL_VIEW")
await msg.answer(INSTRUCT_EMAIL_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")
+57 -32
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 = 1.5
USER_COOLDOWN_SEC = 0.5
EMAIL_COOLDOWN_SEC = 10.0
user_last_request = {}
@@ -129,19 +129,17 @@ async def handle_lk_error(msg: Message, user_id: str, action: str, error_details
# ИНТЕГРАЦИЯ С ACTIVE DIRECTORY ЧЕРЕЗ extensionAttribute2
# =========================================================
def get_card_id_from_ad(user_id: str) -> str:
from utils.ad_search import search_by_user_id
try:
server = Server(config.AD_SERVER, get_info=ALL)
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split('@')[0]
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
entries = search_by_user_id(search_id, attributes=['extensionAttribute2'])
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()
if entries and 'extensionAttribute2' in entries[0] and entries[0].extensionAttribute2.value:
return str(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}")
# =========================================================
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
@@ -206,12 +204,25 @@ async def send_dynamic_lk_main_menu(msg: Message, user_id: str):
await msg.answer(menu_text, parse_mode="html")
async def send_tabel_months_menu(msg: Message, user_id: str):
now = datetime(2026, 6, 3)
prev_1 = now.replace(day=1) - timedelta(days=1)
prev_2 = prev_1.replace(day=1) - timedelta(days=1)
months_ru = {1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь", 7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"}
now = datetime.now()
m1 = now.replace(day=1) - timedelta(days=1) # Прошлый месяц (M-1)
m2 = m1.replace(day=1) - timedelta(days=1) # Позапрошлый месяц (M-2)
months_ru = {
1: "Январь", 2: "Февраль", 3: "Март", 4: "Апрель", 5: "Май", 6: "Июнь",
7: "Июль", 8: "Август", 9: "Сентябрь", 10: "Октябрь", 11: "Ноябрь", 12: "Декабрь"
}
m1_str = f"{months_ru[m1.month]} {m1.year} г."
m2_str = f"{months_ru[m2.month]} {m2.year} г."
menu_text = (
"⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"
f"{EMOJI_DIGITS['1']}{m1_str}\n"
f"{EMOJI_DIGITS['2']}{m2_str}\n\n"
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
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")
@@ -267,13 +278,22 @@ 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()
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return
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
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():
@@ -372,7 +392,6 @@ 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":
@@ -385,16 +404,28 @@ async def lk_router_handler(msg: Message):
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
elif cmd in ["1", "2"]:
log_menu_stats(user_id, "Личный кабинет", "Просмотр табеля за месяц")
log_menu_stats(user_id, "Личный кабинет", f"Просмотр табеля (вариант {cmd})")
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
now = datetime(2026, 6, 3)
target_date = now.replace(day=1) - timedelta(days=1) if cmd == "1" else (now.replace(day=1) - timedelta(days=1)).replace(day=1) - timedelta(days=1)
tabel_report = await asyncio.to_thread(get_tabel_report_text, card_id, target_date.year, target_date.month)
now = datetime.now()
m1 = now.replace(day=1) - timedelta(days=1)
if cmd == "1":
target_date = m1 # Прошлый месяц
elif cmd == "2":
target_date = m1.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")
@@ -578,7 +609,6 @@ 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":
@@ -606,7 +636,6 @@ 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":
@@ -619,14 +648,12 @@ 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":
@@ -670,7 +697,6 @@ 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":
@@ -678,5 +704,4 @@ 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")
+22 -13
View File
@@ -13,7 +13,10 @@ from ldap3.utils.conv import escape_filter_chars
TEST_USER = "man.bogov@tcs.sibcem.ru"
AD_SERVER = "ldap://172.16.20.20"
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
AD_BASES = (
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
AD_USER = "sdesk-mail"
AD_PASSWORD = "fne?e!q.m8phcrGVAcqr"
@@ -32,7 +35,6 @@ 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,
@@ -41,22 +43,29 @@ 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 = (
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)})))"
"(&(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),
)
)
conn.search(
AD_BASE,
search_filter,
attributes=['mobile', 'telephoneNumber']
)
for base in AD_BASES:
conn.search(
base,
search_filter,
attributes=['mobile', 'telephoneNumber']
)
if conn.entries:
break
if not conn.entries:
raise Exception("Пользователь не найден в AD")
+18 -71
View File
@@ -1,7 +1,6 @@
import sys
import asyncio
import logging
from logging.handlers import RotatingFileHandler
import os
import psutil
import time
@@ -18,13 +17,6 @@ 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__)))
@@ -79,7 +71,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, sd_sessions
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT
log_ram_usage("После импорта service_desk")
from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT
@@ -88,6 +80,9 @@ 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 (Личный кабинет)")
@@ -120,68 +115,7 @@ 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", "Вход")
# Помечаем сообщение как обработанное, чтобы 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
await msg.answer(SD_MAIN_MENU_TEXT, parse_mode="html")
return
elif action == "PHOTO":
USER_MENU_SHOWN[user_id] = True
@@ -208,6 +142,18 @@ 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
@@ -225,6 +171,7 @@ 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
@@ -1,100 +0,0 @@
#!/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()
+15 -13
View File
@@ -5,37 +5,39 @@ from ldap3 import Server, Connection, ALL, MODIFY_REPLACE
# Подтягиваем конфиг
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
from config.config import AD_SERVER, AD_USER, AD_PASSWORD
from utils.ad_search import search_by_login as ad_search_all_bases
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 = f"(mail={target_user})"
ldap_filter = "(mail={})".format(target_user)
else:
ldap_filter = f"(sAMAccountName={target_user})"
ldap_filter = "(sAMAccountName={})".format(target_user)
# Подключаемся к AD
entries = search_by_filter(ldap_filter, ["cn"])
if not entries:
return {"success": False, "error": "Пользователь {} не найден в AD.".format(target_user)}
user_dn = entries[0].entry_dn
# Подключаемся для модификации
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])]})
+16 -23
View File
@@ -7,7 +7,8 @@ from PIL import Image
# Подтягиваем ваши настройки из конфига
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
from config.config import AD_SERVER, AD_USER, AD_PASSWORD
from utils.ad_search import search_by_filter as ad_search_all_bases
logging.basicConfig(level=logging.INFO, format="%(message)s")
@@ -21,54 +22,46 @@ def check_user_photo(target_user: str):
ldap_filter = f"(sAMAccountName={target_user})"
try:
# Подключаемся к AD
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
# Ищем пользователя и просим вернуть атрибуты: имя (cn) и фото (thumbnailPhoto)
conn.search(
search_base=AD_BASE,
search_filter=ldap_filter,
attributes=["cn", "thumbnailPhoto"]
)
if not conn.entries:
# Подключаемся к AD и ищем по всем OU из AD_BASES
entries = ad_search_all_bases(ldap_filter, ["cn", "thumbnailPhoto"])
if not entries:
print("❌ Пользователь с такими данными не найден в AD.")
return
user = conn.entries[0]
user = entries[0]
user_name = user.cn.value if 'cn' in user else target_user
print(f"👤 Найден сотрудник: {user_name}")
# Проверяем, есть ли вообще фото
if 'thumbnailPhoto' not in user or not user.thumbnailPhoto.value:
print("⚠️ У этого пользователя НЕТ фотографии в Active Directory.")
return
# Достаем байты фотографии
photo_bytes = user.thumbnailPhoto.value
size_kb = len(photo_bytes) / 1024
# Читаем картинку прямо из памяти (без сохранения на диск) с помощью Pillow
image = Image.open(io.BytesIO(photo_bytes))
width, height = image.size
print("\n📊 --- ИНФОРМАЦИЯ О ФОТО В AD ---")
print(f"📐 Разрешение : {width}x{height} пикселей")
print(f"⚖️ Вес : {size_kb:.1f} KB")
print(f"🗂 Формат : {image.format}")
print("----------------------------------\n")
# Бонус: сохраняем фото во временную папку, чтобы вы могли на него взглянуть
safe_name = target_user.replace('@', '_').replace('.', '_')
save_path = f"/tmp/{safe_name}_ad.jpg"
with open(save_path, "wb") as f:
f.write(photo_bytes)
print(f"💾 Оригинал из AD сохранен сюда: {save_path}")
print("Вы можете скачать его через WinSCP или открыть в TrueConf, чтобы оценить качество глазами.")
except Exception as e:
print(f"❌ Ошибка при подключении к AD: {e}")
-289
View File
@@ -1,289 +0,0 @@
#!/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
@@ -1,298 +0,0 @@
#!/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
@@ -1,82 +0,0 @@
#!/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()
+56 -150
View File
@@ -23,7 +23,7 @@ if not sd_workflow_logger.handlers:
handler.setFormatter(formatter)
sd_workflow_logger.addHandler(handler)
# ----------------------------------
# Абсолютный импорт конфигурации
spec = importlib.util.spec_from_file_location("custom_config", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config", "config.py"))
custom_config = importlib.util.module_from_spec(spec)
@@ -44,14 +44,14 @@ from utils.texts import (
sd_uploading_file,
sd_file_attached,
sd_file_upload_error,
)
)
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
# 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats
# Таймаут ожидания вложений после текста (секунды)
ATTACHMENT_WAIT_TIMEOUT = 2.0
SD_DISPATCHER_SYSTEM_PROMPT = (
"Ты — старший ИИ-диспетчер Service Desk холдинга. Твоя задача — сформулировать краткую и понятную тему ИТ-заявки по-русски (от 3 до 7 слов) на основе текста пользователя.\n"
"ПРАВИЛА:\n"
@@ -59,7 +59,7 @@ SD_DISPATCHER_SYSTEM_PROMPT = (
"2. Отвечать на английском языке ЗАПРЕЩЕНО.\n"
"3. Тебе категорически запрещено решать проблему или писать мануалы по настройке.\n"
"4. Не пиши префиксы 'Тема:', 'Заголовок:' в ответе.\n"
"5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ.")
"5. Если в тексте только приветствие или мат — выведи ровно одно слово: СПАМ.")
# Глушим системные предупреждения
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@@ -67,71 +67,25 @@ 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:
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["displayName", "mail", "l", "userAccountControl"])
if conn.entries:
user = conn.entries[0]
uac = user.userAccountControl.value if 'userAccountControl' in user else 0
return {
"name": user.displayName.value if 'displayName' in user else login,
"mail": user.mail.value if 'mail' in user else None,
"city": user.l.value if 'l' in user else "Кемерово",
"is_disabled": bool(uac & 2)
}
except Exception as e:
logger.error(f"Ошибка LDAP: {e}")
return None
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]
uac = user.userAccountControl.value if 'userAccountControl' in user else 0
return {
"name": user.displayName.value if 'displayName' in user else login,
"mail": user.mail.value if 'mail' in user else None,
"city": user.l.value if 'l' in user else "Кемерово",
"is_disabled": bool(uac & 2)
}
except Exception as e:
logger.error(f"Ошибка LDAP: {e}")
return None
async def generate_smart_subject(text: str) -> str:
import re, traceback, importlib.util, httpx
@@ -142,14 +96,14 @@ async def generate_smart_subject(text: str) -> str:
OLLAMA_SUBJECT_MODEL = custom_config.OLLAMA_SUBJECT_MODEL
def has_chinese(t): return bool(re.search(r'[\u4e00-\u9fff]', str(t)))
if not text or len(text.split()) < 2: return "Заявка из КЛЕВЕР"
user_msg = f"Сформулируй краткую тему для следующей ИТ-заявки:\n{text}"
messages = [
{"role": "system", "content": SD_DISPATCHER_SYSTEM_PROMPT},
{"role": "user", "content": user_msg}
]
try:
payload = {"model": OLLAMA_SUBJECT_MODEL, "messages": messages, "temperature": 0.0}
sd_workflow_logger.info(f"📝 [Subject Gen] Payload: {payload}")
@@ -173,36 +127,18 @@ 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, template_id: str | None = None, templates: list | None = None):
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str):
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
endpoint = f"{SD_URL}/api/v3/requests"
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
async def _send_req(email, current_city, 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:
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)
async def _send_req(email, current_city):
payload = {"request": {"subject": subject, "description": html_desc, "requester": {"email_id": email}, "udf_fields": {"udf_pick_301": current_city}}}
async with httpx.AsyncClient(verify=False) as client:
return await client.post(endpoint, headers=headers, data={"input_data": json.dumps(payload)}, timeout=15.0)
resp = await _send_req(requester_email, city)
data = resp.json() if resp.status_code in [200, 201] else {}
if data.get("response_status", {}).get("status_code") != 2000:
resp = await _send_req(requester_email, "Кемерово")
data = resp.json() if resp.status_code in [200, 201] else {}
if data.get("response_status", {}).get("status_code") == 2000:
return data.get("request", {}).get("id")
@@ -241,7 +177,7 @@ async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
return False
finally:
if downloaded_path and os.path.exists(downloaded_path):
os.remove(downloaded_path)
os.remove(downloaded_path)
def _extract_text_from_content(content):
"""Извлечение текста из msg.content."""
@@ -283,8 +219,6 @@ 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}")
@@ -293,7 +227,7 @@ 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, template_id, session.get("templates", []))
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city)
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
if ticket_id:
@@ -338,10 +272,10 @@ async def _wait_for_attachments_and_create(user_id, session):
await asyncio.sleep(ATTACHMENT_WAIT_TIMEOUT)
except asyncio.CancelledError:
return # Таймер сброшен, прерываем выполнение текущей таски
if session.get("step") != "waiting_for_attachments":
return
sd_workflow_logger.info(f"⏰ [SD] Timeout reached. Initiating ticket creation for user {user_id}")
msg = session.get("msg")
login = session.get("login")
@@ -354,23 +288,23 @@ async def sd_module_handler(msg: Message):
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", "Выход в главное меню")
@@ -378,64 +312,36 @@ async def sd_module_handler(msg: Message):
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
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
session = sd_sessions[user_id]
# --- ШАГ 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 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")
@@ -445,7 +351,7 @@ async def sd_module_handler(msg: Message):
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:
@@ -454,14 +360,14 @@ async def sd_module_handler(msg: Message):
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:
@@ -470,7 +376,7 @@ async def sd_module_handler(msg: Message):
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:
@@ -486,4 +392,4 @@ async def sd_module_handler(msg: Message):
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
return
+297
View File
@@ -0,0 +1,297 @@
#!/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)
+4 -5
View File
@@ -45,11 +45,10 @@ ALLOWED_EXTENSIONS = [
# --- Функция получения Email из Active Directory ---
def get_user_email_sync(login: str) -> str:
try:
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["mail"])
if conn.entries and 'mail' in conn.entries[0] and conn.entries[0].mail.value:
return str(conn.entries[0].mail.value)
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)
except Exception as e:
logger.error(f"Ошибка поиска email в AD: {e}")
return DEFAULT_REQUESTER
+59
View File
@@ -0,0 +1,59 @@
# /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
@@ -0,0 +1,73 @@
# /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,6 +62,13 @@ 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>")
+9 -11
View File
@@ -63,18 +63,16 @@ 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)"""
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2).
Ищет по всем OU из AD_BASES."""
from utils.ad_search import search_by_user_id
try:
server = Server(config.AD_SERVER, get_info=ALL)
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split('@')[0]
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
if conn.entries and 'extensionAttribute2' in conn.entries[0] and conn.entries[0].extensionAttribute2.value:
return str(conn.entries[0].extensionAttribute2.value).strip()
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()
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
except Exception as e:
logger.error(f"Ошибка получения CARD_ID из AD для {user_id}: {e}")
+89 -10
View File
@@ -140,16 +140,14 @@ 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_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>{_footer_main_only(emojis)}"
def _sd_system_error(emojis: dict) -> str:
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
@@ -170,10 +168,8 @@ SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
SD_CREATING = _sd_creating()
def _sd_ticket_create_error(emojis: dict) -> str:
return (
f"❌ <b>Не удалось создать заявку.</b>\n\n" f"Пожалуйста, повторите попытку позже.\n\n" f"{emojis['0']}В главное меню"
)
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() -> str:
return _sd_ticket_create_error(EMOJI_DIGITS)
@@ -400,7 +396,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>"
@@ -464,6 +460,15 @@ 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>"
@@ -494,6 +499,7 @@ LK_UNKNOWN_CMD_TEXT = (
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
def lk_error_handling(emojis: dict) -> str:
return _lk_error_handling(emojis)
@@ -573,4 +579,77 @@ 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)
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_with_ad(emojis: dict, cn: str, ticket_id: str = None) -> str:
"""
Текст Trueconf с проверкой группы 2FA.
cn — CN пользователя из AD.
ticket_id — номер созданной заявки в SD.
"""
from utils.ad_checker import check_group_membership
result = check_group_membership(cn, "2FA")
if not result["found"]:
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} создана!</b>" if ticket_id else ""
return (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"Пользователь {cn} не найден в Active Directory.{ticket_part}\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
if result["in_group"]:
body = (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
else:
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} на предоставление доступа создана в ServiceDesk!</b>" if ticket_id else ""
body = (
f"❌ <b>Доступ к Trueconf запрещён.</b>\n\n"
f"У вас нет группы 2FA в Active Directory.{ticket_part}\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
return body
def _instruct_email(emojis: dict) -> str:
return (
"📧 <b>Инструкция по Почте</b>\n\n"
"Данная функция находится в разработке.\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_EMAIL_TEXT = _instruct_email(EMOJI_DIGITS)