198 Commits

Author SHA1 Message Date
dddennnisss 612564389b Тест-режим: отправлять на /check/ авто-кроп лица (как в превью/публикации), а не оригинал
- make_face_crop_bytes(): тот же квадратный кроп по центру лица, что
  превью и публикуемый кадр (полное разрешение, JPEG q90)
- handlers: кнопка 1 шлёт на проверку кроп, не оригинал
- правило «голова >= 30%» теперь меряется по публикуемому результату
2026-08-23 15:06:04 +07:00
dddennnisss b5cd3df5dd Тест-режим: показывать метрики кадра из /check/ (какой кадр измерил сайт) 2026-08-23 04:33:19 +07:00
dddennnisss a0ade670fe Тест-режим: сброс протухшего pending_photos/test_submode при входе в модуль (фото больше не улетает сразу после тайм-аута) 2026-08-23 03:36:22 +07:00
dddennnisss 2a4e2fc9a2 Fix: URL /check/ (было photo-botcheck/ — терялся слэш после replace) 2026-08-22 21:27:52 +07:00
dddennnisss f18c564ead Тест-режим: проверка через /api/photo-bot/check/ сайта (CV+гримасы+ИИ), метрики из llama.cpp, удаление прямого llama.cpp-клиента 2026-08-22 20:47:57 +07:00
dddennnisss ab58203982 Фото: запрет приёма фото при «на модерации»; неверная команда — «Неизвестная команда» (UNKNOWN_MAIN_CMD_TEXT) во всём модуле 2026-08-22 16:05:08 +07:00
dddennnisss 859ac42a77 Фото: неверная команда — глобальное сообщение ошибки; тест-режим: кнопка 9 «Назад» 2026-08-22 15:24:42 +07:00
dddennnisss bd0c14c8f0 Фото: pending — только статус (без «пришлите фото», без номера); тест-приветствие без блока модерации 2026-08-22 14:48:55 +07:00
dddennnisss 8a7634daed Фото: вход в тест-режим только по кнопке 1; во входном сообщении — меню+статус+кнопка 1 (без описания тест-режима) 2026-08-22 14:22:00 +07:00
dddennnisss 7fa515de58 Фото (тест-режим): детальный отчёт ИИ — время, токены (приём/отдача), скорость ток/сек, детали ошибок 2026-08-22 10:59:29 +07:00
dddennnisss e08c861149 Фото: pending-состояние — модуль остаётся открыт; тест-режим: фото можно отправить на тест; прочий ввод → ошибка неверного выбора 2026-08-22 00:08:34 +07:00
dddennnisss 1bdfd163fe Фото: проверка статуса внутри режима тоже работает в тестовом режиме 2026-08-21 22:25:19 +07:00
dddennnisss 0b9a697099 Фото: статусы модерации показываются и в тестовом режиме 2026-08-21 22:22:42 +07:00
dddennnisss 1c2d018be4 Фото: входное сообщение универсальное (меню + статус модерации одним сообщением) 2026-08-21 22:08:34 +07:00
dddennnisss 0b2364d79f Фото: проверка статуса модерации перенесена из main.py в модуль photo_bot (паттерн ЛК) 2026-08-21 21:21:14 +07:00
dddennnisss c358d4cbbb Фото: уведомление о причине отказа модерации при входе в модуль 2026-08-21 20:05:18 +07:00
Hermes Bot 1ea42a9197 Тест-мод: под-режим «1 — тест проверка фото» внутри штатного фото-режима 2026-08-20 13:06:35 +07:00
Denis Krivochenko 633eb27450 config: фото-тест-мод — без .env, всё в константах config.py 2026-08-17 17:22:01 +07:00
Denis Krivochenko 9271c23780 photo_bot: разрешение фото для ИИ 768->1536 (PHOTO_TEST_AI_MAX_SIDE) 2026-08-17 17:17:02 +07:00
Denis Krivochenko 0941a28da4 photo_bot: жёсткий промпт — чек-лист A-E, фиксация мимики/графики, правила отказа 2026-08-17 17:10:37 +07:00
Denis Krivochenko d3b1ba6e8a prompt: стоп-фактор «гримасы и мимика» + фон с буквами/коллажи 2026-08-17 16:54:55 +07:00
Denis Krivochenko 9cb234dda3 photo_bot: текст превью тест-мода — кнопки 1/2/0 2026-08-17 16:45:45 +07:00
Denis Krivochenko 6f4c979567 config: PHOTO_TEST_MODE — прямой флаг True/False без .env 2026-08-17 16:28:31 +07:00
Denis Krivochenko 9f49dd3e96 photo_bot: тест-мод — текст поиска лица 2026-08-17 16:04:54 +07:00
Denis Krivochenko aee2fe8f25 photo_bot: тестовый мод — фото напрямую в ИИ, без публикации 2026-08-17 16:03:04 +07:00
dddennnisss 9f35db6f09 main.py: обработка 0 и меню — возврат в главное меню 2026-08-14 14:57:20 +07:00
dddennnisss f90dd77bd8 Обновить utils/texts.py 2026-08-14 14:55:24 +07:00
dddennnisss a98fcd0feb photo_processor.py: извлекаем логин из email 2026-08-14 14:41:30 +07:00
dddennnisss b065a85114 handlers.py: извлекаем логин из email 2026-08-14 14:41:22 +07:00
dddennnisss 795f037f1b handlers.py: извлекаем логин из email 2026-08-14 14:39:30 +07:00
dddennnisss 7ca86466f7 main.py: проверка модерации при входе в PHOTO_MODE 2026-08-14 14:35:05 +07:00
dddennnisss 966b8d4774 handlers.py: photo_moderation_pending из texts.py 2026-08-14 14:13:33 +07:00
dddennnisss 7aa2d92b8b texts.py: добавлена photo_moderation_pending, убраны дубликаты 2026-08-14 14:09:16 +07:00
dddennnisss 4418bcb3a3 handlers.py: проверка статуса модерации при входе и перед отправкой 2026-08-14 13:50:46 +07:00
dddennnisss 7c74c19de6 handlers.py: убран автопереход в меню после отправки фото 2026-08-14 13:25:59 +07:00
dddennnisss 781f52a301 handlers.py: убран PHOTO_UPLOADING_API_TEXT, оставлен PHOTO_AI_PROCESSING_TEXT 2026-08-14 12:59:24 +07:00
dddennnisss 90777785f4 texts.py: убран PHOTO_UPLOADING_API_TEXT, оставлен PHOTO_AI_PROCESSING_TEXT 2026-08-14 12:58:57 +07:00
dddennnisss 182194a57a photo_processor.py: timeout 300с (5 минут) 2026-08-14 12:41:05 +07:00
dddennnisss 3148a6c5ab handlers.py: PHOTO_AI_PROCESSING_TEXT из utils/texts.py 2026-08-14 12:40:44 +07:00
dddennnisss 20759ac600 texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:39:02 +07:00
dddennnisss 5644e6dc74 texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:38:33 +07:00
dddennnisss 0fd2136bc0 handlers.py: PHOTO_AI_PROCESSING_TEXT из utils/texts.py 2026-08-14 12:37:35 +07:00
dddennnisss d6c7d1258d texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:37:15 +07:00
dddennnisss 1990b915b2 texts.py: добавлен PHOTO_AI_PROCESSING_TEXT (5 минут) 2026-08-14 12:36:40 +07:00
dddennnisss 2be4711a3a photo_processor.py: timeout 120с для ИИ 2026-08-14 12:28:38 +07:00
dddennnisss 0dba3e7bf6 photo_processor.py: port 8085 добавлен 2026-08-14 12:20:34 +07:00
dddennnisss 746d9bc7e4 handlers.py: все техошибки через photo_error_handling() 2026-08-14 12:14:59 +07:00
dddennnisss c0ec03526e photo_processor.py: логгирование ответа API 2026-08-14 12:12:41 +07:00
dddennnisss 0cf46713d4 photo_processor.py: verify=False для SSL cert 2026-08-14 12:02:08 +07:00
dddennnisss 55d95d3042 handlers.py: NameError image_bytes->raw_bytes исправлено 2026-08-14 11:51:56 +07:00
dddennnisss 7196fe603a handlers.py: добавлен import smtplib, детальное логгирование ошибок 2026-08-14 11:49:25 +07:00
dddennnisss ff6216cdf3 Фикс: поддержка WebP через PIL fallback 2026-08-14 11:41:48 +07:00
dddennnisss a38fc7d889 handlers.py: полная переработка под Django API, get_crop_coordinates, превью 2026-08-14 09:57:52 +07:00
dddennnisss 5bd26d706e Полная переработка: интеграция с Django API, превью, кнопки подтверждения 2026-08-14 09:48:49 +07:00
dddennnisss 5deb7ff689 Обновлены тексты PHOTO BOT — интеграция с Django API 2026-08-14 09:43:41 +07:00
dddennnisss 707dcf3437 Рефакторинг: выделение get_crop_coordinates(), интеграция с Django API 2026-08-14 09:37:35 +07:00
Денис Кривоченко c7ae88a3e0 Merge branch 'main' of http://192.168.1.108:3000/dddennnisss/trueconf_bot 2026-08-14 00:54:40 +07:00
Денис Кривоченко 7b129e0836 photo_bot: remove AI validator, restore prepare_ad_photo workflow 2026-08-14 00:54:30 +07:00
dddennnisss 9a9b90b3a1 photo_bot: remove AI validator, restore prepare_ad_photo workflow 2026-08-14 00:54:27 +07:00
dddennnisss 95803b2c3c Update photo_bot/handlers.py: preview after AI approval + dynamic log path + system_error_text 2026-08-13 14:52:30 +07:00
dddennnisss 809f5f4980 Update photo_bot/handlers.py: AI photo verification instead of preview 2026-08-13 14:15:38 +07:00
dddennnisss fbe9c042ce revert 4db994f6de
revert fix: import config module for email alerts
2026-08-11 15:34:05 +07:00
dddennnisss 508bfd36df revert 941cd569d8
revert fix: lk_diagnostic.py - check q_firm for ALL periods
2026-08-11 12:59:19 +07:00
Денис Кривоченко 941cd569d8 fix: lk_diagnostic.py - check q_firm for ALL periods 2026-08-11 12:56:52 +07:00
dddennnisss 7c2a3f1c4a Обновить lk/test/lk_diagnostic.py 2026-08-11 12:32:51 +07:00
dddennnisss a9a4926e3e revert 427f6ed000
revert fix: lk_diagnostic.py - separate short_name for sAMAccountName and ANR
2026-08-11 11:53:55 +07:00
Денис Кривоченко 427f6ed000 fix: lk_diagnostic.py - separate short_name for sAMAccountName and ANR 2026-08-11 11:52:13 +07:00
Денис Кривоченко cc9d3ca78a fix: lk_diagnostic.py - use universal LDAP filter with ANR 2026-08-11 11:49:06 +07:00
Денис Кривоченко f0d927bcb6 fix: lk_diagnostic.py - use AD + correct SQL columns 2026-08-11 11:30:16 +07:00
Денис Кривоченко d2e7dd6234 fix: lk_diagnostic.py - fix strip syntax error 2026-08-11 11:26:53 +07:00
Денис Кривоченко 9e34b0597b add: diagnostic script for lk payslip issues 2026-08-11 11:17:38 +07:00
Денис Кривоченко 4db994f6de fix: import config module for email alerts 2026-07-31 01:29:55 +07:00
Денис Кривоченко ed669b6aac fix: add missing MIMEMultipart import for email alerts 2026-07-31 01:25:21 +07:00
Денис Кривоченко cecec601e0 fix: add debug logging to email alert function 2026-07-31 01:16:29 +07:00
Денис Кривоченко 6c466dc0f6 fix: use correct config reference for email alerts 2026-07-31 01:12:28 +07:00
Денис Кривоченко 974ecbb36d fix: restore missing logger definition 2026-07-31 00:46:21 +07:00
Денис Кривоченко a189c361d2 fix: restore missing router = Router() declaration 2026-07-31 00:40:39 +07:00
Денис Кривоченко 613102d0c9 fix: add email alerts for transcription module errors 2026-07-31 00:37:09 +07:00
Денис Кривоченко 4a464911d6 fix: add email alerts for all SD module errors 2026-07-31 00:26:24 +07:00
Денис Кривоченко 2fe74b08d8 fix: add email alerts for missing LK session and header errors 2026-07-31 00:03:06 +07:00
Денис Кривоченко 5f1c023652 fix: use system_error_text for lk error handling 2026-07-30 23:42:37 +07:00
Денис Кривоченко 4fbca23c6b fix: use system_error_text for photo unknown command 2026-07-30 23:39:23 +07:00
dddennnisss edbb1bbc95 Обновить service_desk/handlers.py 2026-07-30 23:35:06 +07:00
Денис Кривоченко 0973b83ba0 fix: correct indentation in _create_ticket_and_attach_files 2026-07-30 23:24:31 +07:00
Денис Кривоченко 996cfc7ad3 fix: catch SD errors in background task and send universal error message 2026-07-30 23:24:04 +07:00
Денис Кривоченко 81714f4bc2 fix: move error handling to background task where msg.answer works 2026-07-30 23:22:44 +07:00
Денис Кривоченко 6366f2a503 fix: correct indentation for done_callback 2026-07-30 23:17:17 +07:00
Денис Кривоченко c7e0fd8cc6 fix: add done_callback to SD timer task to catch background errors 2026-07-30 23:16:36 +07:00
Денис Кривоченко d3f6206113 fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:13:03 +07:00
Денис Кривоченко 3d6c99ed24 fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:11:58 +07:00
Денис Кривоченко 03808a553a fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:11:13 +07:00
Денис Кривоченко 6f187591b5 fix: wrap entire SD handler in try-except to prevent crashes when SD is unreachable 2026-07-30 23:10:28 +07:00
Денис Кривоченко 6c43c4315e fix: use universal system_error_text for all SD errors 2026-07-30 23:01:41 +07:00
Денис Кривоченко 1e4770ea16 fix: use universal system_error_text instead of API/system errors in transcription 2026-07-30 22:57:22 +07:00
dddennnisss c5edcbdd06 Обновить instruct/handlers.py 2026-07-30 22:29:53 +07:00
Денис Кривоченко a3b9796fe8 fix: add исполнен/выполнен to CLOSED_STATUSES 2026-07-30 22:15:21 +07:00
Денис Кривоченко aaf0c66ed6 fix: set_state before email unavailable text 2026-07-30 22:03:15 +07:00
Денис Кривоченко 0fa11655d5 fix: set state before showing email unavailable text so 9 works 2026-07-30 21:57:48 +07:00
Денис Кривоченко 7523a4849e fix: reformat email history ask text 2026-07-30 21:43:00 +07:00
Денис Кривоченко bfb3a0f359 fix: escape quotes in email_not_had text 2026-07-30 21:24:48 +07:00
Денис Кривоченко 9f737d3282 fix: separate text for email access not had case 2026-07-30 21:24:21 +07:00
Денис Кривоченко e550e657fe feat: ask about previous mobile email access before showing instructions 2026-07-30 17:21:32 +07:00
dddennnisss 3e98471e21 Обновить utils/texts.py 2026-07-30 16:57:50 +07:00
Денис Кривоченко 042ef92e98 fix: add .docx extension to email instruction file names 2026-07-30 14:50:55 +07:00
Денис Кривоченко 6f0f40a29e fix: send email text then attach docs separately without captions 2026-07-30 14:48:54 +07:00
Денис Кривоченко 3555d378b2 fix: attach first email doc with caption, second without 2026-07-30 14:37:03 +07:00
Денис Кривоченко e1a772ba0c fix: send email text then attach docs without caption 2026-07-30 14:34:19 +07:00
Денис Кривоченко 597aa56651 fix: use send_media_group to send both email instructions in one group 2026-07-30 14:24:42 +07:00
Денис Кривоченко 5f604e52d8 fix: send email text once, then attach docs without caption 2026-07-30 14:22:46 +07:00
Денис Кривоченко 1d8b4ef3c1 feat: attach Gmail Android and Apple email instructions when user has mail 2026-07-30 14:19:48 +07:00
dddennnisss 17cf93eed9 Загрузить файлы в «instruct» 2026-07-30 14:16:12 +07:00
Денис Кривоченко 1ab303ca60 fix: set raw_mail to None when AD mail is empty [] instead of string 2026-07-30 14:12:11 +07:00
Денис Кривоченко 2c5b296eaf fix: check raw AD mail attribute, not UPN fallback, for email availability check 2026-07-30 14:09:16 +07:00
Денис Кривоченко 8d4dcb006e fix: remove stale email dev text from texts.py 2026-07-30 13:54:19 +07:00
Денис Кривоченко 90b4d554bb feat: email instruction checks AD mail attribute and shows available/unavailable message 2026-07-30 13:51:33 +07:00
Денис Кривоченко 1585206707 fix: attach TrueConf instruction docx when ticket already exists (no 2FA) 2026-07-30 12:37:54 +07:00
Денис Кривоченко 03c0d6f759 feat: restore debug logs in instruct/handlers.py 2026-07-30 12:07:42 +07:00
dddennnisss 9ccda24a55 Обновить config/config.py 2026-07-30 12:04:48 +07:00
dddennnisss 8bb0e27028 Обновить instruct/handlers.py 2026-07-30 12:03:33 +07:00
dddennnisss 8aea92b84f Обновить utils/texts.py 2026-07-30 12:02:13 +07:00
Денис Кривоченко dd14853362 fix: remove subject filter from API URL (Russian chars), fetch all tickets and filter in code 2026-07-29 01:28:45 +07:00
Денис Кривоченко c42854c3a5 fix: search existing tickets by subject without status filter, check status in code, get details for CN matching 2026-07-29 01:28:10 +07:00
Денис Кривоченко 6ad50dcd4d fix: search existing SD ticket by CN (name) instead of email, with 3-match strategy 2026-07-29 01:18:26 +07:00
Денис Кривоченко 84a03f79ee fix: use requester name (CN) instead of email for SD API - fallback to AI when name fails 2026-07-29 01:12:48 +07:00
Денис Кривоченко ddbcb11685 revert: return to original instruct SD API behavior 2026-07-29 01:03:30 +07:00
Денис Кривоченко 80b15c86c9 fix: convert UPN @sibcem.ru to @tcs.sibcem.ru for SD API requester 2026-07-29 00:55:26 +07:00
Денис Кривоченко 36dbae5a56 fix: use input_data form field for SD API in instruct 2026-07-29 00:52:07 +07:00
Денис Кривоченко 708684fdfe fix: use service_desk API format in instruct (authtoken, Accept header, json=payload, html description) 2026-07-29 00:48:02 +07:00
Денис Кривоченко baeac6b1cc fix: convert UPN @sibcem.ru to @tcs.sibcem.ru for SD API 2026-07-29 00:41:53 +07:00
Денис Кривоченко 09a2ccc6b4 fix: use UPN as mail fallback in instruct when mail is empty 2026-07-29 00:30:04 +07:00
Денис Кривоченко e07b0af6b4 fix: use standard error stub in instruct module 2026-07-29 00:26:43 +07:00
Денис Кривоченко be16c4fe17 config: add bot_tcs OU to AD_BASES for ServiceAccounts path 2026-07-28 00:13:41 +07:00
Денис Кривоченко cc71f00506 fix: add missing exports for INSTRUCT_TRUECONF_PENDING_TEXT and INSTRUCT_TRUECONF_NO_TICKET_TEXT 2026-07-27 23:57:15 +07:00
Денис Кривоченко 9a24f48259 fix: separate ticket check from creation in Trueconf flow 2026-07-27 23:43:15 +07:00
Денис Кривоченко 9b65ec1e7e feat: add Trueconf access request flow with intermediate prompt and existing ticket check 2026-07-27 23:42:14 +07:00
Денис Кривоченко 354c6b0a51 feat: check for existing Trueconf ticket before creating new one 2026-07-27 23:13:34 +07:00
Денис Кривоченко 1ac0035b4d fix: use DEFAULT_REQUESTER fallback for ticket requester email 2026-07-27 22:40:29 +07:00
Денис Кривоченко c987f14cf9 simplify: single ticket description for Trueconf access request 2026-07-27 22:33:52 +07:00
Денис Кривоченко f1709f16b6 fix: use real ticket_id in Trueconf message and remove doc attachment when no access 2026-07-27 22:16:08 +07:00
Денис Кривоченко 0cfef14722 fix: remove ticket #23651 from Trueconf access denied message 2026-07-27 22:04:37 +07:00
Денис Кривоченко d2fe4aa1dc fix: restore ticket #23651 in Trueconf message 2026-07-27 21:56:58 +07:00
Денис Кривоченко 0ff681b84f fix: update Trueconf access denied message 2026-07-27 21:45:02 +07:00
Денис Кривоченко 3f239267d2 lk: hide empty months from timesheet menu, show only months with data 2026-07-26 17:47:23 +07:00
Денис Кривоченко 94bd664be8 lk: add current month as option 1 for timesheet 2026-07-26 17:41:55 +07:00
Денис Кривоченко c6bc67a98d lk: add 3rd month option for timesheet view 2026-07-26 17:35:37 +07:00
Денис Кривоченко 005b728d7c Rename SD config vars to SD_INSTRUCT_GROUP_NAME / SD_INSTRUCT_TECHNICIAN_NAME, move to section 11 2026-07-26 16:59:10 +07:00
Денис Кривоченко 866654a132 Add SD group/technician config and auto-assign tickets to Техподдержка ХК / Кривоченко 2026-07-26 16:39:18 +07:00
Денис Кривоченко 3c160afe20 Update Trueconf access denied message 2026-07-26 16:34:30 +07:00
Денис Кривоченко 347a909e06 Обновить instruct/handlers.py — имя файла Инструкция.docx 2026-07-23 23:10:30 +07:00
Денис Кривоченко dff39f025c Merge branch 'main' of http://192.168.1.108:3000/dddennnisss/trueconf_bot 2026-07-23 23:05:43 +07:00
Денис Кривоченко 523ed89519 Обновить instruct/handlers.py — файл с текстом в одном сообщении 2026-07-23 23:05:34 +07:00
dddennnisss d1ad0b16a1 Обновить handlers.py — универсальный текст заявки 2FA 2026-07-23 22:50:34 +07:00
Денис Кривоченко 6a6d59394a Обновить handlers.py — универсальный текст заявки 2FA 2026-07-23 22:27:50 +07:00
Денис Кривоченко 32f1cd9513 Обновить utils/texts.py — упростить текст Trueconf 2026-07-23 21:48:01 +07:00
dddennnisss 51b632ec7d Обновить instruct/handlers.py 2026-07-23 20:47:02 +07:00
dddennnisss 54b019f3b9 Fix: correct AD→CARD_ID→EMP_ID chain (no EMAIL column query) 2026-07-23 17:28:15 +07:00
dddennnisss 7c900e5502 Add AD lookup by email → CARD_ID → SQL tabel check for ds.krivochenko@sibcem.ru 2026-07-23 17:25:48 +07:00
dddennnisss 7d67e3ffc2 Fix: properly load DB credentials from Passwork + .env 2026-07-23 17:17:57 +07:00
dddennnisss af0caee963 Add test_tabel_check.py - SQL server table check script 2026-07-23 16:43:47 +07:00
dddennnisss 0dca28ef20 Обновить utils/texts.py 2026-07-23 15:55:48 +07:00
dddennnisss de47c39e76 Обновить lk/handlers.py 2026-07-23 15:50:52 +07:00
dddennnisss f0be37b18f Обновить instruct/handlers.py 2026-07-23 15:49:59 +07:00
dddennnisss 9aedbd4c1f Обновить config/config.py 2026-07-23 15:48:55 +07:00
Денис Кривоченко 977e7369e3 fix: use ai@sibcem.ru when user has no mail in AD 2026-07-23 10:41:17 +07:00
Денис Кривоченко 23a7d20722 fix: create SD ticket on Trueconf instruct access 2026-07-23 10:36:35 +07:00
Денис Кривоченко 1caae96a97 fix: импортируем create_ticket_in_sd из service_desk 2026-07-23 02:17:16 +07:00
Денис Кривоченко 90bac4e343 fix: sd_create_ticket — data=form-encoded как в service_desk 2026-07-23 02:14:17 +07:00
Денис Кривоченко 7517448508 fix: sd_create_ticket — content=json.dumps(input_data), DEFAULT_REQUESTER 2026-07-23 02:09:03 +07:00
Денис Кривоченко f98ba35a60 feat: instruct создаёт SD заявку при отсутствии 2FA 2026-07-23 01:54:56 +07:00
Денис Кривоченко 0ff328694c fix: add Trueconf instruction docx 2026-07-23 01:53:05 +07:00
Денис Кривоченко 04430e56ef feat: instruct создаёт SD заявку при отсутствии 2FA 2026-07-23 01:52:45 +07:00
dddennnisss 0b76fbaf17 Добавить файл инструкции Trueconf.docx в ответ бота 2026-07-23 01:20:56 +07:00
dddennnisss f4c21c825c Загрузить файлы в «instruct» 2026-07-23 01:08:54 +07:00
Денис Кривоченко 3fa7543477 fix: LDAP filter malformed — добавлен символ '|' в or_filter
search_by_user_id: or_filter был '(...)' вместо '(|...)' — LDAP требовал OR-оператор
2026-07-22 18:53:34 +07:00
Денис Кривоченко 064451dc7c add: check_card_id.py — проверка user_id -> CARD_ID -> телефон
Использует AD_BASES через utils.ad_search.search_by_user_id
2026-07-22 18:32:35 +07:00
Денис Кривоченко c76914444c fix: грамматика 'справка по отпускам' (было 'по отпусках') 2026-07-22 16:56:02 +07:00
Денис Кривоченко 09b19f9ced AD: поиск по нескольким OU (AD_BASES)
- config.py: AD_BASE -> AD_BASES (кортеж OU)
- utils/ad_search.py: новая утилита для поиска по всем OU
- utils/ad_checker.py: использовать ad_search
- utils/otp_service.py: использовать ad_search
- lk/handlers.py: использовать ad_search
- transcription_bot/handlers.py: использовать ad_search
- service_desk/handlers.py: использовать ad_search
- instruct/handlers.py: использовать ad_search
- photo_bot/ad_helper.py: использовать ad_search
- photo_bot/check_ad_photo.py: использовать ad_search
- check_ad_2fa.py: использовать ad_search
- debug_ad_filter.py: обновить для AD_BASES
- lk/test_lk_payslip.py: обновить для AD_BASES
2026-07-22 16:52:17 +07:00
Денис Кривоченко 9f76064826 fix: remove extra closing paren in LDAP filter 2026-07-22 00:49:21 +07:00
Денис Кривоченко 49a986fa65 fix: don't escape @ in email for LDAP filter 2026-07-22 00:41:53 +07:00
Денис Кривоченко 2c39e5b50b fix: use lk/handlers.py filter format, add debug script 2026-07-22 00:41:26 +07:00
Денис Кривоченко 26b58f2909 fix: search AD in full domain tree + debug logging 2026-07-22 00:34:40 +07:00
Денис Кривоченко d9528486ef fix: clean duplicate logging, use proper AD lookup with debug 2026-07-22 00:33:45 +07:00
Денис Кривоченко 525899557b add debug logging to AD lookup 2026-07-22 00:32:41 +07:00
Денис Кривоченко 03339885ff fix: replace tcs.sibcem.ru with sibcem.ru in AD user lookup 2026-07-22 00:02:16 +07:00
Денис Кривоченко 8fa42206aa add AD 2FA group check to instruct Trueconf module 2026-07-21 23:47:35 +07:00
Денис Кривоченко be12045fa8 add check_ad_2fa.py - interactive AD group membership checker 2026-07-21 23:27:36 +07:00
Денис Кривоченко 7c0aaf5392 fix: set state on instruct menu selection so back button works 2026-07-21 22:48:39 +07:00
Денис Кривоченко 55a95889b0 import EMOJI_DIGITS from utils/texts.py 2026-07-21 22:44:54 +07:00
Денис Кривоченко 8029f7fb2d move instruct text constants to utils/texts.py 2026-07-21 22:40:08 +07:00
Денис Кривоченко 2fa4ff2781 fix: replace <br> with real newlines for HTML parse mode 2026-07-21 22:36:03 +07:00
Денис Кривоченко aadbaab321 use UNKNOWN_MAIN_CMD_TEXT for instruct module (unified error style) 2026-07-21 22:32:38 +07:00
Денис Кривоченко 54f0d074ba unify INSTRUCT_UNKNOWN_CMD_TEXT with other modules 2026-07-21 22:28:18 +07:00
Денис Кривоченко 642f2bba6a Fix instruct: replace <br> with \n for HTML line breaks 2026-07-21 17:43:31 +07:00
Денис Кривоченко 77cbb1d352 Fix instruct: use <br> for HTML line breaks, fix unknown cmd text 2026-07-21 17:32:53 +07:00
Денис Кривоченко 8e686b1ca3 Fix instruct handlers: correct newline escaping in text strings 2026-07-21 17:28:16 +07:00
Денис Кривоченко d30ceeda6b Add blank line between instruct header and buttons 2026-07-21 17:24:46 +07:00
Денис Кривоченко 0168a8b0a5 Fix instruct menu: remove extra blank line after header 2026-07-21 17:21:18 +07:00
Денис Кривоченко 260b6c3a8a Add instruct module with Trueconf and Email guides 2026-07-21 17:13:05 +07:00
58 changed files with 4426 additions and 1926 deletions
Binary file not shown.
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Проверка наличия пользователя в группе 2FA через Active Directory.
Использует credentials и настройки из config/config.py.
Запуск:
python check_ad_2fa.py
"""
import sys
import os
from ldap3 import Server, Connection, ALL, SUBTREE
# Подтягиваем конфиг
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASES
from utils.ad_search import search_by_filter as ad_search_all_bases
def find_user(login: str) -> dict | None:
"""Найти пользователя в AD по логину (sAMAccountName или mail).
Ищет по всем OU из AD_BASES."""
filter_login = login.lower().replace("\\", "\\5c").replace("*", "\\2a").replace("(", "\\28").replace(")", "\\29")
if "@" in filter_login:
ldap_filter = "(|{}{})".format("(mail={})".format(filter_login), "(sAMAccountName={})".format(filter_login))
else:
ldap_filter = "(|{}{})".format("(sAMAccountName={})".format(filter_login), "(mail={})".format(filter_login))
entries = ad_search_all_bases(ldap_filter, ["cn", "sAMAccountName", "memberOf"])
if not entries:
return None
entry = entries[0]
return {
"dn": entry.entry_dn,
"cn": str(entry.cn),
"sam": str(entry.sAMAccountName),
"groups": [str(g) for g in entry.memberOf],
}
def check_2fa_group(login: str) -> dict:
"""Проверить, входит ли пользователь в группу 2FA."""
user = find_user(login)
if user is None:
return {"found": False, "error": f"Пользователь '{login}' не найден в AD."}
# Ищем группу, содержащую "2FA" в имени
groups_2fa = [g for g in user["groups"] if "2FA" in g.upper()]
return {
"found": True,
"user": user,
"in_2fa_group": len(groups_2fa) > 0,
"matching_groups": groups_2fa,
}
def main():
print("\n" + "=" * 60)
print(" Проверка пользователя в группе 2FA (Active Directory)")
print("=" * 60)
print(f"\n Сервер AD: {AD_SERVER}")
print(f" Базы поиска: {config.AD_BASES}")
print(f" Подключено как: {AD_USER}")
print()
while True:
login = input(" Введите логин (sAMAccountName или email) или 'q' для выхода:\n > ").strip()
if not login or login.lower() == "q":
print("\n До свидания!\n")
break
result = check_2fa_group(login)
if not result["found"]:
print(f"\n{result['error']}")
continue
u = result["user"]
print(f"\n ✅ Пользователь найден:")
print(f" CN: {u['cn']}")
print(f" sAM: {u['sam']}")
print(f" DN: {u['dn']}")
print(f" Групп: {len(u['groups'])}")
if result["in_2fa_group"]:
print(f"\n 🟢 Пользователь ВХОДИТ в группу 2FA:")
for g in result["matching_groups"]:
print(f" {g}")
else:
print(f"\n 🔴 Пользователь НЕ найден в группе 2FA")
if u["groups"]:
print(f"\n Все группы пользователя ({len(u['groups'])}):")
for g in u["groups"]:
print(f" {g}")
print()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n Прервано пользователем.\n")
except Exception as e:
print(f"\n ❌ Ошибка: {e}")
sys.exit(1)
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Проверочный скрипт: user_id -> CARD_ID -> телефон.
Интерактивный режим: после каждого запроса спрашивает,
хочешь ли продолжить.
config.py сам подтягивает все секреты из Passwork при импорте.
"""
import sys
import re
import logging
# ============================================================
# config.py при импорте заполняет globals():
# AD_USER, AD_PASSWORD, AD_SERVER, AD_BASES
# DB_USER, DB_PASSWORD, SQL_SERVER, SQL_DB_NAME
# ============================================================
sys.path.insert(0, "/opt/trueconf_bot")
from config import config
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger(__name__)
# ============================================================
# AD: user_id -> CARD_ID
# ============================================================
def get_card_id_from_ad(user_id: str) -> str:
"""Ищем пользователя в AD по всем OU из AD_BASES, возвращаем extensionAttribute2 (CARD_ID)."""
from utils.ad_search import search_by_user_id
entries = search_by_user_id(user_id, ["extensionAttribute2"])
if not entries:
raise Exception("Пользователь не найден в AD")
entry = entries[0]
if "extensionAttribute2" in entry and entry.extensionAttribute2.value:
card_id = str(entry.extensionAttribute2.value).strip()
log.info("CARD_ID: {}".format(card_id))
return card_id
raise Exception("extensionAttribute2 пуст для этого пользователя в AD")
# ============================================================
# SQL: CARD_ID -> телефон в PR_CARD
# ============================================================
def get_phone_from_db(card_id: str) -> str:
"""Ищем сотрудника в UOV_SELFSERVICE_PR_CARD по CARD_ID."""
import pymssql
conn = pymssql.connect(
server=config.SQL_SERVER,
user=config.DB_USER,
password=config.DB_PASSWORD,
database=config.SQL_DB_NAME,
charset="cp1251",
)
cursor = conn.cursor()
query = """
SELECT c.ID, c.PHONE, c.F_NAME, c.L_NAME, c.M_NAME
FROM UOV_SELFSERVICE_PR_CARD c
WHERE c.ID = %s
"""
cursor.execute(query, (card_id,))
row = cursor.fetchone()
conn.close()
if not row:
raise Exception("CARD_ID {} не найден в PR_CARD".format(card_id))
card_id_db = row[0]
phone = str(row[1]) if row[1] else "Не указан"
f_name = str(row[2]) if row[2] else ""
l_name = str(row[3]) if row[3] else ""
m_name = str(row[4]) if row[4] else ""
log.info("PR_CARD.ID: {}".format(card_id_db))
log.info("ФИО: {} {} {}".format(l_name, f_name, m_name))
log.info("PHONE: {}".format(phone))
return phone
# ============================================================
# MAIN
# ============================================================
def run_one():
"""Выполнить один запрос: ввод user_id -> CARD_ID -> телефон."""
user_id = input("\nВведите user_id (email или sAMAccountName): ").strip()
if not user_id:
print("user_id пустой, выход.")
sys.exit(1)
print()
print("[1] Поиск CARD_ID в Active Directory")
print(" Сервер: {}".format(config.AD_SERVER))
print(" Базы поиска: {}".format(config.AD_BASES))
print(" user_id: {}".format(user_id))
print()
try:
card_id = get_card_id_from_ad(user_id)
except Exception as e:
print("Ошибка AD: {}".format(e))
return None
print()
print("[2] Поиск телефона в базе BossCopy")
print(" Сервер: {}".format(config.SQL_SERVER))
print(" CARD_ID: {}".format(card_id))
print()
try:
phone = get_phone_from_db(card_id)
except Exception as e:
print("Ошибка SQL: {}".format(e))
return card_id
print()
print("=" * 70)
print(" ИТОГ")
print("=" * 70)
print(" user_id: {}".format(user_id))
print(" CARD_ID: {}".format(card_id))
print(" Телефон: {}".format(phone))
print("=" * 70)
print()
return card_id
def main():
print()
print("=" * 70)
print(" Проверка: user_id -> CARD_ID -> телефон")
print("=" * 70)
print()
while True:
run_one()
again = input("Продолжить? (y/n): ").strip().lower()
if again not in ("y", "yes", "д", "да"):
print("Готово.")
break
if __name__ == "__main__":
main()
-268
View File
@@ -1,268 +0,0 @@
#!/usr/bin/env python3
"""
Clean and chunk parsed PDF data from unstructured-api.
Filters noise, preserves structure, chunks to 1500-1800 chars.
"""
import json
import re
def load_parsed_data(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def clean_text(text):
if not text:
return ""
text = re.sub(r'\s+', ' ', text.strip())
return text.strip()
def is_noise(text):
"""Check if text is noise."""
text = clean_text(text)
if not text:
return True
# Company name header/footer
if text == "АО «ХК «Сибцем»":
return True
# Page numbers: "Стр. 2 из 10"
if re.match(r'^Стр\.\s+\d+\s+из\s+\d+$', text):
return True
# Standalone numbers: "4."
if re.match(r'^\d+\.$', text):
return True
# Table of contents
lines = text.split('\n')
if len(lines) > 3:
page_pattern = re.compile(r'\d+\s*$')
page_lines = sum(1 for l in lines if page_pattern.search(l.strip()))
if page_lines > len(lines) * 0.5:
return True
# Specific noise
noise_patterns = [
r'^Оглавление$',
r'^Содержание$',
r'^Редакция \d+$',
r'^Тип документа:',
r'^Наименование процесса:',
r'^Ведущее подразделение:',
r'^Дата утверждения:',
]
for pattern in noise_patterns:
if re.search(pattern, text):
return True
return False
def is_section_title(text):
"""Check if text is a section title like '4. Термины, определения и сокращения'."""
text = clean_text(text)
return bool(re.match(r'^\d+\.\s+\w', text))
def is_section_subtitle(text):
"""Check if text is a section subtitle like '6.7. Электронные...'."""
text = clean_text(text)
return bool(re.match(r'^\d+\.\d+\.\s+\w', text))
def is_section_header(text):
"""Check if text starts with a section/subsection number."""
text = clean_text(text)
return bool(re.match(r'^\d+(?:\.\d+)*[\.\s]', text))
def merge_elements(elements):
"""
Merge short fragments with context.
Strategy: merge consecutive non-title elements into text blocks.
Keep titles and tables as separate blocks.
"""
if not elements:
return []
# Filter noise first
filtered = [el for el in elements if not is_noise(el.get('text', ''))]
# Separate into blocks: text blocks, title blocks, table blocks
blocks = []
current_text_block = []
for el in filtered:
text = el.get('text', '')
el_type = el.get('type', '')
# Skip Image (OCR noise from cover page)
if el_type == 'Image':
continue
# If this is a title/section header, flush text block and add title
if el_type in ('Title',) and is_section_header(text):
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
current_text_block = []
blocks.append({
'text': text,
'type': 'Title',
'metadata': el.get('metadata', {})
})
continue
# If this is a table, flush text block and add table
if el_type == 'Table':
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
current_text_block = []
blocks.append({
'text': text,
'type': 'Table',
'metadata': el.get('metadata', {})
})
continue
# Otherwise, accumulate into text block
current_text_block.append(el)
# Flush remaining text block
if current_text_block:
blocks.append({
'text': ' '.join(clean_text(e['text']) for e in current_text_block),
'type': 'NarrativeText',
'metadata': current_text_block[-1].get('metadata', {})
})
# Now merge short text blocks with adjacent content
# Specifically: merge short blocks that are continuations of previous sections
result = []
for i, block in enumerate(blocks):
text = block['text']
el_type = block['type']
clean = clean_text(text)
# If this is a short text block, try to merge with previous
if len(clean) < 60 and el_type == 'NarrativeText' and result:
# Check if it looks like a continuation (section subtitle or fragment)
if is_section_subtitle(clean) or not clean[0].isdigit():
# Merge with previous block
result[-1]['text'] = f"{result[-1]['text']} {clean}"
continue
result.append(block)
return result
def chunk_text(text, max_chunk_size=1800):
"""Split text into chunks of ~1500-1800 characters."""
if len(text) <= max_chunk_size:
return [text]
# Try to split by paragraphs first
paragraphs = re.split(r'\n+', text)
if len(paragraphs) > 1:
chunks = []
current = ""
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(current) + len(para) + 1 <= max_chunk_size:
current = f"{current}\n{para}"
else:
if current:
chunks.append(current)
current = para
if current:
chunks.append(current)
return chunks
# Split by sentences
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current = ""
for sentence in sentences:
if len(current) + len(sentence) + 1 <= max_chunk_size:
current = f"{current} {sentence}"
else:
if current:
chunks.append(current)
current = sentence
if current:
chunks.append(current)
return chunks
def process_elements(elements, max_chunk_size=1800):
"""Process elements into chunks."""
# Merge blocks
merged = merge_elements(elements)
# Create chunks
chunks = []
chunk_index = 0
for el in merged:
text = el.get('text', '')
el_type = el.get('type', 'Unknown')
page = el.get('metadata', {}).get('page_number', '?')
# Skip very short chunks
if len(text.strip()) < 10:
continue
# Split long texts
parts = chunk_text(text, max_chunk_size)
for part in parts:
chunk_index += 1
chunks.append({
'index': chunk_index,
'type': el_type,
'page': page,
'text': part,
'size': len(part)
})
return chunks, merged
def main():
parsed_path = '/tmp/pol177_parsed.json'
output_path = '/tmp/pol177_clean_chunk.json'
print("Loading parsed data...")
elements = load_parsed_data(parsed_path)
print(f"Loaded {len(elements)} elements")
print("Processing...")
chunks, merged = process_elements(elements)
print(f"Generated {len(chunks)} chunks")
# Stats
if chunks:
sizes = [c['size'] for c in chunks]
print(f"Chunk sizes: min={min(sizes)}, max={max(sizes)}, avg={sum(sizes)/len(sizes):.0f}")
# Type distribution
types = {}
for c in chunks:
types[c['type']] = types.get(c['type'], 0) + 1
print(f"Chunk types: {types}")
# Show all chunks
print("\n=== All chunks ===")
for c in chunks:
print(f"[{c['index']}] {c['type']} (page {c['page']}, {c['size']} chars)")
print(f" {c['text'][:120]}")
print()
if __name__ == '__main__':
main()
Binary file not shown.
Binary file not shown.
+26 -2
View File
@@ -85,8 +85,21 @@ ENABLE_SERVICE_DESK = True # Модуль создания заявок в
ENABLE_PHOTO_BOT = True # Модуль обработки фото/сканов
ENABLE_TRANSCRIPTION = True # Модуль расшифровки голосовых сообщений (Whisper)
ENABLE_SEARCH_BOT = True # Модуль интеллектуального поиска по регламентам (RAG)
ENABLE_INSTRUCT = True # Модуль инструкций (Trueconf, Почта)
ENABLE_LK = True # Личный кабинет сотрудника (отпуска, расчетные листки, справки)
# =========================================================
# 1.1 ТЕСТОВЫЙ ПОД-РЕЖИМ ФОТО-БОТА («1 — тест проверка фото»)
# При включённом флаге в меню «Режим обновления фото профиля»
# появляется пункт «1 — тест проверка фото». Внутри под-режима
# фото уходит НАПРЯМУЮ в ИИ (llama.cpp, тот же endpoint и промпт,
# что в SibCemProfile/PhotoApp/photo_validator.py), результат
# показывается пользователю. Само фото никуда НЕ ДОСЫЛАЕТСЯ
# (ни в AD, ни в API, ни на модерацию). Штатный поток
# (фото → превью → публикация) работает параллельно.
# =========================================================
PHOTO_TEST_MODE = False # True — в меню фото-режима доступен пункт «тест проверка фото»
# =========================================================
# 2. КОНТРОЛЬ ДОСТУПА
# =========================================================
@@ -94,7 +107,9 @@ ENABLE_WHITELIST = True # Включить режим закрытого
ALLOWED_USERS = [ # Список TrueConf ID разрешенных пользователей
"ds.krivochenko@tcs.sibcem.ru",
"mm.norenberg@tcs.sibcem.ru",
"a.kirpichev@tcs.sibcem.ru"
"a.kirpichev@tcs.sibcem.ru",
"bot_test@tcs.sibcem.ru",
"v.s.sholohov@tcs.sibcem.ru"
]
# =========================================================
@@ -103,7 +118,10 @@ ALLOWED_USERS = [ # Список TrueConf ID разрешенны
TC_SERVER = os.getenv("TC_SERVER", "") # Адрес сервера TrueConf
AD_SERVER = "ldap://172.16.20.20" # Адрес контроллера домена Active Directory
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru" # Базовый путь поиска пользователей в AD
AD_BASES = ( # Базовые пути поиска пользователей в AD (несколько OU)
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
SQL_SERVER = "SRVKEM-MOBILEIN.sibcem.ru" # Сервер MS SQL для работы Личного кабинета
SQL_DB_NAME = "BossCopy" # База данных с информацией по кадрам и ЗП
@@ -217,3 +235,9 @@ OTP_MAX_ATTEMPTS = 3
OTP_BLOCK_DURATION_SEC = 1800
# Глобальный таймаут неактивности пользователя в любом подменю (1800 секунд = 30 минут)
SESSION_TIMEOUT = 1800
# =========================================================
# 11. МОДУЛЬ INSTRUCT: НАСТРОЙКИ SERVICE DESK: ГРУППА И ИСПОЛНИТЕЛЬ
# =========================================================
SD_INSTRUCT_GROUP_NAME = "Техподдержка ХК" # Название группы в ServiceDesk
SD_INSTRUCT_TECHNICIAN_NAME = "Кривоченко Денис Сергеевич" # ФИО техника в ServiceDesk
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Debug: print the exact LDAP filter that works in lk/handlers.py"""
import sys
sys.path.insert(0, "/opt/trueconf_bot")
from config.config import AD_USER, AD_PASSWORD, AD_SERVER, AD_BASES
from utils.ad_search import search_by_filter as ad_search_all_bases
from ldap3.utils.conv import escape_filter_chars
# Simulate lk/handlers.py filter
user_id = "ds.krivochenko@tcs.sibcem.ru"
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
short_username = search_id.split("@")[0]
safe_user_id = escape_filter_chars(search_id)
print(f"user_id: {user_id}")
print(f"search_id: {search_id}")
print(f"short_username: {short_username}")
print(f"safe_user_id: {safe_user_id}")
# lk/handlers.py filter
filter_lk = "(&(objectClass=user)(|(mail={})(userPrincipalName={})(userPrincipalName={}{}{}))({}))".format(
safe_user_id,
safe_user_id,
escape_filter_chars(short_username),
"@sibcem.ru",
"",
escape_filter_chars(short_username),
)
print(f"\nFilter (lk format):")
print(filter_lk)
# Count parens
opens = filter_lk.count('(')
closes = filter_lk.count(')')
print(f"\nOpen parens: {opens}, Close parens: {closes}")
# Now test with the connection
print(f"\nSearching with filter across all AD_BASES...")
entries = ad_search_all_bases(filter_lk, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
if entries:
entry = entries[0]
print(f"FOUND: cn={entry.cn}, mail={entry.mail if 'mail' in entry else 'N/A'}, sam={entry.sAMAccountName if 'sAMAccountName' in entry else 'N/A'}")
else:
print("NOT FOUND in any AD_BASE")
# Try root
root_base = "DC=sibcem,DC=ru"
print(f"\nSearching in {root_base}...")
from utils.ad_search import search_by_filter
entries2 = search_by_filter(filter_lk, ["cn", "sAMAccountName", "mail", "userPrincipalName"])
if entries2:
entry = entries2[0]
print(f"FOUND: cn={entry.cn}, mail={entry.mail if 'mail' in entry else 'N/A'}, sam={entry.sAMAccountName if 'sAMAccountName' in entry else 'N/A'}")
else:
print("NOT FOUND in root")
View File
Binary file not shown.
+557
View File
@@ -0,0 +1,557 @@
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.
+98 -35
View File
@@ -57,7 +57,7 @@ EMOJI_DIGITS = {
"10": "🔟"
}
# Список из 7 справок строго по структуру портала
# Список из 7 справок строго по структуре портала
DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС",
@@ -74,7 +74,7 @@ LK_DOCS_MENU_TEXT = (
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
USER_COOLDOWN_SEC = 1.5
USER_COOLDOWN_SEC = 0.5
EMAIL_COOLDOWN_SEC = 10.0
user_last_request = {}
@@ -122,26 +122,24 @@ 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 = lk_error_handling(EMOJI_DIGITS)
error_msg = system_error_text(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:
server = Server(config.AD_SERVER, get_info=ALL)
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split('@')[0]
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
search_id = user_id.replace("@tcs.sibcem.ru", "@sibcem.ru")
entries = search_by_user_id(search_id, attributes=['extensionAttribute2'])
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
if entries and 'extensionAttribute2' in entries[0] and entries[0].extensionAttribute2.value:
return str(entries[0].extensionAttribute2.value).strip()
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}")
# =========================================================
# МОДНОЕ УНИВЕРСАЛЬНОЕ ЯДРО ПАГИНАЦИИ СПИСКОВ ДЛЯ ЧАТА
@@ -200,18 +198,64 @@ 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):
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: "Декабрь"}
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: "Декабрь"
}
menu_text = lk_tabel_months_menu_text(prev_1, prev_2, months_ru, EMOJI_DIGITS)
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": []
}
set_state(user_id, "LK_TABEL_MONTHS")
await msg.answer(menu_text, parse_mode="html")
@@ -267,13 +311,22 @@ async def build_confirmation_text(user_id: str, doc: dict) -> str:
async def lk_router_handler(msg: Message):
user_id = msg.from_user.id
current_time = time.time()
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC: return
current_state = get_state(user_id)
# Если пользователь находится внутри меню ЛК, помечаем сообщение обработанным,
# чтобы оно не улетало в ServiceDesk / Instruct роутеры
if current_state and current_state.startswith("LK_"):
msg.handled = True
# Проверка на слишком частые клики (кулдаун)
if current_time - user_last_request.get(user_id, 0) < USER_COOLDOWN_SEC:
return
user_last_request[user_id] = current_time
current_state = get_state(user_id)
nav_text = lk_nav_text(EMOJI_DIGITS)
if not msg.text: return
if not msg.text:
return
cmd = msg.text.strip().lower()
for raw_num, emoji_num in EMOJI_DIGITS.items():
@@ -372,7 +425,6 @@ async def lk_router_handler(msg: Message):
set_state(user_id, "LK_DOCS_MENU")
await msg.answer(LK_DOCS_MENU_TEXT, parse_mode="html")
else:
# 🎯 ИСПРАВЛЕНО: Вместо спама копии меню ЛК выводим строгое предупреждение
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_TABEL_MONTHS":
@@ -384,17 +436,31 @@ 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 in ["1", "2"]:
log_menu_stats(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})")
await msg.answer(lk_tabel_loading(EMOJI_DIGITS), parse_mode="html")
try:
card_id = await asyncio.to_thread(get_card_id_from_ad, user_id)
now = datetime(2026, 6, 3)
target_date = now.replace(day=1) - timedelta(days=1) if cmd == "1" else (now.replace(day=1) - timedelta(days=1)).replace(day=1) - timedelta(days=1)
tabel_report = await asyncio.to_thread(get_tabel_report_text, card_id, target_date.year, target_date.month)
tabel_report = await asyncio.to_thread(
get_tabel_report_text,
card_id,
target_year,
target_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")
@@ -440,7 +506,8 @@ async def lk_router_handler(msg: Message):
return
if not session:
await msg.answer("⚠️ <i>Сессия авторизации не найдена. Начните сначала.</i>", parse_mode="html")
logger.warning(f"Сессия авторизации не найдена для пользователя {user_id}")
await handle_lk_error(msg, user_id, "Сессия авторизации не найдена", f"user_id={user_id}")
set_state(user_id, "LK_MODE")
await send_dynamic_lk_main_menu(msg, user_id)
return
@@ -554,7 +621,8 @@ async def lk_router_handler(msg: Message):
else:
raise Exception(f"Внутренний каскадный шлюз недоступен: {channel_info}")
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
logger.warning(f"Неверный индекс месяца для пользователя {user_id}: cmd={cmd}")
await handle_lk_error(msg, user_id, "Неверный индекс месяца", f"user_id={user_id}, cmd={cmd}")
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")
@@ -578,7 +646,6 @@ async def lk_router_handler(msg: Message):
set_state(user_id, "LK_DOCS_QUANTITY")
await msg.answer(lk_docs_quantity_prompt(EMOJI_DIGITS), parse_mode="html")
else:
# 🎯 ИСПРАВЛЕНО: Заменен спам списка документов на чистую ошибку ввода
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_DOCS_QUANTITY":
@@ -606,7 +673,6 @@ async def lk_router_handler(msg: Message):
confirm_txt = await build_confirmation_text(user_id, user_docs_session[user_id])
await msg.answer(confirm_txt, parse_mode="html")
else:
# 🎯 ИСПРАВЛЕНО: Вместо кастомной подсказки выводим единый стандарт
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_DOCS_PERIOD":
@@ -619,14 +685,12 @@ async def lk_router_handler(msg: Message):
if cmd in ["1", "2", "3", "4", "5", "6", "7", "8"]:
user_docs_session[user_id]['period'] = f"за {datetime.now().year - (int(cmd) - 1)} год"
else:
# 🎯 ИСПРАВЛЕНО: Убран спам меню годов при абракадабре
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
elif doc_id == "5":
if cmd == "1": user_docs_session[user_id]['period'] = "до 1.5 лет"
elif cmd == "2": user_docs_session[user_id]['period'] = "до 3 лет"
else:
# 🎯 ИСПРАВЛЕНО: Убран спам меню возраста при некорректном вводе
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
elif doc_id == "3":
@@ -670,7 +734,6 @@ async def lk_router_handler(msg: Message):
except Exception as e: await handle_lk_error(msg, user_id, f"Заказ справки {user_docs_session.get(user_id, {}).get('doc_id')}", str(e)); set_state(user_id, "LK_MODE")
finally: user_docs_session.pop(user_id, None)
else:
# 🎯 ИСПРАВЛЕНО: Удален спам-текст подтверждения
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
elif current_state == "LK_DOCS_SUCCESS":
@@ -678,5 +741,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")
+245
View File
@@ -0,0 +1,245 @@
#!/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()
+18 -9
View File
@@ -13,7 +13,10 @@ from ldap3.utils.conv import escape_filter_chars
TEST_USER = "man.bogov@tcs.sibcem.ru"
AD_SERVER = "ldap://172.16.20.20"
AD_BASE = "OU=-Пользователи,DC=sibcem,DC=ru"
AD_BASES = (
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
AD_USER = "sdesk-mail"
AD_PASSWORD = "fne?e!q.m8phcrGVAcqr"
@@ -32,7 +35,6 @@ DB_PASSWORD = "Bav:fX#UwH8%atv4"
def get_phone_from_ad(user_id: str) -> str:
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(
server,
user=AD_USER,
@@ -41,22 +43,29 @@ def get_phone_from_ad(user_id: str) -> str:
)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split("@")[0]
search_filter = (
f"(&(objectClass=user)"
f"(|(mail={safe_user_id})"
f"(userPrincipalName={safe_user_id})"
f"(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})"
f"(sAMAccountName={escape_filter_chars(short_username)})))"
"(&(objectClass=user)"
"(|(mail={})"
"(userPrincipalName={})"
"(userPrincipalName={}))"
"(sAMAccountName={}))".format(
safe_user_id,
safe_user_id,
escape_filter_chars(short_username) + "@sibcem.ru",
escape_filter_chars(short_username),
)
)
for base in AD_BASES:
conn.search(
AD_BASE,
base,
search_filter,
attributes=['mobile', 'telephoneNumber']
)
if conn.entries:
break
if not conn.entries:
raise Exception("Пользователь не найден в AD")
+23 -73
View File
@@ -1,7 +1,6 @@
import sys
import asyncio
import logging
from logging.handlers import RotatingFileHandler
import os
import psutil
import time
@@ -18,13 +17,6 @@ logging.basicConfig(
)
logging.getLogger('trueconf.client.chatbot').propagate = False
logger = logging.getLogger(__name__)
sd_workflow_logger = logging.getLogger('sd_workflow')
sd_workflow_logger.setLevel(logging.INFO)
if not sd_workflow_logger.handlers:
handler = RotatingFileHandler(os.path.join(os.path.dirname(__file__), 'logs', 'sd_workflow.log'), maxBytes=10*1024*1024, backupCount=5)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
sd_workflow_logger.addHandler(handler)
# Добавляем путь к проекту в sys.path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -79,7 +71,7 @@ log_ram_usage("Старт скрипта (базовый вес)")
from photo_bot.handlers import router as photo_router, PHOTO_MAIN_MENU_TEXT
log_ram_usage("После импорта photo_bot")
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT, sd_sessions
from service_desk.handlers import router as sd_router, SD_MAIN_MENU_TEXT
log_ram_usage("После импорта service_desk")
from transcription_bot.handlers import router as transcription_router, TRANSCRIPTION_MAIN_MENU_TEXT
@@ -88,6 +80,9 @@ log_ram_usage("После импорта transcription_router (Виспер)")
from search_bot.handlers import router as search_router, SEARCH_MAIN_MENU_TEXT
log_ram_usage("После импорта search_router")
from instruct.handlers import router as instruct_router
log_ram_usage("После импорта instruct_router")
from lk.handlers import router as lk_router
log_ram_usage("После импорта lk_router (Личный кабинет)")
@@ -115,79 +110,21 @@ async def main_menu(msg: Message):
menu_shown = USER_MENU_SHOWN.get(user_id, False)
if msg.text:
cmd = msg.text.strip().lower()
# Обработка "0" — возврат в главное меню
if cmd in ("0", "меню"):
await msg.answer(MENU_TEXT, parse_mode="html")
return
action = MENU_MAP.get(cmd)
if action == "SD":
USER_MENU_SHOWN[user_id] = True
set_state(user_id, "SD_MODE")
log_menu_stats(user_id, "Service Desk", "Вход")
# Помечаем сообщение как обработанное, чтобы 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
set_state(user_id, "PHOTO_MODE")
log_menu_stats(user_id, "Photo Bot", "Вход")
await msg.answer(PHOTO_MAIN_MENU_TEXT, parse_mode="html")
from photo_bot.handlers import send_dynamic_photo_main_menu
await send_dynamic_photo_main_menu(msg, user_id)
return
elif action == "TRANSCRIPTION":
USER_MENU_SHOWN[user_id] = True
@@ -208,6 +145,18 @@ async def main_menu(msg: Message):
from lk.handlers import send_dynamic_lk_main_menu
await send_dynamic_lk_main_menu(msg, user_id)
return
elif action == "INSTRUCT":
USER_MENU_SHOWN[user_id] = True
set_state(user_id, "INSTRUCT_MODE")
log_menu_stats(user_id, "Инструкции", "Вход")
await msg.answer(
"📋 <b>Выберите инструкцию:</b>\n\n"
"1⃣ — 🖥 Trueconf\n"
"2⃣ — 📧 Почта\n\n"
"<i>0⃣ — В главное меню</i>",
parse_mode="html"
)
return
else:
if not menu_shown:
USER_MENU_SHOWN[user_id] = True
@@ -225,6 +174,7 @@ async def main():
dp.include_router(sd_router)
dp.include_router(transcription_router)
dp.include_router(search_router)
dp.include_router(instruct_router)
dp.include_router(lk_router)
dp.include_router(main_router)
bot = Bot.from_credentials(server=TC_SERVER, username=TC_LOGIN, password=TC_PASSWORD, dispatcher=dp, verify_ssl=False)
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env python3
"""
Парсинг регламента через Unstructured API.
Делает один проход (strategy=hi_res) и разделяет элементы на текст и таблицы.
"""
import json
import re
import requests
from pathlib import Path
# === НАСТРОЙКИ ===
UNSTRUCTURED_API = "http://192.168.1.103:8005/general/v0/general"
PDF_PATH = "/opt/documents_xk/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.pdf"
OUTPUT_PATH = "/opt/okf-regulations/concepts/pol123_parsed.json"
def parse_document(pdf_path: str) -> dict:
"""Отправляет PDF в API (hi_res) и возвращает рассортированные данные."""
print(f" Отправка файла {Path(pdf_path).name} в Unstructured API (hi_res)...")
with open(pdf_path, "rb") as f:
files = {"files": (Path(pdf_path).name, f, "application/pdf")}
data = {
"strategy": "hi_res",
"coordinates": "true"
}
try:
response = requests.post(UNSTRUCTURED_API, files=files, data=data, timeout=300)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f" ❌ Ошибка Unstructured API: {e}")
return {"text": [], "tables": []}
elements = response.json()
if isinstance(elements, dict):
elements = elements.get("elements", [])
text_elements = []
real_tables = []
print(" Сортировка и фильтрация элементов...")
for el in elements:
el_type = el.get("type")
if el_type == "Table":
meta = el.get("metadata", {})
html = meta.get("text_as_html", "")
# Строгий фильтр на наличие HTML-таблицы
if html and "<table" in html.lower():
row_count = len(re.findall(r'<tr[^>]*>', html, re.IGNORECASE)) if html else None
col_count = len(re.findall(r'<td[^>]*>|<th[^>]*>', html[:500], re.IGNORECASE)) if html else None
real_tables.append({
"page": meta.get("page_number"),
"text": el.get("text", ""),
"html": html,
"row_count": row_count,
"col_count": col_count,
})
else:
# Если таблица оказалась фейковой, отправляем её текст в общий котел
text_elements.append(el)
else:
# Всё остальное (Title, NarrativeText, ListItem и т.д.)
text_elements.append(el)
return {"text": text_elements, "tables": real_tables}
def main():
print("=" * 60)
print(f"📄 Парсинг документа: {Path(PDF_PATH).name}")
print("=" * 60)
parsed_data = parse_document(PDF_PATH)
print(f"\n[Итоги]")
print(f" ✅ Получено текстовых элементов: {len(parsed_data['text'])}")
print(f" ✅ Найдено валидных таблиц: {len(parsed_data['tables'])}")
result = {
"document": "ПОЛ-123",
"title": "Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2",
"text": parsed_data["text"],
"tables": parsed_data["tables"],
"metadata": {
"unstructured_api": UNSTRUCTURED_API,
"strategy": "hi_res",
"timestamp": "2026-07-06",
},
}
Path(OUTPUT_PATH).write_text(json.dumps(result, ensure_ascii=False, indent=2))
print(f"\n✅ Сохранено в {OUTPUT_PATH}")
print("=" * 60)
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -13
View File
@@ -5,37 +5,39 @@ from ldap3 import Server, Connection, ALL, MODIFY_REPLACE
# Подтягиваем конфиг
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
from config.config import AD_SERVER, AD_USER, AD_PASSWORD
from utils.ad_search import search_by_login as ad_search_all_bases
logger = logging.getLogger(__name__)
def upload_photo_to_ad(target_user: str, photo_path: str) -> dict:
"""
Читает готовое фото с диска и записывает его в атрибут thumbnailPhoto в AD.
Ищет пользователя по всем OU из AD_BASES.
"""
from utils.ad_search import search_by_filter
try:
# Читаем сырые байты картинки
with open(photo_path, "rb") as f:
photo_bytes = f.read()
# Формируем фильтр для поиска
# Ищем пользователя в AD по всем OU
if '@' in target_user:
ldap_filter = f"(mail={target_user})"
ldap_filter = "(mail={})".format(target_user)
else:
ldap_filter = f"(sAMAccountName={target_user})"
ldap_filter = "(sAMAccountName={})".format(target_user)
# Подключаемся к AD
entries = search_by_filter(ldap_filter, ["cn"])
if not entries:
return {"success": False, "error": "Пользователь {} не найден в AD.".format(target_user)}
user_dn = entries[0].entry_dn
# Подключаемся для модификации
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
# Находим пользователя в дереве (нам нужен его точный путь - DN)
conn.search(search_base=AD_BASE, search_filter=ldap_filter, attributes=["cn"])
if not conn.entries:
return {"success": False, "error": f"Пользователь {target_user} не найден в AD."}
user_dn = conn.entries[0].entry_dn
# 🪄 МАГИЯ: Перезаписываем атрибут thumbnailPhoto нашими байтами
conn.modify(user_dn, {'thumbnailPhoto': [(MODIFY_REPLACE, [photo_bytes])]})
+143 -58
View File
@@ -2,6 +2,7 @@ import os
import asyncio
import logging
import sys
import io
# Добавляем путь к корню проекта
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -10,8 +11,21 @@ from trueconf import Bot, Dispatcher, Router, Message
from trueconf.types import FSInputFile
# Наши обработчики
from photo_processor import prepare_ad_photo
from ad_helper import upload_photo_to_ad
from photo_processor import prepare_ad_photo, get_crop_coordinates
from utils.texts import (
PHOTO_MAIN_MENU_TEXT,
PHOTO_UNKNOWN_CMD_TEXT,
PHOTO_PROCESSING_FACE_TEXT,
PHOTO_UPLOADING_API_TEXT,
photo_preview_ready,
photo_success_published,
photo_success_pending,
photo_error_rejected,
photo_error_already_pending,
photo_cancel_text,
photo_error_handling,
EMOJI_DIGITS,
)
# Настройки
from config.config import *
@@ -31,9 +45,38 @@ router = Router()
tc_bot = None
# 🧠 ПАМЯТЬ БОТА: Храним пути к фото, ожидающим подтверждения
# Пример: {'ds.krivochenko': '/tmp/ready_ds.krivochenko.jpg'}
# Пример: {'ds.krivochenko': {'path': '/tmp/orig_abc.jpg', 'coords': (100, 50, 300, 400)}}
pending_photos = {}
def _generate_preview(image_bytes: bytes, coords: tuple, size: int = 400) -> bytes | None:
"""Генерирует превью фото с кропом по координатам."""
try:
from PIL import Image
img = Image.open(io.BytesIO(image_bytes))
if img.mode != 'RGB':
img = img.convert('RGB')
x1, y1, x2, y2 = coords
cropped = img.crop((x1, y1, x2, y2))
# Ресайз до квадрата
side = min(cropped.size[0], cropped.size[1])
left = (cropped.size[0] - side) // 2
top = (cropped.size[1] - side) // 2
cropped = cropped.crop((left, top, left + side, top + side))
preview = cropped.resize((size, size), Image.Resampling.LANCZOS)
buf = io.BytesIO()
preview.save(buf, format="JPEG", quality=85)
buf.seek(0)
return buf.getvalue()
except Exception as e:
logger.error(f"Ошибка генерации превью: {e}")
return None
@router.message()
async def handle_message(msg: Message):
global tc_bot
@@ -52,39 +95,67 @@ async def handle_message(msg: Message):
# Если согласен
if answer in ["да", "yes", "+", "загрузить", "давай", "конечно"]:
photo_path = pending_photos[user_login]
await msg.answer("⏳ Подключаюсь к Active Directory и загружаю фото...")
photo_data = pending_photos[user_login]
original_path = photo_data['path']
coords = photo_data['coords']
# Запускаем скрипт загрузки в AD
upload_result = await asyncio.to_thread(upload_photo_to_ad, user_login, photo_path)
await msg.answer(PHOTO_UPLOADING_API_TEXT)
if upload_result["success"]:
await msg.answer(
"🎉 **Ура! Фото успешно установлено в Active Directory!**\n\n"
"Оно появится в Outlook, TrueConf и других системах в течение нескольких часов (после синхронизации серверов)."
try:
# Отправляем фото через API
result = await asyncio.to_thread(
prepare_ad_photo, original_path, user_login
)
else:
await msg.answer(f"❌ **Ошибка при загрузке:** {upload_result.get('error')}")
# Убираем за собой файл и стираем пользователя из памяти
if os.path.exists(photo_path):
os.remove(photo_path)
if result["success"]:
verdict = result.get("verdict")
if verdict == "published":
await msg.answer(photo_success_published())
elif verdict == "pending_moderation":
await msg.answer(photo_success_pending())
else:
# Успех, но вердикт другой — показываем сообщение
msg_text = result.get("message", "Фото обработано")
await msg.answer(f"{msg_text}")
else:
error = result.get("error", "Неизвестная ошибка")
if "уже есть фото" in error:
await msg.answer(photo_error_already_pending())
elif "отклонено" in error.lower() or "не прошел" in error.lower():
await msg.answer(photo_error_rejected(error))
else:
await msg.answer(f"❌ Ошибка: {error}")
except Exception as e:
logger.error(f"Ошибка отправки фото: {e}")
await msg.answer(photo_error_handling())
# Убираем за собой
if os.path.exists(original_path):
os.remove(original_path)
del pending_photos[user_login]
return
# Если отказался
elif answer in ["нет", "no", "-", "отмена"]:
photo_path = pending_photos[user_login]
await msg.answer("🚫 Загрузка отменена. Можете прислать мне другую фотографию!")
photo_data = pending_photos[user_login]
original_path = photo_data['path']
if os.path.exists(photo_path):
os.remove(photo_path)
await msg.answer(photo_cancel_text())
if os.path.exists(original_path):
os.remove(original_path)
del pending_photos[user_login]
return
# Если написал что-то непонятное, но мы ждем ответа
# Если написал что-то непонятное
else:
await msg.answer("❓ Пожалуйста, напишите **Да** (чтобы загрузить фото в AD) или **Нет** (чтобы отменить). Либо просто пришлите новую фотографию.")
await msg.answer(
f"❓ Пожалуйста, нажмите:\n"
f"1️⃣ — Подтвердить\n"
f"2️⃣ — Вырезать заново\n\n"
f"Или просто отправьте новое фото."
)
return
# =====================================================================
@@ -97,64 +168,78 @@ async def handle_message(msg: Message):
await msg.answer("❌ Не удалось получить файл. Попробуйте еще раз.")
return
ready_path = f"/tmp/ready_{user_login}.jpg"
raw_path = None
await msg.answer("⏳ Обрабатываю ваше фото, пожалуйста, подождите...")
await msg.answer(PHOTO_PROCESSING_FACE_TEXT)
try:
downloaded_file = await tc_bot.download_file_by_id(file_id)
raw_path = downloaded_file
# Обрабатываем фото (с новым разрешением 240x240)
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 240)
# Читаем байты
with open(raw_path, 'rb') as f:
image_bytes = f.read()
if result["success"]:
photo_document = FSInputFile(ready_path, filename=f"AD_{user_login}.jpg")
# Ищем лицо и получаем координаты
coords = await asyncio.to_thread(get_crop_coordinates, image_bytes)
if not coords:
await msg.answer(
"❌ Лицо не найдено. Пожалуйста, сделайте фото анфас при хорошем освещении.\n\n"
f"{EMOJI_DIGITS['0']}В главное меню"
)
if os.path.exists(raw_path):
os.remove(raw_path)
return
# Генерируем превью
preview_bytes = _generate_preview(image_bytes, coords, size=400)
if preview_bytes:
# Сохраняем оригинал и координаты
preview_path = f"/tmp/preview_{user_login}.jpg"
with open(preview_path, 'wb') as f:
f.write(preview_bytes)
# Сохраняем оригинал
original_path = f"/tmp/orig_{user_login}.jpg"
with open(original_path, 'wb') as f:
f.write(image_bytes)
# Отправляем превью
preview_doc = FSInputFile(preview_path, filename=f"preview_{user_login}.jpg")
# Отправляем фото и СРАЗУ задаем вопрос
await msg.answer_photo(
photo_document,
photo_document,
caption=(
f"✅ **Фото готово!**\n\n"
f"📐 Размер: 240x240 px\n"
f"⚖️ Вес: {result['size_kb']} KB\n\n"
f"❓ **Вам нравится результат? Загружаем в Active Directory?**\n"
f"Напишите мне в ответ **Да** или **Нет**."
)
preview_doc, preview_doc,
caption=photo_preview_ready()
)
# 🧠 ЗАПОМИНАЕМ, что этот пользователь теперь в режиме ожидания ответа
pending_photos[user_login] = ready_path
logger.info(f"Фото подготовлено для {user_login}, ждем подтверждения.")
# Сохраняем состояние
pending_photos[user_login] = {
'path': original_path,
'coords': coords,
'preview_path': preview_path
}
logger.info(f"Фото подготовлено для {user_login}, координаты: {coords}")
else:
await msg.answer(f"❌ Ошибка обработки: {result.get('error')}")
await msg.answer("❌ Ошибка обработки фото. Попробуйте снова.")
except Exception as e:
logger.error(f"Ошибка при работе с фото для {user_login}: {e}")
await msg.answer("❌ Произошла системная ошибка при обработке.")
await msg.answer(photo_error_handling())
finally:
# ВАЖНО: Удаляем ТОЛЬКО оригинал от TrueConf (raw_path).
# Готовое фото (ready_path) оставляем на диске, оно ждет ответа "Да"!
if raw_path and os.path.exists(raw_path):
try: os.remove(raw_path)
except: pass
# Удаляем оригинал TrueConf, но не превью (оно в pending_photos)
if 'raw_path' in locals() and os.path.exists(raw_path):
try:
os.remove(raw_path)
except:
pass
return
# =====================================================================
# СЦЕНАРИЙ 3: Дефолтное приветствие (если просто написали текст)
# =====================================================================
await msg.answer(
"👋 Привет! Я корпоративный фотограф для Active Directory.\n\n"
"📸 **Просто отправь мне фото**, и я автоматически:\n"
"1. Найду лицо на снимке и сделаю правильный кадр.\n"
"2. Улучшу резкость и сожму до стандарта 240x240 px.\n"
"3. Предложу загрузить результат прямо в твой профиль!"
)
await msg.answer(PHOTO_MAIN_MENU_TEXT)
async def main():
global tc_bot
+6 -13
View File
@@ -7,7 +7,8 @@ from PIL import Image
# Подтягиваем ваши настройки из конфига
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config.config import AD_SERVER, AD_USER, AD_PASSWORD, AD_BASE
from config.config import AD_SERVER, AD_USER, AD_PASSWORD
from utils.ad_search import search_by_filter as ad_search_all_bases
logging.basicConfig(level=logging.INFO, format="%(message)s")
@@ -21,22 +22,14 @@ def check_user_photo(target_user: str):
ldap_filter = f"(sAMAccountName={target_user})"
try:
# Подключаемся к AD
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
# Подключаемся к AD и ищем по всем OU из AD_BASES
entries = ad_search_all_bases(ldap_filter, ["cn", "thumbnailPhoto"])
# Ищем пользователя и просим вернуть атрибуты: имя (cn) и фото (thumbnailPhoto)
conn.search(
search_base=AD_BASE,
search_filter=ldap_filter,
attributes=["cn", "thumbnailPhoto"]
)
if not conn.entries:
if not entries:
print("❌ Пользователь с такими данными не найден в AD.")
return
user = conn.entries[0]
user = entries[0]
user_name = user.cn.value if 'cn' in user else target_user
print(f"👤 Найден сотрудник: {user_name}")
+403 -65
View File
@@ -2,7 +2,6 @@
import os
import asyncio
import logging
import httpx
import smtplib
import time
from datetime import datetime
@@ -15,18 +14,27 @@ from trueconf.types import FSInputFile
import config.config as config
from utils import states
from utils.menu import MENU_TEXT
from photo_bot.photo_processor import prepare_ad_photo
from photo_bot.photo_processor import prepare_ad_photo, get_crop_coordinates, check_photo_api, make_face_crop_bytes
from utils.texts import (
UNKNOWN_MAIN_CMD_TEXT,
EMOJI_DIGITS,
PHOTO_MAIN_MENU_TEXT,
PHOTO_UNKNOWN_CMD_TEXT,
photo_error_handling,
PHOTO_UPLOADING_TEXT,
photo_success_sent,
PHOTO_PROCESSING_FACE_TEXT,
PHOTO_AI_PROCESSING_TEXT,
photo_success_published,
photo_success_pending,
photo_error_rejected,
photo_error_already_pending,
photo_cancel_text,
PHOTO_PROCESSING_TEXT,
photo_preview_ready,
photo_error_processing,
photo_welcome_with_status,
photo_test_welcome,
PHOTO_TEST_WELCOME_TEXT,
PHOTO_TEST_PREVIEW_TEXT,
PHOTO_TEST_AI_CHECKING_TEXT,
photo_test_result,
photo_test_ai_error,
)
# 🔌 Импортируем централизованную функцию сбора статистики из main
@@ -36,8 +44,23 @@ logger = logging.getLogger(__name__)
router = Router()
# Словарь для временного хранения путей к фото пользователей
# Формат: {user_id: {'path': '/tmp/orig_user.jpg', 'coords': (x1,y1,x2,y2)}}
pending_photos = {}
# Пользователи, находящиеся в под-режиме «тест проверка фото»
test_submode_users = set()
# Пользователи, у которых фото на модерации (вход заблокирован,
# модуль остаётся открыт: в тест-режиме можно отправить фото на тест)
moderation_pending_users = set()
def in_test_submode(user_id: str) -> bool:
"""Проверяет, находится ли пользователь в под-режиме тест-проверки фото"""
if user_id not in test_submode_users:
return False
# Если сессия истекла (состояние сброшено) — под-режим тоже недействителен
return states.get_state(user_id) == "PHOTO_MODE"
# =========================================================
# СИСТЕМА ОБРАБОТКИ ОШИБОК И ЛОГИРОВАНИЯ
# =========================================================
@@ -101,11 +124,95 @@ def log_and_notify_photo_error(user_id: str, action: str, error_details: str):
async def handle_photo_error(msg: Message, user_id: str, action: str, error_details: str):
"""Вызывает системное логирование и выдает заглушку пользователю"""
logger.error(f"🔥 Ошибка фото-бота для {user_id}, действие: {action}: {error_details}")
await asyncio.to_thread(log_and_notify_photo_error, user_id, action, error_details)
await msg.answer(photo_error_handling(), parse_mode="html")
async def check_moderation_status(user_id: str) -> dict:
"""Проверяет текущий статус модерации пользователя через API"""
from photo_bot.photo_processor import API_URL, API_KEY
import requests
# Извлекаем логин из email (user_id@tcs.sibcem.ru -> user_id)
login = user_id.split('@')[0]
status_url = API_URL.replace("/upload/", "/status/")
try:
response = requests.get(
status_url,
params={"user_id": login},
headers={"X-API-Key": API_KEY},
timeout=15,
verify=False
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Ошибка проверки статуса модерации: {e}")
return {"status": "error", "reason": str(e)}
# =========================================================
# ВХОД В РАЗДЕЛ ФОТО: проверка статуса модерации + показ меню
# =========================================================
async def send_dynamic_photo_main_menu(msg: Message, user_id: str):
"""Вход в раздел фото (паттерн ЛК): модуль сам запрашивает данные и показывает их.
- pending — сообщение «фото на модерации»; модуль остаётся открыт
(цифры → ошибка неверного выбора, 0 — выход). Тест-режим:
фото можно отправить на проверку в ИИ;
- rejected — ОДНО сообщение: меню + причина отказа (кнопки на месте);
- иначе/ошибка API — штатное меню входа.
Статус проверяется и показывается всегда, включая тестовый режим.
"""
# Новый вход в модуль (кнопка 4 из главного меню) = свежий сеанс.
# Тайм-аут сессии чистит только user_states, а module-level pending_photos
# и test_submode_users остаются: висящий тест-кадр из прошлой сессии иначе
# улетит на проверку сразу после нажатия «1». Сбрасываем здесь.
if user_id in pending_photos:
_pd = pending_photos[user_id]
for _k in ('path', 'preview_path'):
_p = _pd.get(_k)
if _p and os.path.exists(_p):
try:
os.remove(_p)
except OSError:
pass
del pending_photos[user_id]
test_submode_users.discard(user_id)
# Статус модерации запрашиваем всегда (и в тестовом режиме тоже)
status = await check_moderation_status(user_id)
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
# Фото на модерации — штатная загрузка заблокирована,
# но модуль остаётся открыт (PHOTO_MODE), чтобы цифры не ушли в главное меню
log_menu_stats(user_id, "Photo Bot", "Вход — фото на модерации")
states.set_state(user_id, "PHOTO_MODE")
moderation_pending_users.add(user_id)
# Единый вход: «фото на модерации» (+ кнопка 1 — тест, если тест-режим включён).
# Описание тест-режима показываем ТОЛЬКО после нажатия кнопки 1
await msg.answer(
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
parse_mode="html"
)
return
if status.get("status") == "ok" and status.get("moderation_status") == "rejected":
log_menu_stats(user_id, "Photo Bot", "Вход — фото отклонено, показана причина")
elif status.get("status") != "ok":
logger.warning(
f"Не удалось проверить статус модерации для {user_id}: {status.get('reason')}"
)
status = None
states.set_state(user_id, "PHOTO_MODE")
log_menu_stats(user_id, "Photo Bot (TEST)" if config.PHOTO_TEST_MODE else "Photo Bot", "Вход")
# Одно универсальное сообщение: меню (+ тест-кнопка) + (если нужно) статус модерации
entry_text = photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE)
await msg.answer(entry_text, parse_mode="html")
# =========================================================
# ОСНОВНОЙ ОБРАБОТЧИК
# =========================================================
@@ -131,112 +238,343 @@ async def photo_module_handler(msg: Message):
cmd = raw_num
break
# --- ТЕСТ-ПОД-РЕЖИМ: 9 = назад в меню фото (не выход из модуля) ---
if in_test_submode(user_id) and cmd == "9" and not is_attachment:
test_submode_users.discard(user_id)
log_menu_stats(user_id, "Photo Bot (TEST)", "Назад из тест-под-режима")
# Висящее тест-фото снимаем (тест-режим: оно никуда не публиковалось)
if user_id in pending_photos:
photo_data = pending_photos[user_id]
if os.path.exists(photo_data['path']):
os.remove(photo_data['path'])
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
os.remove(photo_data['preview_path'])
del pending_photos[user_id]
status = await check_moderation_status(user_id)
await msg.answer(
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
parse_mode="html"
)
return
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню с очисткой кэша) ---
if cmd in ["0", "9", "/start", "меню"]:
log_menu_stats(user_id, "Photo Bot", "Выход в главное меню")
states.clear_state(user_id)
test_submode_users.discard(user_id)
moderation_pending_users.discard(user_id)
if user_id in pending_photos:
if os.path.exists(pending_photos[user_id]):
os.remove(pending_photos[user_id])
photo_data = pending_photos[user_id]
if os.path.exists(photo_data['path']):
os.remove(photo_data['path'])
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
os.remove(photo_data['preview_path'])
del pending_photos[user_id]
await msg.answer(MENU_TEXT, parse_mode="html")
return
# --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 1 или 2) ---
if not is_attachment and user_id in pending_photos:
if cmd == "1":
photo_path = pending_photos[user_id]
final_filename = f"{user_login}.bmp"
# --- СОСТОЯНИЕ «ФОТО НА МОДЕРАЦИИ»: активного фото нет ---
if user_id in moderation_pending_users and user_id not in pending_photos:
if not is_attachment:
status = await check_moderation_status(user_id)
still_pending = (
status.get("status") == "ok"
and status.get("moderation_status") == "pending"
)
if still_pending:
if config.PHOTO_TEST_MODE and cmd == "1":
# Кнопка 1 — вход в тест-под-режим (можно отправить фото в ИИ)
test_submode_users.add(user_id)
log_menu_stats(user_id, "Photo Bot (TEST)", "Тест-под-режим (фото на модерации)")
await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html")
else:
# Неверная команда → глобальное сообщение
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
# Статус изменился (одобрено/отклонено) — разблокируем
was_in_test = in_test_submode(user_id)
moderation_pending_users.discard(user_id)
if config.PHOTO_TEST_MODE and was_in_test:
await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html")
else:
await msg.answer(
photo_welcome_with_status(status), parse_mode="html"
)
return
# is_attachment — пропускаем: фото обрабатывается штатно ниже
# (тест-под-режим: превью + отправка в ИИ)
await msg.answer(PHOTO_UPLOADING_TEXT, parse_mode="html")
# --- ПРОВЕРКА СТАТУСА: если нет фото в pending_photos, проверяем статус модерации ---
# (в тестовом режиме статусы тоже показываем — поведение как в обычном режиме)
if user_id not in pending_photos and not is_attachment:
status = await check_moderation_status(user_id)
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
await msg.answer(
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
parse_mode="html"
)
return
elif status.get("status") != "ok":
# Ошибка проверки — не блокируем, просто логируем
logger.warning(f"Не удалось проверить статус модерации для {user_id}: {status.get('reason')}")
# --- ВХОД В ТЕСТ-ПОД-РЕЖИМ: кнопка 1 в меню фото-режима (тест включен) ---
if (not is_attachment and user_id not in pending_photos
and not in_test_submode(user_id)
and config.PHOTO_TEST_MODE and cmd == "1"):
test_submode_users.add(user_id)
log_menu_stats(user_id, "Photo Bot (TEST)", "Вход в тест-под-режим")
await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html")
return
# --- ТЕСТ-ПОД-РЕЖИМ: кнопка 1 отправляет фото НАПРЯМУЮ в ИИ, фото никуда не публикуется ---
if not is_attachment and user_id in pending_photos and in_test_submode(user_id):
if cmd == "1":
photo_data = pending_photos[user_id]
original_path = photo_data['path']
await msg.answer(PHOTO_TEST_AI_CHECKING_TEXT, parse_mode="html")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
with open(photo_path, "rb") as f:
files = {'file': (final_filename, f, 'image/bmp')}
response = await client.post(config.TRASH_API_URL, files=files)
with open(original_path, 'rb') as f:
raw_photo = f.read()
# На проверку уходит АВТО-КРОП ЛИЦА (тот же, что в превью
# и что публикуется на сайте) — правила «голова ≥ 30%» и
# прочее имеют смысл для публикуемого результата, а не
# для произвольно снятого оригинала.
crop_bytes = await asyncio.to_thread(
make_face_crop_bytes, raw_photo, photo_data.get('coords')
)
# Dry-run: полный пайплайн проверки на САЙТЕ (CV + гримасы + ИИ),
# фото в БД/AD/Orion не попадает
check_res = await asyncio.to_thread(check_photo_api, crop_bytes)
except Exception as e:
logger.error(f"🔥 [TEST MODE] Ошибка проверки фото: {e}")
check_res = {"status": "error", "reason": f"Ошибка бота: {e}"}
if response.status_code == 200:
# ФИКСАЦИЯ СТАТИСТИКИ: Успешная выгрузка фото на утверждение
log_menu_stats(user_id, "Photo Bot", "Отправка фото на утверждение")
await msg.answer(photo_success_sent(), parse_mode="html")
if check_res.get("status") != "ok":
# Фото остаётся в pending — можно повторить проверку (кнопка 1)
log_menu_stats(user_id, "Photo Bot (TEST)", f"Ошибка проверки: {check_res.get('reason')}")
await msg.answer(
photo_test_ai_error(check_res.get('reason', 'Неизвестная ошибка')),
parse_mode="html"
)
return
verdict = check_res.get('verdict', '')
log_menu_stats(user_id, "Photo Bot (TEST)", f"Вердикт проверки: {verdict}")
# Фото остаётся в pending — можно повторить проверку другим фото (кнопка 2)
await msg.answer(
photo_test_result(
verdict,
check_res.get('ai_reason') or '',
check_res.get('cv_approved'),
check_res.get('cv_reason') or '',
check_res.get('forced_moderation'),
check_res.get('timings'),
check_res.get('ai_metrics'),
frame=check_res.get('frame'),
),
parse_mode="html"
)
return
elif cmd == "2":
log_menu_stats(user_id, "Photo Bot (TEST)", "Загрузка другого фото")
photo_data = pending_photos[user_id]
original_path = photo_data['path']
if os.path.exists(original_path):
os.remove(original_path)
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
os.remove(photo_data['preview_path'])
del pending_photos[user_id]
await msg.answer(PHOTO_TEST_WELCOME_TEXT, parse_mode="html")
return
# --- Неверная команда при висящем превью → глобальное сообщение ---
else:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
# --- ПОДТВЕРЖДЕНИЕ ОТПРАВКИ (Кнопка 1) ---
if not is_attachment and user_id in pending_photos:
if cmd == "1":
photo_data = pending_photos[user_id]
original_path = photo_data['path']
await msg.answer(PHOTO_AI_PROCESSING_TEXT, parse_mode="html")
try:
# Отправляем фото через API
result = await asyncio.to_thread(
prepare_ad_photo, original_path, user_login
)
if result["success"]:
verdict = result.get("verdict")
if verdict == "published":
log_menu_stats(user_id, "Photo Bot", "Фото опубликовано")
await msg.answer(photo_success_published(), parse_mode="html")
elif verdict == "pending_moderation":
log_menu_stats(user_id, "Photo Bot", "Фото на модерации")
await msg.answer(photo_success_pending(), parse_mode="html")
else:
msg_text = result.get("message", "Фото обработано")
await msg.answer(f"{msg_text}", parse_mode="html")
else:
error_msg = result.get("error", "Неизвестная ошибка")
# Если фото уже на модерации — показываем конкретное сообщение
if "уже есть фото на модерации" in error_msg:
log_menu_stats(user_id, "Photo Bot", "Попытка повторной загрузки на модерации")
await msg.answer(photo_error_already_pending(), parse_mode="html")
else:
await msg.answer(photo_error_handling(), parse_mode="html")
except Exception as e:
await handle_photo_error(msg, user_id, "Отправка фото через API", str(e))
# Убираем за собой
if os.path.exists(original_path):
os.remove(original_path)
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
os.remove(photo_data['preview_path'])
states.clear_state(user_id)
if os.path.exists(photo_path):
os.remove(photo_path)
if user_id in pending_photos:
del pending_photos[user_id]
await asyncio.sleep(0.5)
await msg.answer(MENU_TEXT, parse_mode="html")
else:
error_msg = f"API вернул статус-код {response.status_code}"
await handle_photo_error(msg, user_id, "Отправка фото по API", error_msg)
except Exception as e:
await handle_photo_error(msg, user_id, "Подключение к API загрузки", str(e))
return
# --- ОТМЕНА ФОТО (Кнопка 2) ---
elif cmd == "2":
log_menu_stats(user_id, "Photo Bot", "Отмена загруженного фото")
photo_path = pending_photos[user_id]
if os.path.exists(photo_path):
os.remove(photo_path)
photo_data = pending_photos[user_id]
original_path = photo_data['path']
if os.path.exists(original_path):
os.remove(original_path)
if 'preview_path' in photo_data and os.path.exists(photo_data['preview_path']):
os.remove(photo_data['preview_path'])
del pending_photos[user_id]
states.clear_state(user_id)
await msg.answer(photo_cancel_text(), parse_mode="html")
return
# --- ИСПРАВЛЕНО: Защита от мусорного ввода при висящем превью ---
else:
await msg.answer(PHOTO_UNKNOWN_CMD_TEXT, parse_mode="html")
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
# --- НЕВЕРНАЯ КОМАНДА (висящего фото нет) → глобальное сообщение ---
if not is_attachment:
await msg.answer(UNKNOWN_MAIN_CMD_TEXT, parse_mode="html")
return
# --- ФОТО НА МОДЕРАЦИИ: в обычном режиме новое фото не принимается ---
if is_attachment and not in_test_submode(user_id) and user_id in moderation_pending_users:
status = await check_moderation_status(user_id)
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
log_menu_stats(user_id, "Photo Bot", "Отказ в приёме фото — есть фото на модерации")
await msg.answer(
photo_welcome_with_status(status, show_test=config.PHOTO_TEST_MODE),
parse_mode="html"
)
return
# Статус изменился (одобрено/отклонено) — снимаем блок, фото принимается штатно
moderation_pending_users.discard(user_id)
# --- ПРИЕМ НОВОГО ФОТО ---
if is_attachment:
file_id = getattr(msg.content, "file_id", None)
ready_path = f"/tmp/{user_login}.bmp"
raw_path = None
raw_bytes = None
await msg.answer(PHOTO_PROCESSING_TEXT, parse_mode="html")
await msg.answer(PHOTO_PROCESSING_FACE_TEXT, parse_mode="html")
try:
downloaded_file = await states.tc_bot.download_file_by_id(file_id)
raw_path = downloaded_file
result = await asyncio.to_thread(prepare_ad_photo, raw_path, ready_path, 96)
logger.info(f"Скачан файл для {user_login}: type={type(downloaded_file)}, len={len(downloaded_file) if isinstance(downloaded_file, (bytes, str)) else 'N/A'}")
if result["success"]:
# ФИКСАЦИЯ СТАТИСТИКИ: Факт успешной загрузки и обработки нового фото
log_menu_stats(user_id, "Photo Bot", "Загрузка и конвертация нового фото")
pending_photos[user_id] = ready_path
preview_path = f"/tmp/prev_{user_login}.jpg"
# Скачиваем в байты напрямую
if isinstance(downloaded_file, bytes):
raw_bytes = downloaded_file
elif isinstance(downloaded_file, str) and os.path.exists(downloaded_file):
with open(downloaded_file, 'rb') as f:
raw_bytes = f.read()
else:
# Пробуем как путь
raw_path = downloaded_file if isinstance(downloaded_file, str) else None
if raw_path and os.path.exists(raw_path):
with open(raw_path, 'rb') as f:
raw_bytes = f.read()
else:
raise Exception(f"Не удалось получить байты файла: {type(downloaded_file)}")
logger.info(f"Прочитано {len(raw_bytes)} байт для {user_login}")
# Ищем лицо и получаем координаты
coords = await asyncio.to_thread(get_crop_coordinates, raw_bytes)
logger.info(f"get_crop_coordinates вернул: {coords} для {user_login}")
if not coords:
await msg.answer(
"❌ Лицо не найдено. Пожалуйста, сделайте фото анфас при хорошем освещении.\n\n"
f"{EMOJI_DIGITS['0']}В главное меню",
parse_mode="html"
)
return
# Генерируем превью
try:
Image.open(ready_path).save(preview_path, format="JPEG")
p_main = FSInputFile(preview_path, filename="preview.jpg")
p_thumb = FSInputFile(preview_path, filename="preview_thumb.jpg")
await msg.answer_photo(p_main, p_thumb)
from PIL import Image
import io
img = Image.open(io.BytesIO(raw_bytes))
if img.mode != 'RGB':
img = img.convert('RGB')
x1, y1, x2, y2 = coords
cropped = img.crop((x1, y1, x2, y2))
# Ресайз до квадрата
side = min(cropped.size[0], cropped.size[1])
left = (cropped.size[0] - side) // 2
top = (cropped.size[1] - side) // 2
cropped = cropped.crop((left, top, left + side, top + side))
preview = cropped.resize((400, 400), Image.Resampling.LANCZOS)
preview_path = f"/tmp/prev_{user_login}.jpg"
preview.save(preview_path, format="JPEG", quality=85)
preview_doc = FSInputFile(preview_path, filename="preview.jpg")
await msg.answer_photo(preview_doc, preview_doc)
except Exception as lib_err:
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Генерация превью", str(lib_err))
finally:
if os.path.exists(preview_path):
os.remove(preview_path)
raise Exception("Ошибка генерации превью")
# Сохраняем оригинал и координаты
original_path = f"/tmp/orig_{user_login}.jpg"
with open(original_path, 'wb') as f:
f.write(raw_bytes)
pending_photos[user_id] = {
'path': original_path,
'coords': coords,
'preview_path': preview_path
}
log_menu_stats(user_id, "Photo Bot", "Загрузка и обработка нового фото")
await asyncio.sleep(0.5)
if in_test_submode(user_id):
await msg.answer(
PHOTO_TEST_PREVIEW_TEXT,
parse_mode="html"
)
else:
await msg.answer(
photo_preview_ready(),
parse_mode="html"
)
else:
error_text = result.get('error', 'Не удалось обработать фото.')
await msg.answer(
photo_error_processing(error_text),
parse_mode="html"
)
except Exception as e:
logger.error(f"🔥 Полная ошибка: {e}", exc_info=True)
await handle_photo_error(msg, user_id, "Скачивание и обработка файла", str(e))
finally:
if raw_path and os.path.exists(raw_path):
try:
os.remove(raw_path)
except Exception as cleanup_err:
await asyncio.to_thread(log_and_notify_photo_error, user_id, "Очистка временных файлов", str(cleanup_err))
return
+217 -88
View File
@@ -1,84 +1,65 @@
import io
import cv2
import os
import logging
import numpy as np
from PIL import Image, ImageFilter, ImageEnhance
import requests
from PIL import Image
logger = logging.getLogger(__name__)
def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -> dict:
# Настройки API
API_URL = "https://profile.sibcem.ru:8085/SibCemProfile/api/photo-bot/upload/"
API_KEY = "sibcem_bot_2026_a7f3k9m2x8q1w5e4r6t"
def _image_to_cv2(image_bytes: bytes):
"""Конвертирует байты изображения в OpenCV матрицу (поддержка webp)."""
# Сначала пробуем cv2.imdecode
nparr = np.frombuffer(image_bytes, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is not None:
return img
# Если не сработало, пробуем через PIL (для webp и других форматов)
try:
# 1. Загружаем изображение для OpenCV
if isinstance(input_path, bytes):
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
else:
img = cv2.imread(input_path)
pil_img = Image.open(io.BytesIO(image_bytes))
if pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
return img
except Exception as e:
logger.error(f"Не удалось открыть изображение: {e}")
return None
def get_crop_coordinates(image_bytes: bytes) -> tuple | None:
"""
Ищет лицо на фото и возвращает координаты квадратного кропа.
Returns:
(crop_x1, crop_y1, crop_x2, crop_y2) или None если лицо не найдено.
"""
try:
img = _image_to_cv2(image_bytes)
if img is None:
return {"success": False, "error": "Не удалось прочитать файл изображения."}
if isinstance(input_path, bytes):
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR)
else:
img = cv2.imread(input_path)
if img is None:
return {"success": False, "error": "Не удалось прочитать файл изображения."}
logger.warning("Не удалось декодировать изображение")
return None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 2. Загружаем каскад Хаара для поиска лица
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
face_cascade = cv2.CascadeClassifier(cascade_path)
# 3. Ищем лица
# Оставляем оптимальное значение minNeighbors=8 для баланса
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50))
if len(faces) == 0:
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
logger.warning("Лицо не найдено на фото")
return None
if len(faces) > 1:
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
x, y, w, h = faces[0]
# =========================================================
# АБСОЛЮТНАЯ ЗАЩИТА ОТ ГРАФИКИ (НОРМАЛИЗОВАННАЯ МАТЕМАТИКА)
# =========================================================
# 1. Вырезаем центральную зону лица
face_roi_original = gray[y + int(h*0.2):y + int(h*0.8), x + int(w*0.2):x + int(w*0.8)]
# 2. Сжимаем до 100x100 пикселей.
# Это уравнивает 4K-фотографии и мелкие картинки, делая метрики стабильными.
face_roi = cv2.resize(face_roi_original, (100, 100))
std_dev = np.std(face_roi)
laplacian_var = cv2.Laplacian(face_roi, cv2.CV_64F).var()
unique_colors = len(np.unique(face_roi))
logger.info(f"Анализ нормализованного лица: std_dev={std_dev:.2f}, laplacian={laplacian_var:.2f}, colors={unique_colors}")
# ПРОВЕРКА 1: Плоские, замыленные картинки
if std_dev < 10.0 or laplacian_var < 30.0:
logger.warning(f"Заблокирован плоский рисунок/замыливание: std={std_dev:.1f}, lap={laplacian_var:.1f}")
return {"success": False, "error": "Изображение слишком размыто или имеет плоскую текстуру. Отправьте реальное фото."}
# ПРОВЕРКА 2: Векторы, комиксы и жесткий 2D-арт (Умный порог резкости + цвета)
# У реального человека на хорошую камеру laplacian может доходить до 500-600,
# но при этом всегда будет богатая палитра цветов (шум матрицы).
# У мультиков высокая резкость всегда сопровождается бедной палитрой.
if laplacian_var > 850.0 or (laplacian_var > 400.0 and unique_colors < 150):
logger.warning(f"Заблокирован резкий 2D-арт: lap={laplacian_var:.1f}, colors={unique_colors}")
return {"success": False, "error": "Изображение похоже на графический арт или сильно отфильтровано. Пожалуйста, загрузите естественное фото."}
# ПРОВЕРКА 3: 3D-рендеры и нейросети (Мало шума)
# На реальной фотографии (даже с хорошим светом) шум матрицы телефона
# даст минимум 110-120 уникальных оттенков на 10000 пикселей.
if unique_colors < 105:
logger.warning(f"Заблокирован 3D-рендер по палитре: unique_colors={unique_colors}")
return {"success": False, "error": "Изображение распознано как 3D-модель или рисунок (неестественная палитра). Ждем реальное фото!"}
# =========================================================
# 4. Кадрируем (делаем квадрат с отступами вокруг лица)
margin = int(w * 0.3)
y1 = max(0, y - int(margin * 1.5))
y2 = min(img.shape[0], y + h + margin)
@@ -92,36 +73,184 @@ def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -
crop_x2 = crop_x1 + side
crop_y2 = crop_y1 + side
# 5. Обработка через Pillow
pil_img = Image.open(io.BytesIO(input_path))
# ВАЖНО: Убираем альфа-канал (прозрачность), иначе BMP выдаст ошибку
if pil_img.mode in ('RGBA', 'P', 'LA'):
background = Image.new('RGB', pil_img.size, (255, 255, 255))
background.paste(pil_img, mask=pil_img.split()[3] if pil_img.mode == 'RGBA' else None)
pil_img = background
elif pil_img.mode != 'RGB':
pil_img = pil_img.convert('RGB')
cropped_img = pil_img.crop((crop_x1, crop_y1, crop_x2, crop_y2))
# Сжимаем до нужного размера (96x96)
final_img = cropped_img.resize((target_size, target_size), Image.Resampling.LANCZOS)
# Добавляем немного резкости, чтобы лицо не мылилось при 96px
final_img = final_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=100, threshold=3))
# 6. Сохраняем в формате BMP
final_img.save(output_path, format="BMP")
# 7. Проверяем финальный вес
size_kb = os.path.getsize(output_path) / 1024
if size_kb > 99:
return {"success": False, "error": f"Сбой: размер файла ({size_kb:.1f} KB) превысил лимит в 99 KB."}
return {"success": True, "size_kb": round(size_kb, 1)}
logger.info(f"Лицо найдено: ({x},{y},{w},{h}), кроп: ({crop_x1},{crop_y1},{crop_x2},{crop_y2})")
return (crop_x1, crop_y1, crop_x2, crop_y2)
except Exception as e:
logger.error(f"Ошибка в photo_processor: {e}")
logger.error(f"Ошибка в get_crop_coordinates: {e}", exc_info=True)
return None
def _check_moderation_status(user_id: str) -> dict:
"""
Проверяет текущий статус модерации пользователя.
Возвращает {'status': 'ok', 'moderation_status': 'pending'|'published'|'none', ...}
"""
# Извлекаем логин из email (user_id@tcs.sibcem.ru -> user_id)
login = user_id.split('@')[0]
status_url = API_URL.replace("/upload/", "/status/")
try:
response = requests.get(
status_url,
params={"user_id": login},
headers={"X-API-Key": API_KEY},
timeout=30,
verify=False
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Ошибка проверки статуса модерации: {e}")
return {"status": "error", "reason": f"Ошибка проверки статуса: {str(e)}"}
def _upload_to_api(image_bytes: bytes, user_id: str, coords: tuple) -> dict:
"""
Отправляет фото и координаты в Django API.
Возвращает JSON от сервера.
"""
x1, y1, x2, y2 = coords
try:
response = requests.post(
API_URL,
headers={"X-API-Key": API_KEY},
data={
"user_id": user_id,
"crop_x1": x1,
"crop_y1": y1,
"crop_x2": x2,
"crop_y2": y2,
},
files={
"image": ("photo.jpg", image_bytes, "image/jpeg")
},
timeout=300,
verify=False
)
response.raise_for_status()
# Логгируем ответ для отладки
logger.info(f"API response status: {response.status_code}, body[:500]: {response.text[:500]}")
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка API: {e}")
return {"status": "error", "reason": f"Ошибка сети: {str(e)}"}
except Exception as e:
logger.error(f"Ошибка отправки в API: {e}")
return {"status": "error", "reason": f"Ошибка: {str(e)}"}
def prepare_ad_photo(input_path: str, user_id: str, output_path: str = None, target_size: int = 96) -> dict:
"""
Отправляет фото на сервер через API.
output_path и target_size больше не используются (оставлены для обратной совместимости).
"""
try:
# Проверяем статус модерации перед отправкой
status = _check_moderation_status(user_id)
if status.get("status") == "ok" and status.get("moderation_status") == "pending":
photo_id = status.get("photo_id", "???")
return {
"success": False,
"error": f"У вас уже есть фото на модерации (ID: {photo_id}). Дождитесь решения модератора."
}
# 1. Читаем байты
if isinstance(input_path, bytes):
image_bytes = input_path
else:
with open(input_path, 'rb') as f:
image_bytes = f.read()
# 2. Ищем лицо и получаем координаты
coords = get_crop_coordinates(image_bytes)
if not coords:
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
# 3. Отправляем в API
api_result = _upload_to_api(image_bytes, user_id, coords)
# 4. Возвращаем результат
if api_result.get("status") == "ok":
return {
"success": True,
"photo_id": api_result.get("photo_id"),
"verdict": api_result.get("verdict"),
"message": api_result.get("message"),
}
else:
return {
"success": False,
"error": api_result.get("reason", "Неизвестная ошибка API")
}
except Exception as e:
logger.error(f"Ошибка в prepare_ad_photo: {e}", exc_info=True)
return {"success": False, "error": str(e)}
def make_face_crop_bytes(image_bytes: bytes, coords: tuple) -> bytes:
"""
Вырезает квадратный кадр по центру лица (тот же регион, что превью
бота и публикуемый на сайте кроп) и возвращает JPEG-байты.
На проверку уходит именно этот кроп: правило «голова ≥ 30%» и все
дальнейшие проверки имеют смысл для публикуемого результата, а не
для произвольно снятого оригинала.
Returns:
JPEG-байты квадрата (полное разрешение, без ресайза).
"""
from PIL import Image
import io
img = Image.open(io.BytesIO(image_bytes))
if img.mode != 'RGB':
img = img.convert('RGB')
x1, y1, x2, y2 = coords
cropped = img.crop((x1, y1, x2, y2))
# Квадрат из центра (как превью)
side = min(cropped.size[0], cropped.size[1])
left = (cropped.size[0] - side) // 2
top = (cropped.size[1] - side) // 2
cropped = cropped.crop((left, top, left + side, top + side))
buf = io.BytesIO()
cropped.save(buf, format="JPEG", quality=90)
return buf.getvalue()
def check_photo_api(image_bytes: bytes, timeout: float = 300.0) -> dict:
"""
Dry-run проверка фото через API сайта (POST /api/photo-bot/check/).
image_bytes — АВТО-КРОП ЛИЦА от бота (make_face_crop_bytes), тот же
кадр, что и превью/публикация. Фото проходит полный пайплайн проверки
на сайте (MediaPipe CV + ARKit-формы + VLM ИИ), НО ни в БД, ни в AD,
ни в Orion не попадает — ответ возвращается боту для тест-под-режима.
Returns:
{"status": "ok", "verdict", "ai_reason", "cv_approved", "cv_reason",
"raw_answer", "forced_moderation", "timings", "ai_metrics"}
или {"status": "error", "reason"}
"""
url = API_URL.replace("/upload/", "/check/")
try:
response = requests.post(
url,
headers={"X-API-Key": API_KEY},
files={"image": ("photo.jpg", image_bytes, "image/jpeg")},
timeout=timeout,
verify=False
)
response.raise_for_status()
logger.info(f"API /check/ response: status={response.status_code}, body[:500]: {response.text[:500]}")
return response.json()
except requests.exceptions.RequestException as e:
logger.error(f"Ошибка /check/ API: {e}")
return {"status": "error", "reason": f"Ошибка сети при обращении к сайту: {e}"}
except Exception as e:
logger.error(f"Ошибка /check/: {e}")
return {"status": "error", "reason": f"Ошибка сайта проверки: {e}"}
Submodule
+1
Submodule sd_dispatcher added at b7b099cb38
-289
View File
@@ -1,289 +0,0 @@
#!/usr/bin/env python3
"""
Универсальный скрипт для улучшения MD-файлов документов АО «ХК «Сибцем».
Применяет паттерны:
1. Удаляет футеры страниц
2. Форматирует блок "УТВЕРЖДАЮ"
3. Выравнивает иерархию заголовков
4. Объединяет таблицы терминов
5. Добавляет разделители между разделами
6. Приводит таблицы к единому виду
Использование:
python3 optimize_document.py <путь_к_оригиналу_на_сервере>
Пример:
python3 optimize_document.py "/opt/documents_xk/АОХКСибцем/07 Служба.../08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.md"
"""
import subprocess
import re
import sys
import time
import requests
import urllib.parse
# Конфигурация
SSH_KEY = "/home/hermes/.ssh/192.168.1.106"
SSH_USER = "administrator@192.168.1.106"
SERVER_BASE = "/opt/documents_xk/АОХКСибцем"
FILEBROWSER_URL = "https://smb.dddennnisss.ru"
FILEBROWSER_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjoyMzAyNzM1NzA3LCJpYXQiOjE3ODQzMzU3MDcsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOmZhbHNlLCJkZWxldGUiOnRydWUsImNyZWF0ZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9fQ.QBKlh3NQJeh4aeMiKLkhg0tF8hxk-Oh2KLsXLSbuvm8"
SOURCE_NAME = "SSD-Storage"
def run_ssh(command):
"""Выполнить команду на сервере через SSH."""
result = subprocess.run(
["ssh", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no",
SSH_USER, command],
capture_output=True, text=True, timeout=30
)
return result
def scp_download(remote_path, local_path):
"""Скачать файл с сервера."""
result = subprocess.run(
["scp", "-i", SSH_KEY, "-o", "StrictHostKeyChecking=no",
f"{SSH_USER}:{remote_path}", local_path],
capture_output=True, text=True, timeout=60
)
return result.returncode == 0
def fix_headings(content):
"""Выровнять иерархию заголовков: все на уровень ##."""
content = re.sub(r'^(#+)\s+(.+)$', r'## \2', content, flags=re.MULTILINE)
return content
def add_dividers(content):
"""Добавить разделители между основными разделами."""
# Разделитель перед Оглавлением
content = re.sub(
r'(\n)(## )(Оглавление)',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед разделами 1-9
content = re.sub(
r'(\n)(## )(\s*(?:1\.|2\.|3\.|4\.|5\.|6\.|7\.|8\.|9\.))',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед Приложениями
content = re.sub(
r'(\n)(## )(Приложение)',
r'\1\n---\n\1\2\3',
content
)
# Разделитель перед Листом согласований
content = re.sub(
r'(\n)(## )(Лист согласований)',
r'\1\n---\n\1\2\3',
content
)
return content
def fix_tables(content):
"""Привести все таблицы к единому виду с thead/tbody."""
def fix_table(table):
if '<thead>' in table and '<tbody>' in table:
return table
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
if not rows:
return table
header = '<thead><tr>' + rows[0] + '</tr></thead><tbody>'
body = ''
for row in rows[1:]:
body += '<tr>' + row + '</tr>'
body += '</tbody>'
return '<table>' + header + body + '</table>'
return re.sub(r'<table>.*?</table>', lambda m: fix_table(m.group(0)), content, flags=re.DOTALL)
def remove_footers(content):
"""Удалить футеры страниц."""
# Удалить "АО «ХК «Сибцем»\nТип документа: ...\nНаименование документа: ..."
content = re.sub(
r'АО «ХК «Сибцем»\nТип документа:.*?\nНаименование документа:.*?\n\n',
'',
content,
flags=re.DOTALL
)
# Удалить "Стр. X из Y"
content = re.sub(r'Стр\. \d+ из \d+\n\n', '', content)
# Удалить "Дата утверждения: DD.MM.YYYY"
content = re.sub(r'Дата утверждения: \d{2}\.\d{2}\.\d{4}\n\n', '', content)
# Удалить "Ведущее подразделение: ..."
content = re.sub(r'Ведущее подразделение:.*?\n\n', '', content)
return content
def fix_approval_block(content):
"""Форматировать блок УТВЕРЖДАЮ."""
# Паттерн 1: "УТВЕРЖДАЮ\nПрезидент\n\nО.В. Шарыкин\n\n«XX» месяц год г."
approval_patterns = [
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
]
for pattern in approval_patterns:
match = re.search(pattern, content)
if match:
old_text = match.group(0)
new_text = f'''<div align="right">
<strong>УТВЕРЖДАУ</strong><br>
Президент<br>
О.В. Шарыкин<br>
{old_text.split("«")[1].split("г.")[0]} г.
</div>'''
content = content.replace(old_text, new_text)
break
return content
def merge_terms_tables(content):
"""Объединить разбитые таблицы терминов."""
# Поиск таблиц терминов
terms_pattern = r'(<table>.*?Термины.*?</table>)'
matches = re.findall(terms_pattern, content, re.DOTALL)
if len(matches) > 1:
# Объединяем все таблицы терминов в одну
merged = '<table><thead><tr><th>Термин</th><th>Определение</th></tr></thead><tbody>'
for table in matches:
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
for row in rows[1:]: # Пропускаем заголовок
merged += row
merged += '</tbody></table>'
content = content.replace(matches[0], merged)
return content
def optimize_document(md_path):
"""Основная функция оптимизации документа."""
start_time = time.time()
print(f"📄 Обработка документа: {md_path}")
print("=" * 60)
# Шаг 1: Скачать MD с сервера
print("\n📥 Шаг 1: Скачивание MD...")
result = run_ssh(f"cat '{md_path}'")
if result.returncode != 0:
print(f"✗ Ошибка при скачивании: {result.stderr}")
return
md_content = result.stdout
print(f"✓ MD скачан: {len(md_content)} символов")
# Шаг 2: Применить паттерны
print("\n🔧 Шаг 2: Применение паттернов...")
# 2.1 Удалить футеры
content = remove_footers(md_content)
print(" ✓ Футеры удалены")
# 2.2 Выровнять иерархию заголовков
content = fix_headings(content)
print(" ✓ Иерархия заголовков выровнена")
# 2.3 Добавить разделители
content = add_dividers(content)
print(" ✓ Разделители добавлены")
# 2.4 Привести таблицы к единому виду
content = fix_tables(content)
print(" ✓ Таблицы приведены к единому виду")
# 2.5 Объединить таблицы терминов
content = merge_terms_tables(content)
print(" ✓ Таблицы терминов объединены")
# 2.6 Форматировать блок УТВЕРЖДАЮ
content = fix_approval_block(content)
print(" ✓ Блок УТВЕРЖДАЮ отформатирован")
# Сохранить улучшенную версию
improved_path = "/tmp/improved_document.md"
with open(improved_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"\n📊 Результаты:")
print(f" Исходный размер: {len(md_content)} символов")
print(f" Улучшенный размер: {len(content)} символов")
# Показать структуру
sections = re.findall(r'^(##)\s+(.+)$', content, re.MULTILINE)
print(f" Разделов: {len(sections)}")
tables = re.findall(r'<table>', content)
print(f" Таблиц: {len(tables)}")
# Показать первые 300 символов
print(f"\n📝 Начало файла:")
print(content[:300].replace('\n', '\\n'))
# Загрузить в filebrowser
print("\n📤 Шаг 3: Загрузка в filebrowser...")
upload_to_filebrowser(content, md_path)
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Готово! Время выполнения: {elapsed:.1f} сек")
print(f"{'=' * 60}")
def upload_to_filebrowser(content, md_path):
"""Загрузить файл в filebrowser."""
# Извлечь имя файла из пути
filename = md_path.split('/')[-1]
# Добавить "_improved" перед расширением
if filename.endswith('.md'):
improved_filename = filename[:-3] + '_improved.md'
else:
improved_filename = filename + '_improved'
session = requests.Session()
session.cookies.set("filebrowser_quantum_jwt", FILEBROWSER_TOKEN, domain="smb.dddennnisss.ru")
url = f"{FILEBROWSER_URL}/api/resources"
params = {"path": improved_filename, "source": SOURCE_NAME}
r = session.put(url, params=params, data=content)
if r.status_code == 200:
print(f"✓ Файл загружен: {improved_filename}")
# Проверить
r = session.get(f"{FILEBROWSER_URL}/api/resources", params={"path": "/", "source": SOURCE_NAME})
if r.status_code == 200:
data = r.json()
items = data.get("files", []) + data.get("folders", [])
for item in items:
if improved_filename in item["name"]:
print(f"✓ Размер: {item['size']} байт")
break
else:
print(f"✗ Ошибка загрузки: {r.status_code} - {r.text[:200]}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование:")
print(" python3 optimize_document.py <путь_к_оригиналу_на_сервере>")
print("\nПример:")
print(" python3 optimize_document.py \"/opt/documents_xk/АОХКСибцем/07 Служба.../08 Положение Управление ИТ-инцидентами от 20.02.2024 № ПОЛ-123-2.md\"")
sys.exit(1)
md_path = sys.argv[1]
optimize_document(md_path)
-298
View File
@@ -1,298 +0,0 @@
#!/usr/bin/env python3
"""
Универсальный скрипт для улучшения MD-файлов документов АО «ХК «Сибцем».
Выполняется на сервере 192.168.1.106.
Применяет паттерны:
1. Удаляет футеры страниц
2. Форматирует блок "УТВЕРЖДАЮ"
3. Выравнивает иерархию заголовков
4. Объединяет таблицы терминов
5. Добавляет разделители между разделами
6. Приводит таблицы к единому виду
Использование:
python3 /opt/trueconf_bot/search_bot/optimize_documents.py <путь_к_каталогу>
Пример:
python3 /opt/trueconf_bot/search_bot/optimize_documents.py "/opt/documents_xk/ready_md/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/"
"""
import os
import re
import sys
import time
import requests
import base64
# Конфигурация
FILEBROWSER_URL = "https://smb.dddennnisss.ru"
FILEBROWSER_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGaWxlQnJvd3NlciBRdWFudHVtIiwiZXhwIjoyMzAyNzM1NzA3LCJpYXQiOjE3ODQzMzU3MDcsImJlbG9uZ3NUbyI6MSwiUGVybWlzc2lvbnMiOnsiYXBpIjp0cnVlLCJhZG1pbiI6dHJ1ZSwibW9kaWZ5Ijp0cnVlLCJzaGFyZSI6dHJ1ZSwicmVhbHRpbWUiOmZhbHNlLCJkZWxldGUiOnRydWUsImNyZWF0ZSI6dHJ1ZSwiZG93bmxvYWQiOnRydWV9fQ.QBKlh3NQJeh4aeMiKLkhg0tF8hxk-Oh2KLsXLSbuvm8"
SOURCE_NAME = "SSD-Storage"
def remove_footers(content):
"""Удалить футеры страниц."""
# Удалить "АО «ХК «Сибцем»\nТип документа: ...\nНаименование документа: ..."
content = re.sub(
r'АО «ХК «Сибцем»\nТип документа:.*?\nНаименование документа:.*?\n\n',
'',
content,
flags=re.DOTALL
)
# Удалить "Стр. X из Y"
content = re.sub(r'Стр\. \d+ из \d+\n\n', '', content)
# Удалить "Дата утверждения: DD.MM.YYYY"
content = re.sub(r'Дата утверждения: \d{2}\.\d{2}\.\d{4}\n\n', '', content)
# Удалить "Ведущее подразделение: ..."
content = re.sub(r'Ведущее подразделение:.*?\n\n', '', content)
return content
def fix_headings(content):
"""Выровнять иерархию заголовков: все на уровень ##."""
content = re.sub(r'^(#+)\s+(.+)$', r'## \2', content, flags=re.MULTILINE)
return content
def add_dividers(content):
"""Добавить разделители между основными разделами."""
# Разделитель перед Оглавлением
content = re.sub(
r'(\n)(## )(Оглавление)',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед разделами 1-9
content = re.sub(
r'(\n)(## )(\s*(?:1\.|2\.|3\.|4\.|5\.|6\.|7\.|8\.|9\.))',
r'\1\n---\n\1\2\3',
content
)
# Разделители перед Приложениями
content = re.sub(
r'(\n)(## )(Приложение)',
r'\1\n---\n\1\2\3',
content
)
# Разделитель перед Листом согласований
content = re.sub(
r'(\n)(## )(Лист согласований)',
r'\1\n---\n\1\2\3',
content
)
return content
def fix_tables(content):
"""Привести все таблицы к единому виду с thead/tbody."""
def fix_table(table):
if '<thead>' in table and '<tbody>' in table:
return table
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
if not rows:
return table
header = '<thead><tr>' + rows[0] + '</tr></thead><tbody>'
body = ''
for row in rows[1:]:
body += '<tr>' + row + '</tr>'
body += '</tbody>'
return '<table>' + header + body + '</table>'
return re.sub(r'<table>.*?</table>', lambda m: fix_table(m.group(0)), content, flags=re.DOTALL)
def merge_terms_tables(content):
"""Объединить разбитые таблицы терминов."""
# Поиск таблиц терминов
terms_pattern = r'(<table>.*?Термины.*?</table>)'
matches = re.findall(terms_pattern, content, re.DOTALL)
if len(matches) > 1:
# Объединяем все таблицы терминов в одну
merged = '<table><thead><tr><th>Термин</th><th>Определение</th></tr></thead><tbody>'
for table in matches:
rows = re.findall(r'<tr>(.*?)</tr>', table, re.DOTALL)
for row in rows[1:]: # Пропускаем заголовок
merged += row
merged += '</tbody></table>'
content = content.replace(matches[0], merged)
return content
def fix_approval_block(content):
"""Форматировать блок УТВЕРЖДАЮ."""
# Паттерн 1: "УТВЕРЖДАЮ\nПрезидент\n\nО.В. Шарыкин\n\n«XX» месяц год г."
approval_patterns = [
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
r'(УТВЕРЖДАУ\nПрезидент\n\nО\.В\. Шарыкин\n\\d+» \w+ \d{4} г\.)',
]
for pattern in approval_patterns:
match = re.search(pattern, content)
if match:
old_text = match.group(0)
new_text = f'''<div align="right">
<strong>УТВЕРЖДАУ</strong><br>
Президент<br>
О.В. Шарыкин<br>
{old_text.split("«")[1].split("г.")[0]} г.
</div>'''
content = content.replace(old_text, new_text)
break
return content
def upload_to_filebrowser(content, filename):
"""Загрузить файл в filebrowser."""
# Добавить "_improved" перед расширением
if filename.endswith('.md'):
improved_filename = filename[:-3] + '_improved.md'
else:
improved_filename = filename + '_improved'
session = requests.Session()
session.cookies.set("filebrowser_quantum_jwt", FILEBROWSER_TOKEN, domain="smb.dddennnisss.ru")
url = f"{FILEBROWSER_URL}/api/resources"
params = {"path": improved_filename, "source": SOURCE_NAME}
r = session.put(url, params=params, data=content)
if r.status_code == 200:
print(f"✓ Файл загружен: {improved_filename}")
# Проверить
r = session.get(f"{FILEBROWSER_URL}/api/resources", params={"path": "/", "source": SOURCE_NAME})
if r.status_code == 200:
data = r.json()
items = data.get("files", []) + data.get("folders", [])
for item in items:
if improved_filename in item["name"]:
print(f"✓ Размер: {item['size']} байт")
break
else:
print(f"✗ Ошибка загрузки: {r.status_code} - {r.text[:200]}")
return improved_filename
def optimize_document(md_path):
"""Основная функция оптимизации документа."""
start_time = time.time()
print(f"\n📄 Обработка документа: {md_path.split('/')[-1]}")
print("=" * 60)
# Шаг 1: Читать MD файл
print("\n📥 Шаг 1: Чтение MD...")
try:
with open(md_path, 'r', encoding='utf-8') as f:
md_content = f.read()
except Exception as e:
print(f"✗ Ошибка при чтении: {e}")
return
print(f"✓ MD прочитан: {len(md_content)} символов")
# Шаг 2: Применить паттерны
print("\n🔧 Шаг 2: Применение паттернов...")
# 2.1 Удалить футеры
content = remove_footers(md_content)
print(" ✓ Футеры удалены")
# 2.2 Выровнять иерархию заголовков
content = fix_headings(content)
print(" ✓ Иерархия заголовков выровнена")
# 2.3 Добавить разделители
content = add_dividers(content)
print(" ✓ Разделители добавлены")
# 2.4 Привести таблицы к единому виду
content = fix_tables(content)
print(" ✓ Таблицы приведены к единому виду")
# 2.5 Объединить таблицы терминов
content = merge_terms_tables(content)
print(" ✓ Таблицы терминов объединены")
# 2.6 Форматировать блок УТВЕРЖДАЮ
content = fix_approval_block(content)
print(" ✓ Блок УТВЕРЖДАУ отформатирован")
# Сохранить улучшенную версию
improved_path = md_path.replace('.md', '_improved.md')
with open(improved_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"\n📊 Результаты:")
print(f" Исходный размер: {len(md_content)} символов")
print(f" Улучшенный размер: {len(content)} символов")
# Показать структуру
sections = re.findall(r'^(##)\s+(.+)$', content, re.MULTILINE)
print(f" Разделов: {len(sections)}")
tables = re.findall(r'<table>', content)
print(f" Таблиц: {len(tables)}")
# Показать первые 300 символов
print(f"\n📝 Начало файла:")
print(content[:300].replace('\n', '\\n'))
# Загрузить в filebrowser
print("\n📤 Шаг 3: Загрузка в filebrowser...")
filename = md_path.split('/')[-1]
upload_to_filebrowser(content, filename)
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Готово! Время выполнения: {elapsed:.1f} сек")
print(f"{'=' * 60}")
def process_directory(directory):
"""Обработать все MD файлы в каталоге."""
start_time = time.time()
print(f"📂 Обработка каталога: {directory}")
print("=" * 60)
# Найти все MD файлы
md_files = []
for root, dirs, files in os.walk(directory):
for file in files:
if file.endswith('.md'):
md_files.append(os.path.join(root, file))
print(f"Найдено {len(md_files)} MD файлов")
# Обработать каждый файл
for md_path in md_files:
try:
optimize_document(md_path)
except Exception as e:
print(f"✗ Ошибка при обработке {md_path}: {e}")
elapsed = time.time() - start_time
print(f"\n{'=' * 60}")
print(f"✅ Обработка каталога завершена! Общее время: {elapsed:.1f} сек")
print(f"{'=' * 60}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Использование:")
print(" python3 optimize_documents.py <путь_к_каталогу>")
print("\nПример:")
print(' python3 optimize_documents.py "/opt/documents_xk/ready_md/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/"')
sys.exit(1)
directory = sys.argv[1]
process_directory(directory)
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""
Комбинированный парсинг ПОЛ-177:
- Текст: Unstructured API (192.168.1.103:8005)
- Таблицы: pypdf (локально)
Сохраняет результат в /opt/okf-regulations/concepts/pol177_parsed.json
"""
import json
import subprocess
from pathlib import Path
# === НАСТРОЙКИ ===
UNSTRUCTURED_API = "http://192.168.1.103:8005/general/v0/general"
PDF_PATH = "/opt/documents_xk/АОХКСибцем/07 Служба Вице-президента по экономике и финансам/ДИТ/02 Регламенты деятельности/10 Положение Хранение электронных документов от 03.04.2024 № ПОЛ-177.pdf"
OUTPUT_PATH = "/opt/okf-regulations/concepts/pol177_parsed.json"
def fetch_unstructured(pdf_path: str) -> list[dict]:
"""Получаем элементы текста из Unstructured API (103)."""
cmd = [
"curl", "-X", "POST", UNSTRUCTURED_API,
"-F", f"files=@{pdf_path}",
"-F", "strategy=fast",
"-F", "output_format=json",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ Unstructured API error: {result.stderr}")
return []
return json.loads(result.stdout)
def extract_tables_pypdf(pdf_path: str) -> list[dict]:
"""Извлекаем таблицы через pypdf (локально на 106)."""
try:
from pypdf import PdfReader
except ImportError:
import subprocess
subprocess.run(["pip3", "install", "pypdf", "--user"], check=True)
from pypdf import PdfReader
reader = PdfReader(pdf_path)
tables = []
for i, page in enumerate(reader.pages):
text = page.extract_text()
if text and ("таблица" in text.lower() or "стр" in text.lower()):
tables.append({"page": i + 1, "text": text})
return tables
def main():
print("=" * 60)
print("📄 Парсинг ПОЛ-177")
print("=" * 60)
# 1. Текст через Unstructured API
print("\n[1/2] Запрос текста через Unstructured API (103)...")
elements = fetch_unstructured(PDF_PATH)
print(f" ✅ Получено {len(elements)} элементов")
# 2. Таблицы через pypdf
print("\n[2/2] Извлечение таблиц через pypdf (106)...")
tables = extract_tables_pypdf(PDF_PATH)
print(f" ✅ Найдено {len(tables)} страниц с текстом")
# 3. Сохраняем комбинированный результат
result = {
"document": "ПОЛ-177",
"title": "Положение Хранение электронных документов от 03.04.2024 № ПОЛ-177",
"unstructured": elements,
"tables": tables,
"metadata": {
"unstructured_api": UNSTRUCTURED_API,
"strategy": "fast",
"timestamp": "2026-07-06",
},
}
Path(OUTPUT_PATH).write_text(json.dumps(result, ensure_ascii=False, indent=2))
print(f"\n✅ Сохранено в {OUTPUT_PATH}")
print("=" * 60)
if __name__ == "__main__":
main()
Binary file not shown.
+73 -126
View File
@@ -44,9 +44,9 @@ from utils.texts import (
sd_uploading_file,
sd_file_attached,
sd_file_upload_error,
system_error_text,
UNKNOWN_MAIN_CMD_TEXT,
)
from utils.texts import UNKNOWN_MAIN_CMD_TEXT
# 🔌 Импортируем централизованную функцию сбора статистики из main
from utils.stats_logger import log_menu_stats
# Таймаут ожидания вложений после текста (секунды)
@@ -67,61 +67,15 @@ logger = logging.getLogger(__name__)
router = Router()
sd_sessions = {}
async def get_sd_templates():
url = f"{SD_URL}/api/v3/request_templates"
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
input_data = {"list_info": {"row_count": 100, "start_index": 1}}
params = {"input_data": json.dumps(input_data)}
try:
async with httpx.AsyncClient(verify=False) as client:
response = await client.get(url, headers=headers, params=params, timeout=30.0)
if response.status_code != 200:
sd_workflow_logger.error(f"Ошибка API шаблонов: HTTP {response.status_code} - {response.text}")
return []
data = response.json()
all_templates = data.get("request_templates", [])
pre_filtered = [
t for t in all_templates
if t.get("status") == "ACTIVE"
and not t.get("inactive", False)
and str(t.get("id")) != "2"
]
sd_workflow_logger.info(f"Шаблонов в системе: {len(all_templates)} | Активных: {len(pre_filtered)}")
async def check_template(t):
try:
t_resp = await client.get(url + "/" + str(t["id"]), headers=headers, timeout=15.0)
if t_resp.status_code == 200:
detailed = t_resp.json().get("request_template", {})
if detailed.get("show_to_requester") is True:
sc = detailed.get("service_category", {})
t["service_category"] = sc
t["request"] = detailed.get("request", {})
return t if detailed.get("show_to_requester") is True else None
except Exception as e:
sd_workflow_logger.warning(f"Ошибка деталей шаблона ID {t['id']}: {e}")
return None
tasks = [check_template(t) for t in pre_filtered]
results = await asyncio.gather(*tasks)
active_templates = [t for t in results if t is not None]
sd_workflow_logger.info(f"Шаблоны с show_to_requester=True: {len(active_templates)}")
for temp in active_templates:
sd_workflow_logger.info(f" ID: {temp['id']} | Name: {temp['name']}")
return active_templates
except Exception as e:
sd_workflow_logger.error(f"Ошибка загрузки шаблонов: {e}")
return []
# ================================================
# БИЗНЕС-ЛОГИКА
# ================================================
def get_ad_user_sync(login: str):
try:
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["displayName", "mail", "l", "userAccountControl"])
if conn.entries:
user = conn.entries[0]
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,
@@ -173,39 +127,31 @@ async def generate_smart_subject(text: str) -> str:
sd_workflow_logger.error(f"📝 [Subject Gen Error] Exception: {e}\n{traceback.format_exc()}")
return "Заявка из КЛЕВЕР"
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str, template_id: str | None = None, templates: list | None = None):
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json", "Content-Type": "application/json"}
async def create_ticket_in_sd(requester_email: str, subject: str, description: str, city: str):
headers = {"authtoken": SD_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
endpoint = f"{SD_URL}/api/v3/requests"
html_desc = f"<p>{description.replace(chr(10), '<br>')}</p><br><hr><p style='color:#555;font-size:12px;'><i>Создано через TrueConf КЛЕВЕР</i></p>"
async def _send_req(email, current_city, template_id, templates):
req_payload = {"request": {"subject": subject, "description": html_desc, "requester": {"name": email}}}
if template_id:
req_payload["request"]["template"] = {"id": template_id}
# Найти service_category по шаблону
for t in templates:
if str(t.get("id")) == str(template_id):
sc = t.get("service_category", {})
if sc:
req_payload["request"]["service_category"] = sc
break
if "udf_pick_301" in req_payload["request"]:
req_payload["request"]["udf_fields"] = {"udf_pick_301": current_city}
elif "udf_fields" in req_payload["request"] and current_city:
req_payload["request"]["udf_fields"]["udf_pick_301"] = current_city
else:
req_payload["request"]["udf_fields"] = {"udf_pick_301": current_city}
async 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:
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)
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)
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)
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
return None
async def process_and_upload_file(file_id: str, filename: str, ticket_id: str):
@@ -283,8 +229,7 @@ def _extract_attachments_from_content(content, is_attachment_type=False):
async def _create_ticket_and_attach_files(user_id, msg_text, session, msg, login):
"""Логика генерации темы, отправки в SD и загрузки всех очередей вложений."""
session["step"] = "creating_ticket"
template_id = session.get("template_id")
sd_workflow_logger.info(f"[Create] Template ID: {template_id}")
try:
ad_user = await asyncio.to_thread(get_ad_user_sync, login)
sd_workflow_logger.info(f"👤 [AD Lookup] User: {login} -> Found: {ad_user is not None}")
@@ -293,13 +238,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, template_id, session.get("templates", []))
ticket_id = await create_ticket_in_sd(sender_email, subject, msg_text, city)
sd_workflow_logger.info(f"🎫 [Ticket Created] ID: {ticket_id}")
if ticket_id:
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}")
@@ -307,7 +252,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}")
@@ -325,19 +270,20 @@ 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(sd_ticket_create_error(), parse_mode="html")
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
except Exception as e:
logger.exception(f"Ошибка: {e}")
sd_workflow_logger.error(f"❌ [SD Error] {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(sd_system_error(), parse_mode="html")
await msg.answer(system_error_text(EMOJI_DIGITS), 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
@@ -346,10 +292,32 @@ 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))
@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":
@@ -371,7 +339,7 @@ async def sd_module_handler(msg: Message):
cmd = raw_num
break
# --- ВЫХОД (Кнопки 0 и 9 возвращают в общее меню) ---
# --- ВЫХОД ---
if cmd in ["0", "9", "/start", "меню"]:
log_menu_stats(user_id, "Service Desk", "Выход в главное меню")
states.clear_state(user_id)
@@ -380,73 +348,41 @@ async def sd_module_handler(msg: Message):
return
if user_id not in sd_sessions:
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": [], "templates": [], "template_names": {}}
sd_sessions[user_id] = {"step": "need_text", "files_queue": [], "post_create_queue": []}
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
# Если только вложение без текста — сохраняем и ждем текст
if not msg_text.strip() and inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Saved {len(inline_attachments)} attachment(s), waiting for text")
await msg.answer(SD_TEXT_REQUIRED, parse_mode="html")
return
# Если пришел текст (с вложениями или без)
if inline_attachments:
session["files_queue"].extend(inline_attachments)
sd_workflow_logger.info(f"📎 [SD] Added {len(inline_attachments)} inline attachment(s) to queue")
# Настраиваем параметры сессии для ожидания
session["step"] = "waiting_for_attachments"
session["msg_text"] = msg_text
session["msg"] = msg
session["user_id"] = user_id
session["login"] = login
# Сразу выдаем ОДНО сообщение пользователю, чтобы он видел реакцию бота
await msg.answer(SD_CREATING, parse_mode="html")
# Запускаем фоновый таймер с механизмом сброса (Debounce)
if "timer_task" in session and not session["timer_task"].done():
session["timer_task"].cancel()
session["timer_task"] = asyncio.create_task(_wait_for_attachments_and_create(user_id, session))
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: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ (Сброс таймаута) ---
# --- ШАГ 2: РЕЖИМ ОЖИДАНИЯ ДОП. ВЛОЖЕНИЙ ---
if session["step"] == "waiting_for_attachments":
if inline_attachments:
session["files_queue"].extend(inline_attachments)
@@ -455,14 +391,14 @@ async def sd_module_handler(msg: Message):
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: ЗАЯВКА СОЗДАЕТСЯ (Перехват файлов из "черной дыры") ---
# --- ШАГ 3: ЗАЯВКА СОЗДАЕТСЯ ---
if session["step"] == "creating_ticket":
if inline_attachments:
if "post_create_queue" not in session:
@@ -483,7 +419,18 @@ async def sd_module_handler(msg: Message):
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")
_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
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
import asyncio
import httpx
import json
import urllib3
BASE_URL = "https://192.168.1.237:8080"
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
async def main():
url = f"{BASE_URL}/api/v3/requests"
headers = {
"authtoken": API_TOKEN,
"Accept": "application/vnd.manageengine.sdp.v3+json"
}
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
resp = await client.get(url, headers=headers, params={
"input_data": json.dumps({
"list_info": {"row_count": 100, "start_index": 1}
})
})
data = resp.json()
print(f"Status: {resp.status_code}")
print(f"Has more: {data.get('list_info', {}).get('has_more_rows')}")
requests = data.get("requests", [])
print(f"Total: {len(requests)}")
for r in requests:
print(f" ID={r.get('id')} subject={r.get('subject')} created={r.get('created_time')}")
if __name__ == "__main__":
urllib3.disable_warnings()
asyncio.run(main())
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Проверка формата даты и вариантов фильтрации."""
import asyncio
import httpx
import json
import urllib3
BASE_URL = "https://192.168.1.237:8080"
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
async def debug():
url = f"{BASE_URL}/api/v3/requests"
headers = {
"authtoken": API_TOKEN,
"Accept": "application/vnd.manageengine.sdp.v3+json"
}
# Вариант 1: без фильтра, посмотреть все поля
print("=== Все заявки (без фильтра) ===")
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
resp = await client.get(url, headers=headers, params={
"input_data": json.dumps({
"list_info": {"row_count": 100, "start_index": 1}
})
})
data = resp.json()
requests = data.get("requests", [])
for req in requests:
print(f"\n--- Заявка {req.get('request_id', '?')} ---")
print(f" subject: {req.get('subject')}")
print(f" created_time: {req.get('created_time')}")
print(f" created_time type: {type(req.get('created_time')).__name__}")
# Показать все ключи
print(f" Ключи: {list(req.keys())}")
if __name__ == "__main__":
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
asyncio.run(debug())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Поиск рабочего формата фильтрации."""
import asyncio
import httpx
import json
import urllib3
BASE_URL = "https://192.168.1.237:8080"
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
async def test_filters():
url = f"{BASE_URL}/api/v3/requests"
headers = {
"authtoken": API_TOKEN,
"Accept": "application/vnd.manageengine.sdp.v3+json"
}
# Сначала посмотрим все заявки с их ID и датами
print("=== Все заявки ===")
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
resp = await client.get(url, headers=headers, params={
"input_data": json.dumps({
"list_info": {"row_count": 100, "start_index": 1}
})
})
data = resp.json()
requests = data.get("requests", [])
for req in requests:
req_id = req.get("request_id", req.get("id", "?"))
subject = req.get("subject", "?")
created = req.get("created_time", {})
if isinstance(created, dict):
created_str = created.get("display_value", "?")
ts = created.get("value", "?")
else:
created_str = str(created)
ts = "?"
print(f" ID={req_id} created={created_str} ts={ts}")
# Попробуем разные форматы фильтрации
print("\n=== Попробуем фильтры ===")
filters = [
# Вариант 1: ISO строка
{
"search_criteria": {
"column_name": "created_time",
"condition": "between",
"values": ["Jul 1, 2026 00:00:00", "Jul 31, 2026 23:59:59"]
}
},
# Вариант 2: без search_criteria, с custom_field
{
"search_criteria": {
"column_name": "created_time",
"condition": "between",
"values": ["2026-07-01", "2026-07-31"]
}
},
# Вариант 3: timestamp как строка
{
"search_criteria": {
"column_name": "created_time",
"condition": "between",
"values": ["1782864000000", "1785542399000"]
}
},
]
for i, criteria in enumerate(filters):
params = {
"input_data": json.dumps({
"list_info": {"row_count": 100, "start_index": 1},
"search_criteria": criteria.get("search_criteria")
})
}
resp = await client.get(url, headers=headers, params=params)
data = resp.json()
reqs = data.get("requests", [])
print(f"\nФильтр {i+1}: {criteria['search_criteria']['values']} -> {len(reqs)} заявок")
for r in reqs:
print(f" {r.get('id')}: {r.get('subject')}")
if __name__ == "__main__":
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
asyncio.run(test_filters())
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Получение списка заявок из ServiceDesk Plus через API v3."""
import asyncio
import httpx
import json
import urllib3
BASE_URL = "https://192.168.1.237:8080"
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
async def list_tickets():
url = f"{BASE_URL}/api/v3/requests"
headers = {
"authtoken": API_TOKEN,
"Accept": "application/vnd.manageengine.sdp.v3+json"
}
params = {
"input_data": json.dumps({
"list_info": {
"row_count": 50,
"start_index": 1
}
})
}
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
resp = await client.get(url, headers=headers, params=params)
data = resp.json()
requests = data.get("requests", [])
print(f"Всего заявок: {len(requests)}\n")
for req in requests:
req_id = req.get("request_id", req.get("id", "?"))
subject = req.get("subject", req.get("short_description", "?"))
# Группа
group = req.get("group", {})
if isinstance(group, dict):
group_name = group.get("name", "?")
else:
group_name = str(group)
# Статус
status = req.get("status", {})
if isinstance(status, dict):
status_name = status.get("name", "?")
else:
status_name = str(status)
# Приоритет
priority = req.get("priority", {})
if isinstance(priority, dict):
priority_name = priority.get("name", "?")
else:
priority_name = str(priority)
# Описание (убираем HTML)
desc = req.get("description", "")
desc_clean = desc.replace("<p>", "").replace("</p>", "").replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
desc_clean = desc_clean[:300]
# Создатель
requester = req.get("requester", {})
req_name = requester.get("name", "?") if isinstance(requester, dict) else "?"
print(f"--- Заявка #{req_id} ---")
print(f" Тема: {subject}")
print(f" Группа: {group_name}")
print(f" Статус: {status_name}")
print(f" Приоритет: {priority_name}")
print(f" Автор: {req_name}")
print(f" Описание:\n {desc_clean}")
print()
if __name__ == "__main__":
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
asyncio.run(list_tickets())
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Дебаг запроса к ServiceDesk Plus API v3."""
import asyncio
import httpx
import json
import urllib3
BASE_URL = "https://192.168.1.237:8080"
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
async def debug():
url = f"{BASE_URL}/api/v3/requests"
headers = {
"authtoken": API_TOKEN,
"Accept": "application/vnd.manageengine.sdp.v3+json"
}
# Разные варианты формата
variants = [
# Вариант 1: input_data как JSON
{
"input_data": json.dumps({
"list_info": {
"row_count": 5,
"start_index": 1
}
})
},
# Вариант 2: без input_data, просто параметры
None,
# Вариант 3: input_data без json.dumps
{
"input_data": {
"list_info": {
"row_count": 5,
"start_index": 1
}
}
}
]
for i, params in enumerate(variants):
print(f"\n=== Вариант {i+1} ===")
print(f"Params: {params}")
async with httpx.AsyncClient(verify=False, timeout=15.0) as client:
try:
resp = await client.get(url, headers=headers, params=params)
print(f"HTTP {resp.status_code}")
print(f"Response: {resp.text[:500]}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
asyncio.run(debug())
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Получение списка заявок из ServiceDesk Plus с 20 июля 2026. Экспорт: Группа, Описание, Назначено, Категория в JSONL."""
import asyncio
import httpx
import json
import urllib3
from datetime import datetime, timezone
from html import unescape
import re
BASE_URL = "https://192.168.1.237:8080"
API_TOKEN = "062CE056-C1F5-490C-A6DF-FD2C653A71DD"
FROM_DATE_MS = int(datetime(2026, 7, 20, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1000)
def get_ts(req):
ct = req.get("created_time", {})
if isinstance(ct, dict):
v = ct.get("value", "0")
return int(v) if v else 0
return 0
def clean_html(text):
if not text:
return ""
text = re.sub(r'<[^>]+>', '', text)
text = unescape(text)
return text.strip()
def get_name(obj):
"""Извлечь имя из объекта группы/техника/категории."""
if not obj:
return ""
if isinstance(obj, dict):
return obj.get("name", "") or obj.get("display_value", "")
return str(obj)
async def fetch_ticket_detail(client, req_id):
try:
resp = await client.get(
f"{BASE_URL}/api/v3/requests/{req_id}",
headers={"authtoken": API_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"},
timeout=15.0
)
data = resp.json()
return data.get("request", data)
except Exception as e:
return {}
async def list_tickets():
url = f"{BASE_URL}/api/v3/requests"
headers = {"authtoken": API_TOKEN, "Accept": "application/vnd.manageengine.sdp.v3+json"}
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
resp = await client.get(url, headers=headers)
data = resp.json()
all_reqs = data.get("requests", [])
from_20 = [r for r in all_reqs if get_ts(r) >= FROM_DATE_MS]
# Получаем детали для каждой заявки
tasks = [fetch_ticket_detail(client, r.get("request_id", r.get("id", "?"))) for r in from_20]
details = await asyncio.gather(*tasks)
rows = []
for req, detail in zip(from_20, details):
# Группа — из списка (есть там)
group = req.get("group", {})
group_name = get_name(group)
# Описание — из детального запроса
desc = detail.get("description", "")
desc_clean = clean_html(desc)
# Назначено (специалист) — из детального запроса
tech = detail.get("technician", {})
assignee = get_name(tech)
# Категория — из детального запроса
cat = detail.get("category", {})
category = get_name(cat)
rows.append({
"group": group_name,
"description": desc_clean,
"assignee": assignee,
"category": category
})
# Выводим JSONL
for row in rows:
print(json.dumps(row, ensure_ascii=False))
if __name__ == "__main__":
urllib3.disable_warnings()
asyncio.run(list_tickets())
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""
Проверка подключения к SQL Server и загрузки данных табеля
за последние 5 месяцев для ds.krivochenko@sibcem.ru.
Цепочка поиска сотрудника (как в LK модуле бота):
1. AD: search_by_user_id(email) → extensionAttribute2 = CARD_ID
2. SQL: SELECT ID FROM UOV_SELFSERVICE_PR_EMP WHERE CARD_ID = %s → EMP_ID
3. SQL: SELECT ... FROM UOV_SELFSERVICE_TB_TABEL WHERE EMP_ID = %s → табель
Запуск:
cd /opt/trueconf_bot
python3 test_tabel_check.py
"""
import sys
import os
import calendar
from datetime import datetime
# ============================================================
# 1. Загрузка .env
# ============================================================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ENV_PATH = os.path.join(BASE_DIR, "config", ".env")
def load_env(filepath):
try:
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
if "=" in line:
key, value = line.split("=", 1)
os.environ[key.strip()] = value.strip(' "\'\r\n')
except FileNotFoundError:
print(f"⚠️ Файл {filepath} не найден.")
load_env(ENV_PATH)
# Карта соответствия: Имя переменной в боте -> Название карточки в сейфе
CREDENTIALS_MAP = {
"DB_PASSWORD": os.getenv("PW_ID_SQL", "").strip(' "\'\r\n'),
"AD_PASSWORD": os.getenv("PW_ID_AD", "").strip(' "\'\r\n'),
}
# ============================================================
# 2. Загрузка паролей из Passwork
# ============================================================
DB_USER = ""
DB_PASSWORD = ""
AD_USER = ""
AD_PASSWORD = ""
for pw_path in ["/opt/passwork", os.path.join(BASE_DIR, "passwork"), os.path.join(BASE_DIR, "config", "passwork")]:
if os.path.isdir(pw_path) and pw_path not in sys.path:
sys.path.insert(0, pw_path)
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
try:
from passwork import get_passwork_secrets
print("🔐 Загрузка секретов из Passwork...")
required_cards = [name for name in CREDENTIALS_MAP.values() if name]
passwork_pool = get_passwork_secrets(required_cards=required_cards)
for var_name, card_name in CREDENTIALS_MAP.items():
if not card_name:
continue
clean_card = card_name.strip(' "\'\r\n')
card_data = passwork_pool.get(clean_card)
if card_data is None:
print(f" ⚠️ Карточка '{clean_card}' не найдена")
continue
globals()[var_name] = card_data["password"].strip(' "\'\r\n')
if var_name == "AD_PASSWORD":
globals()["AD_USER"] = card_data.get("login", "").strip(' "\'\r\n')
elif var_name == "DB_PASSWORD":
globals()["DB_USER"] = card_data.get("login", "").strip(' "\'\r\n')
print(f"{var_name} из карточки '{clean_card}'")
except ImportError:
print("⚠️ Модуль passwork не найден")
except Exception as e:
print(f"⚠️ Ошибка Passwork: {e}")
if not DB_PASSWORD:
DB_PASSWORD = os.getenv("DB_PASSWORD", "").strip()
DB_USER = os.getenv("DB_USER", DB_USER).strip()
if not AD_PASSWORD:
AD_PASSWORD = os.getenv("AD_PASSWORD", "").strip()
AD_USER = os.getenv("AD_USER", AD_USER).strip()
# ============================================================
# 3. Настройки подключения
# ============================================================
DB_SERVER = os.getenv("SQL_SERVER", "SRVKEM-MOBILEIN.sibcem.ru")
DB_NAME = os.getenv("SQL_DB_NAME", "BossCopy")
AD_SERVER = os.getenv("AD_SERVER", "ldap://172.16.20.20")
AD_BASES = (
"OU=-Пользователи,DC=sibcem,DC=ru",
"OU=Планшеты,OU=enabled,OU=БезКомпьютеров,DC=sibcem,DC=ru",
)
print()
print("=" * 60)
print(" Проверка подключения к SQL Server")
print("=" * 60)
print(f" Сервер : {DB_SERVER}")
print(f" База : {DB_NAME}")
print(f" Логин : {DB_USER}")
print(f" Пароль : {'*' * len(DB_PASSWORD) if DB_PASSWORD else '(не задан)'}")
print()
# ============================================================
# 4. Проверка pymssql
# ============================================================
try:
import pymssql
print(f"✅ pymssql загружен (версия {pymssql.__version__})")
except ImportError:
print("❌ pymssql не установлен!")
sys.exit(1)
# ============================================================
# 5. Подключение к SQL Server
# ============================================================
try:
conn = pymssql.connect(
server=DB_SERVER,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME,
charset='cp1251',
login_timeout=10
)
print("✅ Подключение к SQL Server успешно!")
cursor = conn.cursor()
except Exception as e:
print(f"❌ Ошибка подключения: {e}")
sys.exit(1)
# ============================================================
# 6. Поиск сотрудника в AD по email → CARD_ID (как в LK модуле)
# ============================================================
print()
print("=" * 60)
print(" Поиск сотрудника в Active Directory")
print("=" * 60)
TARGET_EMAIL = "ds.krivochenko@sibcem.ru"
print(f" Ищем: {TARGET_EMAIL}")
try:
from ldap3 import Server, Connection, ALL
server = Server(AD_SERVER, get_info=ALL)
ldap_conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
# Как в ad_search.py: ищем по mail, userPrincipalName, sAMAccountName
short_username = TARGET_EMAIL.split("@")[0] if "@" in TARGET_EMAIL else TARGET_EMAIL
or_parts = [
"(mail={})".format(TARGET_EMAIL),
"(userPrincipalName={})".format(TARGET_EMAIL),
"(userPrincipalName={})".format(short_username + "@sibcem.ru"),
"(sAMAccountName={})".format(short_username),
]
or_filter = "(|{})".format("".join(or_parts))
search_filter = "(&{}{})".format("(objectClass=user)", or_filter)
print(f" LDAP-фильтр: {search_filter}")
card_id_from_ad = None
for base in AD_BASES:
ldap_conn.search(
search_base=base,
search_filter=search_filter,
attributes=["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2"],
)
if ldap_conn.entries:
entry = ldap_conn.entries[0]
print(f" ✅ Найден в OU: {base}")
print(f" CN : {entry.cn}")
print(f" sAMAccountName : {entry.sAMAccountName}")
print(f" mail : {entry.mail}")
ext2 = str(entry.extensionAttribute2) if hasattr(entry, 'extensionAttribute2') and entry.extensionAttribute2 else "(не задан)"
print(f" extensionAttribute2: {ext2}")
card_id_from_ad = ext2
break
if not ldap_conn.entries:
print(f" ❌ Сотрудник не найден в AD по email {TARGET_EMAIL}")
except ImportError:
print("❌ ldap3 не установлен")
sys.exit(1)
except Exception as e:
print(f"❌ Ошибка LDAP: {e}")
sys.exit(1)
# ============================================================
# 7. Поиск EMP_ID по CARD_ID в SQL (как в LK модуле)
# ============================================================
print()
print("=" * 60)
print(" Поиск EMP_ID по CARD_ID в SQL")
print("=" * 60)
emp_id = None
card_id = None
if card_id_from_ad:
card_id = card_id_from_ad
print(f" CARD_ID из AD: {card_id}")
# Как в info_service.py: SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s
cursor.execute("SELECT e.ID FROM UOV_SELFSERVICE_PR_EMP e WHERE e.CARD_ID = %s", (card_id,))
row = cursor.fetchone()
if row:
emp_id = row[0]
print(f" ✅ Найден: emp_id={emp_id}")
else:
print(f" ❌ Сотрудник с CARD_ID={card_id} не найден в UOV_SELFSERVICE_PR_EMP")
# ============================================================
# 8. Загрузка табеля за последние 5 месяцев
# ============================================================
print()
print("=" * 60)
print(" Загрузка табеля за последние 5 месяцев")
print("=" * 60)
if not emp_id:
print(" ❌ Не удалось найти сотрудника — завершение")
conn.close()
sys.exit(1)
now = datetime.now()
months_back = 5
for i in range(months_back):
month = now.month - i
year = now.year
while month <= 0:
month += 12
year -= 1
month_name = calendar.month_name[month]
print(f"\n📅 {month_name} {year}")
print("-" * 40)
query = """
SELECT e.D, e.TDAY_ID, e.H
FROM UOV_SELFSERVICE_TB_TABEL e
WHERE e.EMP_ID = %s AND YEAR(e.D) = %s AND MONTH(e.D) = %s
ORDER BY e.D ASC
"""
try:
cursor.execute(query, (emp_id, year, month))
records = cursor.fetchall()
except Exception as e:
print(f" ❌ Ошибка запроса: {e}")
continue
if not records:
print(f" ⚠️ Данные табеля отсутствуют")
continue
print(f" Записей: {len(records)}")
total_days = 0
total_hours = 0.0
codes = {}
for r in records:
tday_id = str(r[1]).strip() if r[1] else "?"
hours = float(r[2]) if r[2] is not None else 0.0
codes[tday_id] = codes.get(tday_id, 0) + 1
if tday_id == 'Я':
total_days += 1
total_hours += hours
print(f" Явки (Я): {codes.get('Я', 0)} дн., часов: {total_hours:.1f}")
print(f" Коды: {', '.join(f'{k}={v}' for k, v in codes.items())}")
print(f" Первые 5 записей:")
for r in records[:5]:
d = r[0].strftime('%d.%m.%Y') if hasattr(r[0], 'strftime') else str(r[0])
print(f" {d} | {str(r[1]).strip():>3} | {float(r[2]) if r[2] else 0:.1f} ч.")
# ============================================================
# Закрытие
# ============================================================
conn.close()
print()
print("=" * 60)
print(" ✅ Проверка завершена")
print("=" * 60)
+68 -9
View File
@@ -16,14 +16,64 @@ from utils.texts import (
transcription_error_bad_extension,
TRANSCRIPTION_UPLOADING_TEXT,
transcription_success_received,
transcription_error_api,
transcription_error_system,
system_error_text,
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()
@@ -45,13 +95,13 @@ ALLOWED_EXTENSIONS = [
# --- Функция получения Email из Active Directory ---
def get_user_email_sync(login: str) -> str:
try:
server = Server(AD_SERVER, get_info=ALL)
conn = Connection(server, user=AD_USER, password=AD_PASSWORD, auto_bind=True)
conn.search(search_base=AD_BASE, search_filter=f"(sAMAccountName={login})", attributes=["mail"])
if conn.entries and 'mail' in conn.entries[0] and conn.entries[0].mail.value:
return str(conn.entries[0].mail.value)
from utils.ad_search import search_by_login
entries = search_by_login(login, ["mail"])
if entries and 'mail' in entries[0] and entries[0].mail.value:
return str(entries[0].mail.value)
except Exception as e:
logger.error(f"Ошибка поиска email в AD: {e}")
_transcription_log_and_notify_email("Поиск email в AD", str(e))
return DEFAULT_REQUESTER
@router.message()
@@ -116,12 +166,21 @@ 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:
await msg.answer(transcription_error_api(response.status_code), parse_mode="html")
_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")
except Exception as e:
logger.error(f"Transcription Upload Error: {e}")
await msg.answer(transcription_error_system(), parse_mode="html")
_transcription_log_and_notify_email("Загрузка/отправка файла на транскрипцию", str(e))
await msg.answer(system_error_text(EMOJI_DIGITS), parse_mode="html")
return
# --- ЗАГЛУШКА НА НЕИЗВЕСТНЫЙ ТЕКСТ / СТАНДАРТНАЯ ОШИБКА ВВОДА ---
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.
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
# /opt/trueconf_bot/utils/ad_checker.py
# Проверка членства пользователя в группах Active Directory
from ldap3 import Server, Connection, ALL
from utils.ad_search import search_by_filter as ad_search_all_bases
def get_user_groups(cn: str) -> list[str] | None:
"""
Найти пользователя по CN и вернуть список групп.
Ищет по всем OU из AD_BASES.
Возвращает None если пользователь не найден.
"""
entries = ad_search_all_bases("(cn={})".format(cn), ["cn", "sAMAccountName", "memberOf"])
if not entries:
return None
entry = entries[0]
return [str(g) for g in entry.memberOf]
def check_group_membership(cn: str, group_pattern: str = "2FA") -> dict:
"""
Проверить членство пользователя в группе.
Args:
cn: CN пользователя (например 'Krivochenko_Denis_Sergeevich')
group_pattern: подстрока для поиска в имени группы (по умолчанию '2FA')
Returns:
dict с полями:
found: bool — найден ли пользователь
cn: str | None — CN пользователя
sam: str | None — sAMAccountName
in_group: bool — в группе ли
groups: list[str] — все группы
matching_groups: list[str] — группы с паттерном
"""
groups = get_user_groups(cn)
if groups is None:
return {
"found": False,
"cn": None,
"sam": None,
"in_group": False,
"groups": [],
"matching_groups": [],
}
matching = [g for g in groups if group_pattern.upper() in g.upper()]
return {
"found": True,
"cn": cn,
"sam": groups[0].split(",")[0].split("=")[-1], # грубо из DN
"in_group": len(matching) > 0,
"groups": groups,
"matching_groups": matching,
}
+73
View File
@@ -0,0 +1,73 @@
# /opt/trueconf_bot/utils/ad_search.py
# Универсальный поиск пользователей по нескольким OU в AD.
# Каждая функция принимает user_id/email/логин и ищет по всем OU из AD_BASES.
# Возвращает первую найденную запись (ldap3 Entry) или None.
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_filter_chars
import config.config as config
def _get_conn():
"""Создать соединение с AD."""
server = Server(config.AD_SERVER, get_info=ALL)
return Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
def search_by_login(login: str, attributes: list[str] | None = None) -> list:
"""
Найти пользователя по sAMAccountName во всех OU из AD_BASES.
Возвращает список Entry (обычно 0 или 1).
"""
attrs = attributes or ["cn", "sAMAccountName"]
conn = _get_conn()
for base in config.AD_BASES:
conn.search(
search_base=base,
search_filter="({}={})".format("sAMAccountName", login),
attributes=attrs,
)
if conn.entries:
return list(conn.entries)
return []
def search_by_user_id(user_id: str, attributes: list[str] | None = None) -> list:
"""
Найти пользователя по email/UPN/sAMAccountName во всех OU из AD_BASES.
Возвращает список Entry (обычно 0 или 1).
"""
attrs = attributes or ["cn", "sAMAccountName", "mail", "userPrincipalName", "extensionAttribute2"]
safe_id = escape_filter_chars(user_id)
short_username = user_id.split("@")[0] if "@" in user_id else user_id
safe_sam = escape_filter_chars(short_username)
# Строим LDAP filter: (&(objectClass=user)(|(mail=...)(userPrincipalName=...)(sAMAccountName=...)))
or_parts = [
"(mail={})".format(safe_id),
"(userPrincipalName={})".format(safe_id),
"(userPrincipalName={})".format(short_username + "@sibcem.ru"),
"(sAMAccountName={})".format(safe_sam),
]
or_filter = "(|{})".format("".join(or_parts))
search_filter = "(&{}{})".format("(objectClass=user)", or_filter)
conn = _get_conn()
for base in config.AD_BASES:
conn.search(search_base=base, search_filter=search_filter, attributes=attrs)
if conn.entries:
return list(conn.entries)
return []
def search_by_filter(search_filter: str, attributes: list[str]) -> list:
"""
Найти по произвольному LDAP-фильтру во всех OU из AD_BASES.
Возвращает список Entry.
"""
conn = _get_conn()
for base in config.AD_BASES:
conn.search(search_base=base, search_filter=search_filter, attributes=attributes)
if conn.entries:
return list(conn.entries)
return []
+7
View File
@@ -62,6 +62,13 @@ def build_dynamic_menu():
dynamic_items.append(f"{num_char} — Поиск по регламентам")
counter += 1
if getattr(config, 'ENABLE_INSTRUCT', False):
num_char = emoji_digits.get(counter, str(counter))
MENU_MAP[str(counter)] = "INSTRUCT"
MENU_MAP[num_char] = "INSTRUCT"
dynamic_items.append(f"{num_char} — 📋 Инструкции")
counter += 1
if counter == 1:
dynamic_items.append("<i>В данный момент бот находится на техническом обслуживании.</i>")
+9 -11
View File
@@ -63,18 +63,16 @@ async def send_otp_via_api(phone: str, code: str) -> tuple:
return False, str(e)
def get_card_id_from_ad(user_id: str) -> str:
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2)"""
"""Синхронно вытягивает CARD_ID сотрудника из Active Directory (extensionAttribute2).
Ищет по всем OU из AD_BASES."""
from utils.ad_search import search_by_user_id
try:
server = Server(config.AD_SERVER, get_info=ALL)
conn = Connection(server, user=config.AD_USER, password=config.AD_PASSWORD, auto_bind=True)
safe_user_id = escape_filter_chars(user_id)
short_username = user_id.split('@')[0]
search_filter = f"(&(objectClass=user)(|(mail={safe_user_id})(userPrincipalName={safe_user_id})(userPrincipalName={escape_filter_chars(f'{short_username}@sibcem.ru')})(sAMAccountName={escape_filter_chars(short_username)})))"
conn.search(config.AD_BASE, search_filter, attributes=['extensionAttribute2'])
if conn.entries and 'extensionAttribute2' in conn.entries[0] and conn.entries[0].extensionAttribute2.value:
return str(conn.entries[0].extensionAttribute2.value).strip()
entries = search_by_user_id(user_id, ["extensionAttribute2"])
if not entries:
raise Exception("Пользователь не найден в AD.")
entry = entries[0]
if 'extensionAttribute2' in entry and entry.extensionAttribute2.value:
return str(entry.extensionAttribute2.value).strip()
raise Exception("Поле extensionAttribute2 не заполнено в AD.")
except Exception as e:
logger.error(f"Ошибка получения CARD_ID из AD для {user_id}: {e}")
+1
View File
@@ -10,6 +10,7 @@ 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):
"""Фиксирует новое состояние пользователя и обновляет метку времени активности"""
+404 -76
View File
@@ -2,18 +2,16 @@
# Все текстовые сообщения бота, отправляемые пользователю
# =========================================================
# GLOBAL CONSTANTS
# GLOBAL CONSTANTS & HELPERS
# =========================================================
# --- Main Bot ---
# Общее для главного бота
UNKNOWN_MAIN_CMD_TEXT = (
"⚠️ <b>Неизвестная команда.</b>\n\n"
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
)
# --- Emoji Digits ---
# Используются для навигации и выбора пунктов меню
EMOJI_DIGITS = {
"0": "0⃣",
"1": "1⃣",
@@ -29,7 +27,6 @@ EMOJI_DIGITS = {
}
# --- Footer Templates ---
# Шаблоны подвалов сообщений для навигации
def _footer(emojis: dict) -> str:
"""Стандартный футер с кнопками Назад и Меню"""
@@ -39,6 +36,17 @@ 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
@@ -140,16 +148,14 @@ def _sd_ticket_created(ticket_id: int, subject: str, description: str, emojis: d
clean_desc = clean_desc[:147] + "..."
return (
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
f"📌 <b>Тема: {subject}</b>\n\n"
f"📝 <b>Текст: {clean_desc}</b>\n\n"
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
f"{_footer(emojis)}"
)
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
def _sd_ticket_create_error(emojis: dict) -> str:
return f"❌ <b>Ошибка создания заявки.</b>{_footer_main_only(emojis)}"
def _sd_system_error(emojis: dict) -> str:
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
@@ -170,10 +176,8 @@ SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
SD_CREATING = _sd_creating()
def _sd_ticket_create_error(emojis: dict) -> str:
return (
f"❌ <b>Не удалось создать заявку.</b>\n\n" f"Пожалуйста, повторите попытку позже.\n\n" f"{emojis['0']}В главное меню"
)
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
def sd_ticket_create_error() -> str:
return _sd_ticket_create_error(EMOJI_DIGITS)
@@ -194,16 +198,31 @@ def sd_file_upload_error() -> str:
# =========================================================
# PHOTO BOT
# =========================================================
# Модуль для обновления фото профиля
# Модуль для обновления фото профиля — интеграция с Django API
# --- Private functions (Internal logic) ---
def _photo_welcome(emojis: dict) -> str:
return (
f"🖼 <b>Режим обновления фото профиля</b>\n\n"
f"📸 Пришлите мне вашу фотографию.\n\n"
f"{emojis['0']}В главное меню"
def _photo_welcome(emojis: dict, show_test: bool = False, info: str = None, pending: bool = False) -> str:
"""Входное сообщение фото-режима. info — необязательный блок о статусе
модерации (отказ). pending=True — фото уже на модерации: без призыва
отправить фото, только статус."""
text = f"🖼 <b>Режим обновления фото профиля</b>\n\n"
if pending:
text += (
f"⏳ <b>У вас уже есть фото на модерации.</b>\n"
f"Ожидайте решения модератора.\n\n"
)
else:
text += (
f"📸 Пришлите мне вашу фотографию.\n\n"
f"Бот автоматически найдёт лицо, покажет превью\n"
f"и отправит фото на публикацию в ваши корпоративные сервисы.\n\n"
)
if info:
text += info + "\n\n"
if show_test:
text += f"{emojis['1']} — тест проверка фото\n"
return text + f"{emojis['0']}В главное меню"
def _photo_unknown(emojis: dict) -> str:
return (
@@ -213,19 +232,203 @@ def _photo_unknown(emojis: dict) -> str:
f"{emojis['0']}В главное меню"
)
def _photo_error_handling(emojis: dict) -> str:
def _photo_processing_face(emojis: dict) -> str:
return "⏳ <i>Ищу лицо на фото, пожалуйста, подождите...</i>"
def _photo_preview_ready(emojis: dict) -> str:
return (
f"⚠️ <i>В данный момент выполнить это действие невозможно. "
f"Информация о проблеме передана ответственным.\n"
f"Приносим извинения за доставленные неудобства.</i>\n\n"
f"📸 Вот ваш кадр.\n\n"
f"{emojis['1']} — Подтвердить\n"
f"{emojis['2']} — Вырезать заново\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_uploading() -> str:
return "⏳ Отправка..."
def _photo_ai_processing(emojis: dict) -> str:
return (
"⏳ <i>ИИ обрабатывает фото. Это может занять до 5 минут.\n"
"Пожалуйста, не закрывайте чат и подождите...</i>"
)
def _photo_success_sent() -> str:
return "📩 <b>Фото отправлено на утверждение.</b>"
def _photo_status_info(status: dict = None, emojis: dict = None) -> str | None:
"""Блок о статусе модерации для входного сообщения. Универсальный для всех
случаев: rejected → причина отказа, pending → ожидание, прочее → пусто."""
if not status or status.get("status") != "ok":
return None
ms = status.get("moderation_status")
if ms == "rejected":
reason = (status.get("rejection_reason") or "Причина не указана.").strip()
return (
f"⚠️ <b>Ваше предыдущее фото было отклонено.</b>\n"
f"📝 Причина: {reason}\n\n"
f"Пожалуйста, отправьте новое фото."
)
if ms == "pending":
return (
f"⏳ <b>У вас уже есть фото на модерации.</b>\n"
f"Ожидайте решения модератора."
)
return None
def _photo_test_welcome(emojis: dict) -> str:
"""Тест-приветствие: фото уйдёт напрямую в ИИ, никуда не публикуется."""
return (
f"🧪 <b>Тестовый режим фото-проверки</b>\n\n"
f"📸 Пришлите фотографию — она уйдёт <b>напрямую в ИИ</b>.\n"
f"Бот покажет вердикт ИИ. <b>Фото никуда не публикуется</b>.\n\n"
f"{emojis['9']} — Назад\n"
f"{emojis['0']}В главное меню"
)
def _photo_test_preview(emojis: dict) -> str:
return (
f"🧪 <b>Тестовый режим</b>\n\n"
f"Кадр получен. Фото никуда не публикуется.\n\n"
f"{emojis['1']} — отправлю фото на проверку по сайту\n"
f"{emojis['2']} — Загрузить другое фото\n"
f"{emojis['9']} — Назад\n"
f"{emojis['0']}В главное меню"
)
def _photo_test_ai_checking(emojis: dict) -> str:
return (
"🧪 <i>Отправляю фото на проверку по сайту (тестовый режим)...\n"
"Фото проходят полный контроль: CV, гримасы, ИИ.\n"
"Обычно это занимает 1-3 минуты. Пожалуйста, подождите...</i>"
)
def _photo_test_receiving(emojis: dict) -> str:
return "🧪 <i>Ищу лицо на фото (тестовый режим), пожалуйста, подождите...</i>"
def _photo_test_result(verdict: str, reason: str, cv_approved=None, cv_reason: str = '',
forced=None, timings: dict = None, ai_metrics: dict = None,
frame: dict = None, emojis: dict = None) -> str:
emojis = emojis or {}
icons = {"Прошел": "", "Не прошел": "", "На модерацию": ""}
icon = icons.get(verdict, "")
parts = [
"🧪 <b>Тестовый режим: результат проверки</b>\n",
f"{icon} <b>Вердикт:</b> {verdict}\n",
]
if reason:
parts.append(f"📝 <b>Причина (ИИ):</b> {reason}\n")
# Детерминированный слой (MediaPipe CV + ARKit-формы)
if cv_approved is True:
line = "🎯 <b>CV-проверка:</b> ✅ пройдена"
if cv_reason:
line += f" — <i>{cv_reason}</i>"
parts.append(line + "\n")
elif cv_approved is False:
parts.append("🎯 <b>CV-проверка:</b> ❌ отклонена\n")
if cv_reason:
parts.append(f"↳ <i>{cv_reason}</i>\n")
else:
parts.append("🎯 <b>CV-проверка:</b> —\n")
# Метрики кадра, которые реально измерил сайт (виден именно проверенный кадр)
if frame:
bb = frame.get('face_bbox_px') or {}
try:
box = f"[{bb.get('x1')}..{bb.get('x2')}]×[{bb.get('y1')}..{bb.get('y2')}]"
parts.append(
f"📐 <b>Кадр (измерено сайтом):</b> {frame.get('img_w')}×{frame.get('img_h')} px, "
f"бокс лица {box}, голова <b>{frame.get('head_pct')}%</b> "
f"(норма ≥ {int(frame.get('min_head_percent', 30))}%)\n"
)
except Exception:
pass
if forced:
parts.append("️ <i>На сайте включён режим принудительной модерации: в реальном режиме фото ушло бы модератору, а не было опубликовано сразу.</i>\n")
# Тайминги этапов
if timings:
names = [
('cv', "CV (геометрия)"),
('expression', "Гримасы (ARKit-формы)"),
('ai', "ИИ (llama.cpp)"),
('total', "Всего"),
]
tl = ["📊 <b>Технические показатели (по этапам)</b>"]
for k, name in names:
v = timings.get(k)
if v is not None:
tl.append(f"{name}: <b>{float(v):.2f} сек</b>")
if len(tl) > 1:
parts.append("\n".join(tl) + "\n")
# Токены и скорость (сайт передаёт их из ответа llama.cpp)
if ai_metrics:
ml = ["🔢 <b>ИИ: токены и скорость</b>"]
pt = ai_metrics.get('prompt_tokens')
gps = ai_metrics.get('prompt_per_second')
if pt is not None:
s = f"⬇️ Приём (промпт): <b>{pt} токенов</b>"
if gps is not None:
s += f"{float(gps):.1f} ток/сек"
ml.append(s)
ct = ai_metrics.get('completion_tokens')
ggs = ai_metrics.get('predicted_per_second')
if ct is not None:
s = f"⬆️ Отдача (генерация): <b>{ct} токенов</b>"
if ggs is not None:
s += f"{float(ggs):.1f} ток/сек"
ml.append(s)
if len(ml) > 1:
parts.append("\n".join(ml) + "\n")
parts.append("⚠️ <i>Тестовый режим: фото не публиковалось и никуда не отправлялось.</i>\n\n")
parts.append(f"{emojis.get('2', '2')} — Загрузить другое фото\n")
parts.append(f"{emojis.get('9', '9')} — Назад\n")
parts.append(f"{emojis.get('0', '0')}В главное меню")
return "\n".join(parts)
def _photo_test_ai_error(emojis: dict, error: str = '', elapsed: float = None) -> str:
detail = ''
err = (error or '').strip()
if len(err) > 400:
err = err[:400] + ''
if err:
detail += f"📋 <b>Детали:</b> <i>{err}</i>\n"
if elapsed is not None:
detail += f"⏱ Время до ошибки: <b>{elapsed:.1f} сек</b>\n"
return (
f"❌ <b>Ошибка проверки фото.</b>\n\n"
f"{detail}\n"
f"Сервис проверки временно недоступен. Повторите попытку позже.\n\n"
f"{emojis['1']} — Повторить проверку\n"
f"{emojis['2']} — Загрузить другое фото\n"
f"{emojis['0']}В главное меню"
)
def _photo_success_published(emojis: dict) -> str:
return (
f"🎉 <b>Фото успешно опубликовано!</b>\n\n"
f"Применение во всех системах может занять до 2-х суток.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_success_pending(emojis: dict) -> str:
return (
f"📋 <b>Фото отправлено на модерацию.</b>\n\n"
f"Ожидайте уведомления.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_error_rejected(reason: str, emojis: dict) -> str:
return (
f"⚠️ <b>Фото отклонено:</b> {reason}\n\n"
f"Пожалуйста, отправьте другое фото.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_error_already_pending(emojis: dict) -> str:
return (
f"⚠️ <b>У вас уже есть фото на модерации.</b>\n\n"
f"Подождите завершения текущей обработки.\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_cancel(emojis: dict) -> str:
return (
@@ -233,47 +436,65 @@ def _photo_cancel(emojis: dict) -> str:
f"{emojis['0']}В главное меню"
)
def _photo_processing() -> str:
return "⏳ Обработка..."
def _photo_preview_ready(emojis: dict) -> str:
return (
f"... <b>Фото готово!</b>\n\n"
f"{emojis['1']} — Отправить на утверждение\n"
f"{emojis['2']} — Попробовать другое\n\n"
f"{emojis['0']}В главное меню"
)
def _photo_error_processing(error_text: str, emojis: dict) -> str:
return (
f"❌ <b>Ошибка:</b> {error_text}\n\n"
f"Пожалуйста, отправьте другое фото.\n\n"
f"{emojis['0']}В главное меню"
)
# --- Public constants and functions ---
PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
PHOTO_TEST_ENTRY_TEXT = _photo_welcome(EMOJI_DIGITS, show_test=True)
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
def photo_error_handling() -> str:
return _photo_error_handling(EMOJI_DIGITS)
return system_error_text(EMOJI_DIGITS, show_back=False)
PHOTO_UPLOADING_TEXT = _photo_uploading()
def photo_success_sent() -> str:
return _photo_success_sent()
def photo_cancel_text() -> str:
return _photo_cancel(EMOJI_DIGITS)
PHOTO_PROCESSING_TEXT = _photo_processing()
PHOTO_PROCESSING_FACE_TEXT = _photo_processing_face(EMOJI_DIGITS)
def photo_preview_ready() -> str:
return _photo_preview_ready(EMOJI_DIGITS)
def photo_error_processing(error_text: str) -> str:
return _photo_error_processing(error_text, EMOJI_DIGITS)
PHOTO_AI_PROCESSING_TEXT = _photo_ai_processing(EMOJI_DIGITS)
def photo_success_published() -> str:
return _photo_success_published(EMOJI_DIGITS)
def photo_success_pending() -> str:
return _photo_success_pending(EMOJI_DIGITS)
def photo_error_rejected(reason: str) -> str:
return _photo_error_rejected(reason, EMOJI_DIGITS)
def photo_error_already_pending() -> str:
return _photo_error_already_pending(EMOJI_DIGITS)
def photo_cancel_text() -> str:
return _photo_cancel(EMOJI_DIGITS)
def photo_welcome_with_status(status: dict = None, show_test: bool = False) -> str:
"""Универсальное входное сообщение фото-режима: меню + статус модерации.
pending — только статус (без призыва отправить фото);
show_test — добавить кнопку тест-проверки (PHOTO_TEST_MODE)."""
info = _photo_status_info(status, EMOJI_DIGITS)
pending = bool(
status and status.get("status") == "ok"
and status.get("moderation_status") == "pending"
)
return _photo_welcome(EMOJI_DIGITS, show_test=show_test, info=info, pending=pending)
PHOTO_TEST_WELCOME_TEXT = _photo_test_welcome(EMOJI_DIGITS)
def photo_test_welcome() -> str:
"""Тест-приветствие (тест-под-режим фото-проверки)."""
return _photo_test_welcome(EMOJI_DIGITS)
PHOTO_TEST_PREVIEW_TEXT = _photo_test_preview(EMOJI_DIGITS)
PHOTO_TEST_AI_CHECKING_TEXT = _photo_test_ai_checking(EMOJI_DIGITS)
PHOTO_TEST_RECEIVING_TEXT = _photo_test_receiving(EMOJI_DIGITS)
def photo_test_result(verdict: str, ai_reason: str = '', cv_approved=None, cv_reason: str = '',
forced=None, timings: dict = None, ai_metrics: dict = None,
frame: dict = None) -> str:
return _photo_test_result(verdict, ai_reason, cv_approved, cv_reason,
forced, timings, ai_metrics, frame, EMOJI_DIGITS)
def photo_test_ai_error(error: str = '') -> str:
return _photo_test_ai_error(EMOJI_DIGITS, error)
# =========================================================
@@ -323,14 +544,6 @@ 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)
@@ -349,7 +562,7 @@ def search_feedback_info_text(emojis: dict) -> str:
return _search_feedback_info(emojis)
def search_unavailable_action(emojis: dict) -> str:
return _search_unavailable(emojis)
return system_error_text(emojis, show_back=False)
# =========================================================
@@ -357,8 +570,6 @@ def search_unavailable_action(emojis: dict) -> str:
# =========================================================
# Модуль Личного Кабинета (финансовые документы, табель и др.)
# --- Constants ---
DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС",
@@ -371,12 +582,6 @@ 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>"
@@ -400,7 +605,7 @@ def _lk_payslip_loading(emojis: dict) -> str:
return "⏳ <i>Формирую архив расчетных листков...</i>"
def _lk_vacation_loading(emojis: dict) -> str:
return "⏳ <i>Формирую справку по отпусках...</i>"
return "⏳ <i>Формирую справку по отпускам...</i>"
def _lk_sick_loading(emojis: dict) -> str:
return "⏳ <i>Загружаю историю больничных листов...</i>"
@@ -464,6 +669,15 @@ def _lk_docs_quantity_prompt(emojis: dict) -> str:
def _lk_docs_year_prompt(emojis: dict) -> str:
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_years_menu(current_year: int, emojis: dict) -> str:
lines = ["📅 <b>Выберите год запрашиваемого периода:</b>\n"]
for i in range(1, 9):
year = current_year - (i - 1)
emoji = emojis.get(str(i), f"{i}")
lines.append(f"{emoji}{year} год")
lines.append(f"\n<i>{emojis.get('9', '9⃣')} — Назад\n{emojis.get('0', '0⃣')} — В главное меню</i>")
return "\n".join(lines)
def _lk_docs_avg_salary_prompt(emojis: dict) -> str:
return f"Укажите количество месяцев для расчета средней ЗП (например: 3):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
@@ -495,7 +709,7 @@ LK_UNKNOWN_CMD_TEXT = (
)
def lk_error_handling(emojis: dict) -> str:
return _lk_error_handling(emojis)
return system_error_text(emojis, show_back=True)
def lk_auth_processing(emojis: dict) -> str:
return _lk_auth_processing(emojis)
@@ -573,4 +787,118 @@ def lk_nav_text(emojis: dict) -> str:
return _lk_nav_text(emojis)
def lk_years_menu(current_year: int, emojis: dict) -> str:
return lk_years_menu(current_year, emojis)
return _lk_years_menu(current_year, emojis)
# =========================================================
# INSTRUCT BOT (Инструкции)
# =========================================================
# Модуль инструкций
# --- Private functions (Internal logic) ---
def _instruct_main_menu(emojis: dict) -> str:
return (
f"📋 <b>Выберите инструкцию:</b>\n\n"
f"{emojis['1']} — 🖥 Настройка TrueConf на мобильном устройстве\n"
f"{emojis['2']} — 📧 Настройка почты на мобильном устройстве\n\n"
f"<i>{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf(emojis: dict) -> str:
"""Статический текст Trueconf — без проверки группы."""
return (
"🖥 <b>Инструкция по TrueConf</b>\n\n"
"Данная функция находится в разработке.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf_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)
+655
View File
@@ -0,0 +1,655 @@
# /opt/trueconf_bot/utils/texts.py
# Все текстовые сообщения бота, отправляемые пользователю
# =========================================================
# GLOBAL CONSTANTS
# =========================================================
# --- Main Bot ---
# Общее для главного бота
UNKNOWN_MAIN_CMD_TEXT = (
"⚠️ <b>Неизвестная команда.</b>\n\n"
"Пожалуйста, используйте только цифры, соответствующие нужным пунктам меню, и отправьте выбранную цифру мне в сообщении."
)
# --- Emoji Digits ---
# Используются для навигации и выбора пунктов меню
EMOJI_DIGITS = {
"0": "0⃣",
"1": "1⃣",
"2": "2⃣",
"3": "3⃣",
"4": "4⃣",
"5": "5⃣",
"6": "6⃣",
"7": "7⃣",
"8": "8⃣",
"9": "9⃣",
"10": "🔟"
}
# --- Footer Templates ---
# Шаблоны подвалов сообщений для навигации
def _footer(emojis: dict) -> str:
"""Стандартный футер с кнопками Назад и Меню"""
return f"\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _footer_main_only(emojis: dict) -> str:
"""Футер только с кнопкой Меню"""
return f"\n\n<i>{emojis['0']} — В главное меню</i>"
# =========================================================
# TRANSCRIPTION BOT
# =========================================================
# Модуль для расшифровки аудио и видео файлов
# --- Private functions (Internal logic) ---
def _transcription_welcome(emojis: dict) -> str:
return (
f"🎙 <b>Добро пожаловать! Я — Ассистент расшифровки совещаний.</b>\n\n"
f"Я помогу вам перевести аудио или видео в текст, а также подготовлю краткое резюме вашей встречи.\n\n"
f"📎 Для начала работы просто отправьте нужный файл прямо в этот чат.\n\n"
f"{emojis['0']} — В главное меню"
)
def _transcription_unknown(emojis: dict) -> str:
return (
f"⚠️ <b>Неизвестная команда.</b>\n\n"
f"Пожалуйста, отправьте в сообщении мне нужную цифру соответствующего пункта меню в чат."
)
def _transcription_bad_extension(ext: str, emojis: dict) -> str:
return (
f"❌ <b>Ошибка: Недопустимый формат файла ({ext}).</b>\n\n"
f"Пожалуйста, отправьте поддерживаемый аудио или видео файл.\n\n"
f"{_footer(emojis)}"
)
def _transcription_uploading() -> str:
return "⏳ <b>Загружаю файл...</b>"
def _transcription_success(email: str, emojis: dict) -> str:
return (
f"✅ <b>Хорошо, файл принят в обработку.</b>\n\n"
f"Как только всё будет готово, я отправлю вам результаты на почту (<code>{email}</code>).\n\n"
f"⏳ Обычно это занимает от 5 до 30 минут.\n\n"
f"{_footer(emojis)}"
)
def _transcription_api_error(status_code: int, emojis: dict) -> str:
return f"❌ <b>Ошибка API сервера ({status_code}).</b>{_footer(emojis)}"
def _transcription_system_error(emojis: dict) -> str:
return f"❌ <b>Произошла системная ошибка при передаче файла.</b>{_footer(emojis)}"
# --- Public constants and functions ---
TRANSCRIPTION_MAIN_MENU_TEXT = _transcription_welcome(EMOJI_DIGITS)
TRANSCRIPTION_UNKNOWN_CMD_TEXT = _transcription_unknown(EMOJI_DIGITS)
def transcription_error_bad_extension(ext: str) -> str:
return _transcription_bad_extension(ext, EMOJI_DIGITS)
TRANSCRIPTION_UPLOADING_TEXT = _transcription_uploading()
def transcription_success_received(email: str) -> str:
return _transcription_success(email, EMOJI_DIGITS)
def transcription_error_api(status_code: int) -> str:
return _transcription_api_error(status_code, EMOJI_DIGITS)
def transcription_error_system() -> str:
return _transcription_system_error(EMOJI_DIGITS)
# =========================================================
# SERVICE DESK BOT
# =========================================================
# Модуль для создания и управления заявками в Service Desk
# --- Private functions (Internal logic) ---
def _sd_welcome(emojis: dict) -> str:
return (
f"🎫 <b>Режим создания заявки в Service Desk</b>\n\n"
f"• Опишите проблему текстом.\n"
f"• Можно прикрепить фото или документ.\n\n"
f"{emojis['0']} — В главное меню"
)
def _sd_unknown(emojis: dict) -> str:
return (
f"⚠️ <b>Неизвестная команда.</b>\n"
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
f"{emojis['9']} — Назад\n"
f"{emojis['0']} — В главное меню"
)
def _sd_text_required(emojis: dict) -> str:
return f"⚠️ <b>Сначала опишите проблему текстом.</b>"
def _sd_creating() -> str:
return "⏳ Создаю заявку, пожалуйста, подождите..."
def _sd_ticket_created(ticket_id: int, subject: str, description: str, emojis: dict) -> str:
clean_desc = description.replace("\n", " ").strip()
if len(clean_desc) > 150:
clean_desc = clean_desc[:147] + "..."
return (
f"✅ <b>Заявка #{ticket_id} создана!</b>\n\n"
f"📌 <b>Тема: {subject}</b>\n\n"
f"📝 <b>Текст: {clean_desc}</b>\n\n"
f"Если нужно добавить скриншоты или документ — отправьте их сейчас.\n\n"
f"{_footer(emojis)}"
)
def _sd_ticket_create_error(emojis: dict) -> str:
return f"❌ <b>Ошибка создания заявки.</b>{_footer_main_only(emojis)}"
def _sd_system_error(emojis: dict) -> str:
return f"❌ <b>Системная ошибка.</b> Попробуйте позже.{_footer_main_only(emojis)}"
def _sd_uploading_file(ticket_id: int, emojis: dict) -> str:
return f"⏳ Загружаю файл в заявку #{ticket_id}..."
def _sd_file_attached(emojis: dict) -> str:
return f"✅ <b>Файл успешно прикреплен!</b>{_footer(emojis)}"
def _sd_file_upload_error(emojis: dict) -> str:
return f"❌ <b>Ошибка загрузки файла.</b>{_footer_main_only(emojis)}"
# --- Public constants and functions ---
SD_MAIN_MENU_TEXT = _sd_welcome(EMOJI_DIGITS)
SD_UNKNOWN_CMD_TEXT = _sd_unknown(EMOJI_DIGITS)
SD_TEXT_REQUIRED = _sd_text_required(EMOJI_DIGITS)
SD_CREATING = _sd_creating()
def sd_ticket_created(ticket_id: int, subject: str, description: str) -> str:
return _sd_ticket_created(ticket_id, subject, description, EMOJI_DIGITS)
def sd_ticket_create_error() -> str:
return _sd_ticket_create_error(EMOJI_DIGITS)
def sd_system_error() -> str:
return _sd_system_error(EMOJI_DIGITS)
def sd_uploading_file(ticket_id: int) -> str:
return _sd_uploading_file(ticket_id, EMOJI_DIGITS)
def sd_file_attached() -> str:
return _sd_file_attached(EMOJI_DIGITS)
def sd_file_upload_error() -> str:
return _sd_file_upload_error(EMOJI_DIGITS)
# =========================================================
# PHOTO BOT
# =========================================================
# Модуль для обновления фото профиля
# --- Private functions (Internal logic) ---
def _photo_welcome(emojis: dict) -> str:
return (
f"🖼 <b>Режим обновления фото профиля</b>\n\n"
f"📸 Пришлите мне вашу фотографию.\n\n"
f"{emojis['0']} — В главное меню"
)
def _photo_unknown(emojis: dict) -> str:
return (
f"⚠️ <b>Неизвестная команда.</b>\n"
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
f"{emojis['9']} — Назад\n"
f"{emojis['0']} — В главное меню"
)
def _photo_error_handling(emojis: dict) -> str:
return (
f"⚠️ <i>В данный момент выполнить это действие невозможно. "
f"Информация о проблеме передана ответственным.\n"
f"Приносим извинения за доставленные неудобства.</i>\n\n"
f"{emojis['0']} — В главное меню"
)
def _photo_uploading() -> str:
return "⏳ Отправка..."
def _photo_success_sent() -> str:
return "📩 <b>Фото отправлено на утверждение.</b>"
def _photo_cancel(emojis: dict) -> str:
return (
f"🚫 Отменено. Пришлите другое фото.\n\n"
f"{emojis['0']} — В главное меню"
)
def _photo_processing() -> str:
return "⏳ Обработка..."
def _photo_preview_ready(emojis: dict) -> str:
return (
f"... <b>Фото готово!</b>\n\n"
f"{emojis['1']} — Отправить на утверждение\n"
f"{emojis['2']} — Попробовать другое\n\n"
f"{emojis['0']} — В главное меню"
)
def _photo_error_processing(error_text: str, emojis: dict) -> str:
return (
f"❌ <b>Ошибка:</b> {error_text}\n\n"
f"Пожалуйста, отправьте другое фото.\n\n"
f"{emojis['0']} — В главное меню"
)
# --- Public constants and functions ---
PHOTO_MAIN_MENU_TEXT = _photo_welcome(EMOJI_DIGITS)
PHOTO_UNKNOWN_CMD_TEXT = _photo_unknown(EMOJI_DIGITS)
def photo_error_handling() -> str:
return _photo_error_handling(EMOJI_DIGITS)
PHOTO_UPLOADING_TEXT = _photo_uploading()
def photo_success_sent() -> str:
return _photo_success_sent()
def photo_cancel_text() -> str:
return _photo_cancel(EMOJI_DIGITS)
PHOTO_PROCESSING_TEXT = _photo_processing()
def photo_preview_ready() -> str:
return _photo_preview_ready(EMOJI_DIGITS)
def photo_error_processing(error_text: str) -> str:
return _photo_error_processing(error_text, EMOJI_DIGITS)
# =========================================================
# SEARCH BOT
# =========================================================
# Модуль поиска по регламентам
# --- Private functions (Internal logic) ---
def _search_welcome(emojis: dict) -> str:
return (
f"📚 <b>Поиск по регламентам</b>\n\n"
f"Введите ваш вопрос (например: <i>'время хранения электронных документов'</i> ).\n"
f"Бот найдет наиболее подходящие абзацы из нормативных документов.\n\n"
f"Для возврата в главное меню отправьте {emojis['0']}."
)
def _search_unknown(emojis: dict) -> str:
return (
f"⚠️ <b>Неизвестная команда.</b>\n"
f"Пожалуйста, используйте цифры меню из сообщения выше.\n\n"
f"{emojis['9']} — Назад\n"
f"{emojis['0']} — В главное меню"
)
def _search_searching() -> str:
return "🔍 <b>Ищу информацию в регламентах...</b>"
def _search_not_found(emojis: dict) -> str:
return (
f"🤖 Точной информации в регламентах не найдено.\n\n"
f"Для выхода отправьте {emojis['0']}."
)
def _search_feedback_thanks(emojis: dict) -> str:
return (
f"🙏 <b>Спасибо за ваш отзыв!</b> Мы передали его разработчикам.\n\n"
f"🔍 <b>Можете сразу задать следующий вопрос по регламентам.</b>\n\n"
f"Для выхода отправьте {emojis['0']}."
)
def _search_feedback_info(emojis: dict) -> str:
return (
f"⚠️ <b>Информация передана.</b>\n\n"
f"Если желаете, напишите прямо сейчас текстовым сообщением, что конкретно вам не понравилось в ответе.\n\n"
f"{emojis['1']} — Задать ещё вопрос\n"
f"{emojis['0']} — Выйти в главное меню"
)
def _search_unavailable(emojis: dict) -> str:
return (
"⚠️ <i>В данный момент выполнить это действие невозможно. "
"Информация о проблеме передана ответственным.\n"
"Приносим извинения за доставленные неудобства.</i>\n\n"
f"<i>{emojis['0']} — В главное меню</i>"
)
# --- Public constants and functions ---
SEARCH_MAIN_MENU_TEXT = _search_welcome(EMOJI_DIGITS)
SEARCH_UNKNOWN_CMD_TEXT = _search_unknown(EMOJI_DIGITS)
def search_searching_text() -> str:
return _search_searching()
def search_not_found_text(emojis: dict) -> str:
return _search_not_found(emojis)
def search_feedback_thanks_text(emojis: dict) -> str:
return _search_feedback_thanks(emojis)
def search_feedback_info_text(emojis: dict) -> str:
return _search_feedback_info(emojis)
def search_unavailable_action(emojis: dict) -> str:
return _search_unavailable(emojis)
# =========================================================
# LK BOT
# =========================================================
# Модуль Личного Кабинета (финансовые документы, табель и др.)
# --- Constants ---
DOC_TYPES = {
"1": "Справка о доходах физического лица (2-НДФЛ)",
"2": "Справка об удержаниях за ДМС",
"3": "Справка о средней заработной плате",
"4": "Справка о получении единовременного пособия по рождению ребенка",
"5": "Справка о получении ежемесячного пособия и назначении отпуска по уходу за ребенком",
"6": "Справка об удержаниях по исполнительным листам",
"7": "Копия трудовой книжки"
}
# --- Private functions (Internal logic) ---
def _lk_error_handling(emojis: dict) -> str:
return (
"⚠️ <i>В данный момент выполнить это действие невозможно. Информация о проблеме передана ответственным. Приносим извинения за доставленные неудобства.</i>\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _lk_auth_processing(emojis: dict) -> str:
return "⏳ <i>Проверка безопасности... Запрашиваю контакты авторизации...</i>"
def _lk_auth_success(emojis: dict) -> str:
return "⏳ <i>Авторизация успешна. Загружаю детализацию расчетного листа...</i>"
def _lk_auth_2fa_expired(emojis: dict) -> str:
return f"⏱ <b>Время действия кода подтверждения изменилось или истекло.</b>\nПожалуйста, запросите расчетный лист заново.{emojis['9']} — Назад"
def _lk_auth_2fa_wrong_code(emojis: dict, attempts_left: int) -> str:
return (
f"⚠️ <b>Неверный код подтверждения.</b>\nОсталось попыток: {attempts_left}. Попробуйте еще раз или вернитесь назад.\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _lk_auth_2fa_blocked_max(emojis: dict, minutes: int) -> str:
return (
f"❌ <b>Превышено максимальное количество попыток ввода кода.</b>\nДоступ к финансовым документам заблокирован на <b>{minutes} минут</b>.<br>\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _lk_payslip_loading(emojis: dict) -> str:
return "⏳ <i>Формирую архив расчетных листков...</i>"
def _lk_vacation_loading(emojis: dict) -> str:
return "⏳ <i>Формирую справку по отпускам...</i>"
def _lk_sick_loading(emojis: dict) -> str:
return "⏳ <i>Загружаю историю больничных листов...</i>"
def _lk_missions_loading(emojis: dict) -> str:
return "⏳ <i>Загружаю историю командировок...</i>"
def _lk_rewards_loading(emojis: dict) -> str:
return "⏳ <i>Ищу информацию о ваших наградах...</i>"
def _lk_transfers_loading(emojis: dict) -> str:
return "⏳ <i>Загружаю историю кадровых перемещений...</i>"
def _lk_tabel_loading(emojis: dict) -> str:
return "⏳ <i>Рассчитываю баланс рабочего времени...</i>"
def _lk_auth_sending(emojis: dict) -> str:
return "⏳ <i>Отправка заявки...</i>"
def _lk_success_submission(emojis: dict) -> str:
return (
"✅ <b>Ваша заявка успешно отправлена!</b>\n"
"Специалисты свяжутся с вами по готовности документа.\n\n"
f"<i>{emojis['9']} — Назад в список справок\n{emojis['0']} — В главное меню</i>"
)
def _lk_main_menu_text(header: str, emojis: dict) -> str:
return (
f"{header}\n"
f"{emojis['1']} — Расчётный лист\n"
f"{emojis['2']} — Информация о днях отпуска\n"
f"{emojis['3']} — Больничные\n"
f"{emojis['4']} — Командировки\n"
f"{emojis['5']} — Награды\n"
f"{emojis['6']} — Переводы\n"
f"{emojis['7']} — Табель учёта рабочего времени\n"
f"{emojis['8']} — Справки\n\n"
f"<i>{emojis['0']} — Вернуться в главное меню</i>"
)
def _lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis: dict) -> str:
return (
"⏱ <b>Выберите месяц для просмотра табеля:</b>\n\n"
f"{emojis['1']} — {months_ru[prev_1.month]} {prev_1.year} г.\n"
f"{emojis['2']} — {months_ru[prev_2.month]} {prev_2.year} г.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _lk_payslip_months_menu_text(months_data: list, months_ru: dict, emojis: dict) -> str:
menu_text = "📅 <b>Выберите месяц для просмотра:</b>\n\n"
for idx, cmonth in enumerate(months_data, start=1):
month_name = months_ru.get((cmonth - 1) % 12 + 1, '00')
year = (cmonth - 1) // 12
menu_text += f"{emojis[str(idx)]} — {month_name} {year} г.\n"
menu_text += f"\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
return menu_text
def _lk_docs_quantity_prompt(emojis: dict) -> str:
return f"Укажите необходимое количество экземпляров (максимум — 5 шт.):\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_docs_year_prompt(emojis: dict) -> str:
return f"📅 <b>Выберите год, за который требуется справка:</b>\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_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>"
def _lk_docs_child_age_prompt(emojis: dict) -> str:
return f"👶 <b>Выберите возраст ребенка (до скольки лет):</b>\n\n{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_docs_period_prompt(year: int, emojis: dict) -> str:
return f"за {year} год\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_docs_child_age_options(emojis: dict) -> str:
return f"{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_docs_period_options(emojis: dict) -> str:
return f"{emojis['1']} — до 1.5 лет\n{emojis['2']} — до 3 лет\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
def _lk_nav_text(emojis: dict) -> str:
return f"\n\n<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
# --- Public constants and functions ---
LK_DOCS_MENU_TEXT = (
"\n".join([f"{EMOJI_DIGITS[k]} — {v}" for k, v in DOC_TYPES.items()]) +
f"\n\n<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
LK_UNKNOWN_CMD_TEXT = (
"⚠️ <b>Неизвестная команда.</b>\n"
"Пожалуйста, выберите пункт меню, используя цифры или кнопки.<br>\n"
f"<i>{EMOJI_DIGITS['9']} — Назад\n{EMOJI_DIGITS['0']} — В главное меню</i>"
)
def lk_error_handling(emojis: dict) -> str:
return _lk_error_handling(emojis)
def lk_auth_processing(emojis: dict) -> str:
return _lk_auth_processing(emojis)
def lk_auth_success(emojis: dict) -> str:
return _lk_auth_success(emojis)
def lk_auth_2fa_expired(emojis: dict) -> str:
return _lk_auth_2fa_expired(emojis)
def lk_auth_2fa_wrong_code(emojis: dict, attempts_left: int) -> str:
return _lk_auth_2fa_wrong_code(emojis, attempts_left)
def lk_auth_2fa_blocked_max(emojis: dict, minutes: int) -> str:
return _lk_auth_2fa_blocked_max(emojis, minutes)
def lk_payslip_loading(emojis: dict) -> str:
return _lk_payslip_loading(emojis)
def lk_vacation_loading(emojis: dict) -> str:
return _lk_vacation_loading(emojis)
def lk_sick_loading(emojis: dict) -> str:
return _lk_sick_loading(emojis)
def lk_missions_loading(emojis: dict) -> str:
return _lk_missions_loading(emojis)
def lk_rewards_loading(emojis: dict) -> str:
return _lk_rewards_loading(emojis)
def lk_transfers_loading(emojis: dict) -> str:
return _lk_transfers_loading(emojis)
def lk_tabel_loading(emojis: dict) -> str:
return _lk_tabel_loading(emojis)
def lk_auth_sending(emojis: dict) -> str:
return _lk_auth_sending(emojis)
def lk_success_submission(emojis: dict) -> str:
return _lk_success_submission(emojis)
def lk_main_menu_text(header: str, emojis: dict) -> str:
return _lk_main_menu_text(header, emojis)
def lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis: dict) -> str:
return _lk_tabel_months_menu_text(prev_1, prev_2, months_ru, emojis)
def lk_payslip_months_menu_text(months_data: list, months_ru: dict, emojis: dict) -> str:
return _lk_payslip_months_menu_text(months_data, months_ru, emojis)
def lk_docs_quantity_prompt(emojis: dict) -> str:
return _lk_docs_quantity_prompt(emojis)
def lk_docs_year_prompt(emojis: dict) -> str:
return _lk_docs_year_prompt(emojis)
def lk_docs_avg_salary_prompt(emojis: dict) -> str:
return _lk_docs_avg_salary_prompt(emojis)
def lk_docs_child_age_prompt(emojis: dict) -> str:
return _lk_docs_child_age_prompt(emojis)
def lk_docs_period_prompt(year: int, emojis: dict) -> str:
return _lk_docs_period_prompt(year, emojis)
def lk_docs_child_age_options(emojis: dict) -> str:
return _lk_docs_child_age_options(emojis)
def lk_docs_period_options(emojis: dict) -> str:
return _lk_docs_period_options(emojis)
def lk_nav_text(emojis: dict) -> str:
return _lk_nav_text(emojis)
def lk_years_menu(current_year: int, emojis: dict) -> str:
return _lk_years_menu(current_year, emojis)
# =========================================================
# INSTRUCT BOT (Инструкции)
# =========================================================
# Модуль инструкций
# --- Private functions (Internal logic) ---
def _instruct_main_menu(emojis: dict) -> str:
return (
f"📋 <b>Выберите инструкцию:</b>\n\n"
f"{emojis['1']} — 🖥 Trueconf\n"
f"{emojis['2']} — 📧 Почта\n\n"
f"<i>{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf(emojis: dict) -> str:
"""Статический текст Trueconf — без проверки группы."""
return (
"🖥 <b>Инструкция по Trueconf</b>\n\n"
"Данная функция находится в разработке.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
def _instruct_trueconf_with_ad(emojis: dict, cn: str, ticket_id: str = None) -> str:
"""
Текст Trueconf с проверкой группы 2FA.
cn — CN пользователя из AD.
ticket_id — номер созданной заявки в SD.
"""
from utils.ad_checker import check_group_membership
result = check_group_membership(cn, "2FA")
if not result["found"]:
ticket_part = f"\n\n🎫 <b>Заявка #{ticket_id} создана!</b>" if ticket_id else ""
return (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"Пользователь {cn} не найден в Active Directory.{ticket_part}\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
if result["in_group"]:
body = (
f"🖥 <b>Инструкция по Trueconf</b>\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
else:
body = (
f" <b>Доступ к Trueconf пока не настроен.</b>\n\n"
f"🎫 Заявка #23651 для настройки создана в ServiceDesk!\n\n"
f"🔄 После выполнения заявки запросите инструкцию ещё раз.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
return body
def _instruct_email(emojis: dict) -> str:
return (
"📧 <b>Инструкция по Почте</b>\n\n"
"Данная функция находится в разработке.\n\n"
f"<i>{emojis['9']} — Назад\n{emojis['0']} — В главное меню</i>"
)
# --- Public constants and functions ---
INSTRUCT_MAIN_MENU_TEXT = _instruct_main_menu(EMOJI_DIGITS)
INSTRUCT_TRUECONF_TEXT = _instruct_trueconf(EMOJI_DIGITS)
INSTRUCT_TRUECONF_WITH_AD = _instruct_trueconf_with_ad
INSTRUCT_EMAIL_TEXT = _instruct_email(EMOJI_DIGITS)