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