Compare commits

..
248 Commits
Author SHA1 Message Date
gitadmin 87ccd23e96 [ZILANT] коррекция логики проверки добавленных ссылок
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2026-09-02 22:49:03 +03:00
gitadmin f74953e082 [ZILANT] логирование ответа АИ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 22:19:58 +03:00
gitadmin 709178d9ea [ZILANT] отладка добавления ссылок 02
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:51:18 +03:00
gitadmin ac17043a08 [ZILANT] отладка до-редактирования ссылок в пост
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:40:29 +03:00
gitadmin 62a77dba78 Коррекция логики добавления ссылок при [ZILANT] постинге в ТГ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:32:35 +03:00
gitadmin ea6c5e5a7d [ZILANT] обработка пустых постов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 12:33:10 +03:00
gitadmin 80e0847329 [ZILANT] перенаправление лога автораннера
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 12:06:42 +03:00
gitadmin c7ba0479ba fix accidental local overwrite
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:49:52 +03:00
gitadmin d7e105d986 [ZILANT] отправлять картинки при посте событий как файл, а не как URL; делаем как при репосте ВК постов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:37:19 +03:00
gitadmin 352c0da209 update .env for debug mode
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:18:40 +03:00
gitadmin 4500cf5bf4 создан шаблон .env.example
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:02:30 +03:00
gitadmin a1aadb6393 создан шаблон .env файла конфигурации 2026-08-30 11:02:19 +03:00
gitadmin 251fa0be11 реструктуризация файлов проекта
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 10:08:43 +03:00
gitadmin adc49c285c delete gitignored file
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 20:19:43 +03:00
gitadmin 1b080990fa Add .gitignore for season_links.pyc
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-02 20:17:04 +03:00
gitadmin 7af6748c74 [ZILANT] вывод __pycache__ в gitignore 2026-08-02 20:16:11 +03:00
gitadmin e0e27ad569 [ZILANT] Защита от старых подписок
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-02 19:54:39 +03:00
gitadmin 739e7171ea [ZILANT] коррекция работы фронта с долгим ответом ИИ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 16:52:48 +03:00
gitadmin 9b8b5514b8 [ZILANT] Переход на RouterAI
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 16:42:51 +03:00
gitadmin 7c59c0d5e2 [ZILANT] доработка параметров setwebhook для корректных callback'ов кнопок подписки
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 16:09:42 +03:00
gitadmin 2797183834 [ZILANT] debug 2 - test reg script
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:50:44 +03:00
gitadmin 72c7d38609 [ZILANT] debug - failing secret code
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:41:37 +03:00
gitadmin 116b9bca10 [ZILANT] обновление реквизитов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:31:05 +03:00
gitadmin c5ba5d6462 [ZILANT] изменение регистрации респондер-бота для корректного проброса секретного кода
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:25:07 +03:00
gitadmin 4795079dce [ZILANT] обновление механизма setwebhook с секретным токеном
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:10:59 +03:00
gitadmin 8c3a62d021 [ZILANT] убрать зацикливание запроса на вебхук
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:05:14 +03:00
gitadmin 064d22cfd8 [ZILANT] настройка запуска респондер-бота через релей
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 14:28:57 +03:00
gitadmin 0b5f20b748 [ZILANT] Установка дефолтной схемы при ее отсутствии в RELAY_URL 2026-08-02 14:18:24 +03:00
gitadmin df45f6c83c [ZILANT] переход на тг-релей, смена базы.
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 13:37:01 +03:00
gitadmin 7c4efdbc8e [ВОЛК] коррекция бота под повторные попытки
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2026-01-11 19:05:01 +03:00
gitadmin b0f94c69e2 [ВОЛК] отладочное логгирование
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-11 18:40:07 +03:00
gitadmin b2ead01f15 [ВОЛК] отладочные выводы бота в лог
ci/woodpecker/push/woodpecker Pipeline failed
2026-01-11 18:26:13 +03:00
gitadmin 2d59cac00b [ВОЛК] журналирование в боте
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 23:15:22 +03:00
gitadmin 3e793aff91 [ВОЛК] проверка с прод ботом информером
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 22:20:32 +03:00
gitadmin a8f61f724f [ВОЛК] retrying logic и другая библиотека
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 22:11:15 +03:00
gitadmin 96e7bb3da1 [ВОЛК] Тест кнопка на фронте
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 21:03:48 +03:00
gitadmin 720ba0ed36 [ВОЛК] Multiple retries
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 20:45:22 +03:00
gitadmin 93930717a7 [ВОЛК] отладка поста изображения
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 19:23:57 +03:00
gitadmin c7ca9cf487 [ВОЛК] увеличиваем таймауты
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 19:18:40 +03:00
gitadmin e9d190254d [ВОЛК] отладочный постер
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 19:08:14 +03:00
gitadmin 906aa4ed93 [ВОЛК] Socker timeout
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 18:35:10 +03:00
gitadmin 9580d167bd [ВОЛК] коррекция формата таймаута
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 18:19:34 +03:00
gitadmin 7608afa77b [ВОЛК] фикс ConnectionCls
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 18:16:47 +03:00
gitadmin 538213eae9 [ВОЛК] очередная отладка таймаутов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 18:13:25 +03:00
gitadmin 8087908c27 [ВОЛК] добавление резервного постер-бота
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 17:41:10 +03:00
gitadmin feed7d641a [ВОЛК] временно переход на волк-информер-бот
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 17:33:22 +03:00
gitadmin f9aa9dfb29 [ВОЛК] правка логики реадктирования
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 17:02:58 +03:00
gitadmin 2f0c2766aa [ВОЛК] переход на либу requests
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 16:31:40 +03:00
gitadmin 5e95fd2d9d [ВОЛК] еще более синхронный код
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 16:10:56 +03:00
gitadmin 972474ad45 [ВОЛК] вводим ANYIO BACKEND для синхронного потока загрузки картинок
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 15:58:18 +03:00
gitadmin 54ddc05058 [ВОЛК] увеличение таймаутов на запись и в gunicorn
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 15:45:40 +03:00
gitadmin c999898c58 [ВОЛК] переключение на отладочный бот
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 15:39:19 +03:00
gitadmin 5b2d09f4be [ВОЛК] переключение на отправку изображения файлом
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 15:26:10 +03:00
gitadmin 8556b1f508 [ВОЛК] оптимизируем работу с event_loop
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 14:54:31 +03:00
gitadmin bccf3e7717 [ВОЛК] улучшение трассировки таймаута при постинге в телеграм
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 14:45:57 +03:00
gitadmin d908c28b5d [ВОЛК] детальный лог телего-ошибок в tg_publish
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 14:21:51 +03:00
gitadmin a5705b2ceb [ВОЛК] временно переключиться на бота ВОЛК-информер в отладке
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 14:08:01 +03:00
gitadmin 66af108914 [ВОЛК] упрощение лога ensure_db
ci/woodpecker/push/woodpecker Pipeline was successful
2026-01-10 13:31:03 +03:00
gitadmin 073628838c [ВОЛК] возврат в отладочную среду перед активацией на проде
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-29 20:58:36 +03:00
gitadmin 7f916cf0f9 [ВОЛК] фикс опечатки в имени переменной рабочего режима
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-28 23:42:58 +03:00
gitadmin d3dab7a96e [ВОЛК] фикс названия респондер-бота
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-28 23:27:24 +03:00
gitadmin 0cfa26152b [ВОЛК] подключаем бота подписки
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-28 23:20:11 +03:00
gitadmin c8f1cc2595 [ВОЛК] runner-timer
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 02:14:34 +03:00
gitadmin 82bb7984e9 [ВОЛК] загрузка событий из командной строки
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 02:04:26 +03:00
gitadmin 208a388e53 [ВОЛК] флаг автопубликации событий и категорий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 01:53:51 +03:00
gitadmin cca4afd9a5 [ВОЛК] фиксим редактирование без изменения текста
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 01:33:43 +03:00
gitadmin 33068735b5 [ВОЛК] исправление отметки о публикации события
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 01:20:01 +03:00
gitadmin 0bae7ee6de ...services back
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 00:54:11 +03:00
gitadmin 37d56e2e78 ...return to normal testing operation
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 00:52:55 +03:00
gitadmin e961d0ee90 ...secret forth
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 00:48:00 +03:00
gitadmin 5a9a4514e1 ... secret back
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 00:46:58 +03:00
gitadmin 93a99f758b [ВОЛК] будет ли работать без настроек респондер-бота?
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 00:43:58 +03:00
gitadmin 0576e98f5d [ВОЛК] продолжение
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-26 00:37:15 +03:00
gitadmin f16b8417b0 [ВОЛК] тест сервис
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-26 00:34:16 +03:00
gitadmin 825428bca3 [ВОЛК] starting prod...
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-26 00:28:13 +03:00
gitadmin 30a4737db5 [ВОЛК] включение автопубликации
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 23:41:33 +03:00
gitadmin 008ecbd3ed [ВОЛК] редактируемость факта и даты публикации площадки
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 23:20:23 +03:00
gitadmin 84cc0573e5 [ВОЛК] изменение формата выдачи ссылок при публикации события
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 23:02:23 +03:00
gitadmin bc8b1569aa [ВОЛК] исправление поля marked_to_publication
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 22:38:04 +03:00
gitadmin 1cda8969db [ВОЛК] отметка всех записей для публикации
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 22:31:50 +03:00
gitadmin 179d78a750 [ВОЛК] первичная публикация категорий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 22:17:51 +03:00
gitadmin 837d5a171f [ВОЛК] фикс повторного редактирования категории 2025-12-25 21:54:00 +03:00
gitadmin 685938d9f7 [ВОЛК] фикс возврата на свою страницу из редактора площадок
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 21:38:08 +03:00
gitadmin 2cbc2a1b46 [ВОЛК] редактирование площадок
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 21:29:58 +03:00
gitadmin a25ac707e8 [ВОЛК] фикс отображения площадок
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 21:21:04 +03:00
gitadmin 399b02d29f [ВОЛК] Выделение URL изображения из описания категории
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 21:10:06 +03:00
gitadmin 2284fd3263 [ВОЛК] управление публикацией площадок
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 20:59:18 +03:00
gitadmin 0490135d9e [ВОЛК] отображение картинки в детализации площадки
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 20:42:03 +03:00
gitadmin 4703043062 [ВОЛК] парсинг markdown-ссылок при публикации в теолеграм
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 20:37:06 +03:00
gitadmin e339cac7d9 [ВОЛК] добавление поля URL картинки в таблицу категорий (площадок)
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 20:31:01 +03:00
gitadmin 18e7aaef34 [ВОЛК] добавление поля tg_id в таблицу категорий (площадок)
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 20:23:14 +03:00
gitadmin e03fb9a872 [ВОЛК] добавление закладки Площадки
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-25 20:15:02 +03:00
gitadmin 26d12a4758 [ВОЛК] коррекция синтаксиса
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 19:37:24 +03:00
gitadmin 0d411bb5ce [ВОЛК] переход tg_publish на httpx_client
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-21 19:26:09 +03:00
gitadmin 5c46c0bcf0 [ВОЛК] Переход evtg_publish на httpx_client
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 19:14:08 +03:00
gitadmin e3de30e429 [ВОЛК] доработка тестового httpx постера до картинок
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 18:55:15 +03:00
gitadmin ebaa7e89e4 [ВОЛК] переход на прямого httpx клиента для постинга в телеграм
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 18:44:34 +03:00
gitadmin de7fc5ad8a [ВОЛК] еще одна попытка
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 17:09:43 +03:00
gitadmin d493cea3dc [ВОЛК] добавляем httpx
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 17:05:59 +03:00
gitadmin 3cbfc15bfd [ВОЛК] отключаем http/2
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 16:51:59 +03:00
gitadmin 6246a75458 [ВОЛК] Новый отладочный бот
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 16:30:58 +03:00
gitadmin c1db85f6ee [ВОЛК] уточнение отладочного скрипта
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 16:04:16 +03:00
gitadmin 47960abc38 [ВОЛК] добавлен отладочный скрипт для ловли причин таймаута в телеге
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 15:55:39 +03:00
gitadmin cd5615779b [ВОЛК] ловим причину таймаута телеграм
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 15:33:25 +03:00
gitadmin d4770fbbac [ВОЛК] добавление полной диагностики telegram при ошибке
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 15:06:30 +03:00
gitadmin d89b4efcba [ВОЛК] снятие проверки времени изменений для ВОЛК; удаление лишних отладочных выводов в ВОЛК загрузчике
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 14:55:02 +03:00
gitadmin e095fc35bc [ВОЛК] увеличение таймаута до 120 сек для корректной обработки загрузки категорий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 14:34:03 +03:00
gitadmin 35c8a00822 [ВОЛК] увеличение таймаута gunicorn
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 14:19:24 +03:00
gitadmin 0ec925ec2f [ВОЛК] добавление User-agent
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 14:16:39 +03:00
gitadmin e1e5e725c7 [ВОЛК] добавление отладочного скрипта для ловли бага с volk26_nri
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 13:51:55 +03:00
gitadmin b25f65b6a1 [ВОЛК] volk26_nri, step 6
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 13:37:15 +03:00
gitadmin c2fd69cf39 [ВОЛК] volk26_nri, step 5
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 13:31:45 +03:00
gitadmin ae1d9eb8a7 [ВОЛК] volk26_nri, step 4 2025-12-21 13:30:07 +03:00
gitadmin 6c40abbb7f [ВОЛК] volk26_nri, step 3
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 13:25:00 +03:00
gitadmin 20b8f9353a [ВОЛК] volk26_uri, step 2
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 13:20:31 +03:00
gitadmin 58a8f1909f [ВОЛК] проверяем urlopen для отлова бага с volk26_nri
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 13:13:49 +03:00
gitadmin 8772eaaf3d [ВОЛК] исправление нереентерабельности логгирования
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 13:03:16 +03:00
gitadmin 53db795675 [ВОЛК] фикс вывода лога
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 12:51:32 +03:00
gitadmin aa417538a1 [ВОЛК] отладочные флаги
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 12:43:54 +03:00
gitadmin 7ad4b1b623 [ВОЛК] коррекция логирования ошибок
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 12:39:06 +03:00
gitadmin d622805d66 [ВОЛК] обработка ошибки загрузки описаний категорий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-21 12:23:39 +03:00
gitadmin 3a7710f009 Re-pushing changes e74779fa32 after fixing cicd
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-20 21:30:38 +03:00
gitadmin e6ebcaeeea add manual branch to cicd script
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
2025-12-20 21:25:40 +03:00
gitadmin 97847c7a99 test push, refreshing .env
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-20 21:20:56 +03:00
gitadmin 4fb1f39323 test fake update 2025-12-20 18:13:40 +03:00
gitadmin fde121a7c5 update log access 2025-12-20 16:59:23 +03:00
gitadmin 54453c1064 update .env 2025-12-20 16:58:18 +03:00
gitadmin e74779fa32 Обновление конфигурации API ВОЛК для нового события volk26 и улучшение логики обработки событий в коде. Теперь используется id_event для уникальной идентификации событий, что позволяет избежать проблем с дублированием и улучшает логику обновления записей. 2025-12-20 16:52:23 +03:00
gitadmin cf57eb3743 ВОЛК меняем логику отбора максимального ключа для продолжения добавления событий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 22:54:16 +03:00
gitadmin 5f9f9a3a1b [ВОЛК] при загрузке событий заполняется поле id_event для избегания проблем с индексами
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 22:45:53 +03:00
gitadmin 992fa14859 отладочный вывод скрыт под условие LOG_DEBUG_DATA
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 22:31:39 +03:00
gitadmin 3f27404959 улучшение логики форматирования ВК текста для публикации
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 22:26:09 +03:00
gitadmin 0546f3fc51 отладка - другой вариант вывода в лог
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 22:17:24 +03:00
gitadmin ced50eec66 отладка - вывод в лог отформатированного текста для публикации в телеграм 2025-11-15 22:09:40 +03:00
gitadmin 34fd7c25e8 обработка URL-ссылок в ВК-формате при публикации постов
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 21:49:34 +03:00
gitadmin 146e55bfa6 обработка репостов
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 21:37:15 +03:00
gitadmin 4a58a80401 [ВОЛК] загрузка всех событий по всем категориям и соответствующая кнопка
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 21:11:14 +03:00
gitadmin 2c84256634 Добавление поля authors в таблицу событий для соответствия функционалу API ВОЛК. Добавление функции загрузки событий одной категории.
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 20:40:51 +03:00
gitadmin 651aee2b9a добавление поля description в таблицу категорий (ВОЛК)
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-15 19:55:11 +03:00
gitadmin cd9cd739be Условия отображения и названия кнопок на закладке "События"
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-14 14:23:28 +03:00
gitadmin 41ccd110ba Добавление модуля загрузки категорий для API ВОЛК и соответствующей кнопки на фронте
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-14 14:00:29 +03:00
gitadmin c6642f6cda Удаление поля number из таблицы категорий, добавление версии API ВОЛК в .env
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-14 13:28:01 +03:00
gitadmin 345f8fd121 Передача в фронт режима работы VOLK/ZILANT
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-14 12:43:43 +03:00
gitadmin 57d6d016a7 Добавление таблицы категорий и .env линков на API ВОЛК
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-14 12:30:31 +03:00
gitadmin 6488f40613 switch to qwen AI model for naming
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-13 23:17:46 +03:00
gitadmin 7e33db323a Добавление выключателя ссылок на бота подписки
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-13 22:39:04 +03:00
gitadmin b19bd2cc84 BEGIN adding VOLK mode
ci/woodpecker/push/woodpecker Pipeline was successful
2025-11-13 22:13:39 +03:00
gitadmin b760035af9 Добавление тэга отмены автопубликации ВК-поста
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-31 16:36:22 +03:00
gitadmin faa5d6dd6f Режим публикации со звуком и без
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-29 23:23:15 +03:00
gitadmin ca89e22506 отключил evtg_prefetch
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-15 23:24:13 +03:00
gitadmin 40882045e4 Жирный шрифт названий постов в подписке
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-14 17:49:29 +03:00
gitadmin 7054c5fdd7 вывод ботом только опубликованных событий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-14 17:38:46 +03:00
gitadmin 9feb131e98 Отображение бейджей связанных объектов в детализации
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-14 17:04:42 +03:00
gitadmin 68502ce2f0 Автолинковка событий и постов по площадкам. Управление автопубликацией событий (перенос функций evtg_prefetch)
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 22:37:35 +03:00
gitadmin ab3c32c773 Ред пост 16 180px
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:55:18 +03:00
gitadmin 5657ee5c9f Ред пост 15 custom-wide
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:40:12 +03:00
gitadmin c272a157ff Ред пост 14
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:33:55 +03:00
gitadmin 046ff4c7e5 Ред пост 13 100px
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:27:08 +03:00
gitadmin 2411391d18 Ред пост 12 add flex
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:23:51 +03:00
gitadmin e8dd53a4e9 Ред пост 11
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:19:20 +03:00
gitadmin 6ff1b980a5 Откат Ред пост 10
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:14:33 +03:00
gitadmin 2ec5438675 Ред пост 10
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:06:37 +03:00
gitadmin a50fd3823a Ред пост 9
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 21:02:26 +03:00
gitadmin 1814d7b819 Ред пост 8
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 20:55:46 +03:00
gitadmin 45c0671326 Ред пост 7
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 20:48:19 +03:00
gitadmin 9ab01a5f74 Ред пост 6
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 20:33:18 +03:00
gitadmin 9e70b1aea4 Ред пост 5
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 19:38:30 +03:00
gitadmin 2df357a8a0 Ред пост 4
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 19:31:05 +03:00
gitadmin 7f9c80849e Ред пост 3
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 19:24:32 +03:00
gitadmin e889717527 Правка интерфейса редактирования постов - 2
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 19:18:35 +03:00
gitadmin 8c76d98748 правка интерфейса редактирования постов - 1
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 19:13:00 +03:00
gitadmin aa5a1f03ad переделка интерфейса редактора
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 18:57:24 +03:00
gitadmin c13d4a9520 исправление дубля get_venue
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 18:07:30 +03:00
gitadmin 8777426756 интерфейсные правки для auto_unit
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 18:03:22 +03:00
gitadmin a8c9d158aa добавление поля auto_unit
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 17:53:07 +03:00
gitadmin 69bf49f3a9 убрать проверку времени в evt_prefetch
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 12:59:24 +03:00
gitadmin da304f6455 remove debug prints
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 12:47:18 +03:00
gitadmin ad5e70c350 set PROCESS_EVENT_UPDATES to false in .env & fix boolean value load
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 12:44:42 +03:00
gitadmin 4d55698581 debug prints to track PROCESS_EVENT_UPDATES
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 12:39:03 +03:00
gitadmin 9f4cc757eb checking PROCESS_EVENT_UPDATES import
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 12:16:08 +03:00
gitadmin 0905b21570 Добавление PROCESS_EVENT_UPDATES и возможности отключать этой переменной автопостинг для обновляемых событий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-10-12 11:55:44 +03:00
gitadmin f1b8e5dbc8 Отправка сообщений работает. Подгружаем адреса и порты из .env
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-30 00:00:15 +03:00
gitadmin ed408e9ba9 https заработал, проверим прямой IP
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:53:56 +03:00
gitadmin ae3fd8652d update API links
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:49:47 +03:00
gitadmin 6e3a2fbdf7 localhost
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:44:47 +03:00
gitadmin 9850477a6a https again
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:42:41 +03:00
gitadmin 28c37a3cec прямой IP линк 2025-09-29 23:39:13 +03:00
gitadmin 96a3f1ee7f https link
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:18:02 +03:00
gitadmin 50b1faef59 прямой IP
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:15:18 +03:00
gitadmin 9a98cffe48 уточнение диагностики отправки сообщения
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:11:00 +03:00
gitadmin a5d3b82092 настройка отправки сообщения через правильный порт и ключ
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 23:04:50 +03:00
gitadmin af755115c8 отправка сообщения через бота
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 22:43:20 +03:00
gitadmin 824a7978ac синхронизировать правую панель с выбранной записью при поиске
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 21:51:47 +03:00
gitadmin 6bb675ea78 добавление поиска
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 21:45:50 +03:00
gitadmin dc7713ce1f check copied database
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 20:40:04 +03:00
gitadmin 2a84f1ddf1 dbcopier
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 20:37:42 +03:00
gitadmin 7602c60bd6 групповая привязка по тэгам
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 19:51:02 +03:00
gitadmin f40dbbf41d переразмещение кнопки групповой привязки
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 16:07:59 +03:00
gitadmin 8c02770fe3 групповая привязка событий к постам по площадкам
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 15:50:57 +03:00
gitadmin 99c5a3b55a коррекция заголовка после починки скролла
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 14:55:05 +03:00
gitadmin e3d941b589 еще попытка
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 14:04:32 +03:00
gitadmin 2af194cfe6 еще попытка
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 13:47:43 +03:00
gitadmin f58eb00ba4 еще одна попытка
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 13:44:44 +03:00
gitadmin da73f650b8 следующая попытка
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 13:37:16 +03:00
gitadmin 25cd77ec92 вертикальный скролл, попытка 4
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 13:32:11 +03:00
gitadmin 8c8a8970d1 вертикальный скролл, попытка 3
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 13:23:27 +03:00
gitadmin 1b42ba1709 вертикальный скролл - попытка 2
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 13:11:52 +03:00
gitadmin 2fe47b7d2d вертикальный скролл правой панели
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-29 13:03:54 +03:00
gitadmin 784ef64971 коррекция высоты бейджей публикации
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 21:24:57 +03:00
gitadmin c898c5b60d обновление отображения детализации событий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 21:17:53 +03:00
gitadmin f0255fe21e добавить отступ между картинкой и текстом поста
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 20:33:35 +03:00
gitadmin bf728ea11b вторая попытка коррекции высоты блоков
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 20:30:24 +03:00
gitadmin 2901724e40 вертикальный ресайз блоков подписки, опроса и связанных событий
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 20:15:33 +03:00
gitadmin c91c0d6e1c коррекция высоты блоков подписки и связанных событий, декодировка представления текста опроса
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 20:09:41 +03:00
gitadmin b8189e9b12 change post details tab
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 19:59:19 +03:00
gitadmin d7c38e112a fix workdir in services
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 19:03:39 +03:00
gitadmin 4ba82c3c7d subscriptions visualisation in mainbot
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 18:53:10 +03:00
gitadmin f05047c6a0 reconfigure exclusions
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 18:34:16 +03:00
gitadmin 421a484d2b exclude .git from wp rsync
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-28 18:20:20 +03:00
gitadmin 6f3156a4ac add post-deploy script to wp pipeline
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-28 18:09:05 +03:00
gitadmin 3e0c79855f remove ensure_db from post-deploy script
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 22:56:34 +03:00
gitadmin 243051d3f5 load_dotenv()
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 22:38:43 +03:00
gitadmin 9a6f39c7c4 fix env data passing to script
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 22:30:55 +03:00
gitadmin 7135c0c364 post-deploy - fixing .env parcing
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 22:22:55 +03:00
gitadmin b4b4f055d9 post-deploy - read .env correctly
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 22:20:42 +03:00
gitadmin 4b42eac739 add post-deploy logging
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 22:15:05 +03:00
gitadmin 1c0f4cceb1 restart from correct test code
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 21:48:56 +03:00
gitadmin 6da846af74 script for post-deploy and services files
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 21:07:40 +03:00
gitadmin 5e846b0984 remove secret tracker from wp.yml
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 19:50:06 +03:00
gitadmin c831e741c1 from_secret
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-27 01:26:55 +03:00
gitadmin 922635aa98 next debug
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-27 01:13:28 +03:00
gitadmin aff7b361cb debug #8
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-27 01:00:11 +03:00
gitadmin d034d0466d debug #7
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-27 00:50:23 +03:00
gitadmin 89bed6a2e3 checking secret
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-27 00:47:46 +03:00
gitadmin ef8fbd6b22 debug #6
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-27 00:42:16 +03:00
gitadmin a1a8d779a9 comeback to alpine
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-27 00:37:10 +03:00
gitadmin 956c6fc4c6 update
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-26 23:42:25 +03:00
gitadmin 1c79316d1f re-debug for wp
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-26 23:30:23 +03:00
gitadmin 6814ec0b6e debug for wp
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-26 23:20:00 +03:00
gitadmin 5e635eb77e debug for wp 2025-09-26 23:16:34 +03:00
gitadmin 0b1e7d78b2 pushing woodpecker
ci/woodpecker/push/woodpecker Pipeline was successful
2025-09-26 23:01:20 +03:00
gitadmin d8112d6677 switch to new deploy script
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-26 22:54:51 +03:00
gitadmin e6e4cd7262 test update #4
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-26 01:00:38 +03:00
gitadmin 4ba1acf782 Update #3 auto
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-26 00:54:55 +03:00
gitadmin b73584ec25 next wp syntax update
ci/woodpecker/push/woodpecker Pipeline failed
2025-09-26 00:28:00 +03:00
gitadmin 82e43cbc31 empty one for auto deploy 2025-09-26 00:21:44 +03:00
gitadmin 9ee7ea98d6 change wp syntax 2025-09-25 22:49:07 +03:00
gitadmin 3a1f400ae9 update readme to check autoupdate
ci/woodpecker/manual/woodpecker Pipeline was successful
2025-09-25 22:27:35 +03:00
57 changed files with 10019 additions and 965 deletions
+23
View File
@@ -0,0 +1,23 @@
---
description: Поддерживать VERSION и docs/CHANGELOG при изменениях проекта
alwaysApply: true
---
# Версионирование и документация
При **значимых** изменениях кода или поведения:
1. Обновить [`VERSION`](../VERSION) (SemVer):
- **PATCH** — исправления, мелкие правки без смены API/поведения
- **MINOR** — новая функциональность, обратно совместимая
- **MAJOR** — ломающие изменения (формат ссылок, схема БД, API)
2. Добавить запись в [`docs/CHANGELOG.md`](../docs/CHANGELOG.md) под новой версией (Added / Changed / Fixed / Removed).
3. При изменении архитектуры, пайплайна, режимов ZILANT/VOLK или карты файлов — обновить [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) и при необходимости [`README.md`](../README.md), [`docs/volk-mode.md`](../docs/volk-mode.md).
4. Перенос скриптов в `tools/` — обновить [`tools/README.md`](../tools/README.md).
5. Не поднимать версию за косметические правки без изменения поведения.
Текущая базовая версия: **1.1.4**.
+2
View File
@@ -0,0 +1,2 @@
# Исключить все файлы .env из игнорирования Cursor
!.env
+138 -11
View File
@@ -1,35 +1,162 @@
# Общие настройки
SECRET_KEY=24af5f41dba9d8cc0dbc1841660da0b14d7df86ab5e0ed973cdd3bc2dbac23f6
LOG_FILE = /var/log/zilantposter.log
LOG_FILE = /opt/testbot-git/logs/testzilantposter.log
# LOG_FILE = /opt/volk/logs/volkposter.log
WORKMODE = "ZILANT"
# WORKMODE = "VOLK"
# Код сезона для deep link / callback подписок (zk2026_post_…, zk2026_event_…)
SEASON = "zk2026"
# Telegram API через internal_relay (retransbot) — обязательно
# Нужна схема http:// или https:// (если без схемы — подставится http://)
# Локально: http://127.0.0.1:5100 или http://192.168.0.109:5100
# Через nginx: https://rbin.aabpro.ru
RELAY_URL = "http://192.168.0.109:5100"
# Webhook Responder-bot
# WEBHOOK_URL — адрес локальной доставки апдейтов от internal_relay к gunicorn-боту
# (должен быть доступен с машины internal_relay). Не URL для Telegram.
# gunicorn слушает 192.168.0.109:5005 — указываем его напрямую.
# WEBHOOK_URL = "http://192.168.0.109:5010/volk_bot"
# WEBHOOK_PORT = 5010
# WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
# Webhook Responder-bot
WEBHOOK_URL = "http://192.168.0.109:5005/testbot"
WEBHOOK_PORT = 5005
WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
# Webhook Poster-bot
#POSTHOOK_URL = "https://bot.aabpro.ru/testbot2"
#POSTHOOK_PORT = 5008
#POSTHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
# Controller
WEBCTRL_URL = "https://bot.aabpro.ru/testedit"
WEBCTRL_PORT = 5006
WEBCTRL_PREFIX = "/testedit"
# WEBCTRL_URL = "https://bot.aabpro.ru/volk"
# WEBCTRL_PORT = 5009
# WEBCTRL_PREFIX = "/volk"
# логин и пароль по умолчанию для первого запуска веб-контроллера
DEFNM=admin
DEFPW=Z1l@ntAdm1n23
# VK access
# Zilant group
VK_GROUP_ID = -3342146
# VOLK group
# VK_GROUP_ID = -58475527
VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012e946b952a8'
VK_API_VERSION = '5.131'
# MySQL database
MDBASE = 'zilant_posts'
MDBASE = 'testposts'
# MDBASE = 'tempzilant' # debug Zilant database
# Zilant 2026 database
# MDBASE = 'zilant_2026'
# MDBASE = 'volk_test' # debug VOLK database
# MDBASE = 'volkdata' # VOLK production database
MDB_HOST = 'localhost'
MDB_USER = 'zilantposter'
MDB_PW = 'Z1l@ntp08t3r'
# MDB_USER = 'zilantposter'
# MDB_PW = 'Z1l@ntp08t3r'
MDB_USER = 'testuser'
MDB_PW = 'T38tU83r69'
# MDB_USER = 'volkuser'
# MDB_PW = 'V0lK@dM1n23'
# Poster bot
BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
# Debug bot @posto_1638_bot
# BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
# @info_1638_bot
POSTER_BOT_TOKEN = "8215624618:AAF6VtqRtFr4twFRTiTjedSDE6txkbIy6rc"
# VOLK production bot @VOLK_informer_bot
# POSTER_BOT_TOKEN = "8279661544:AAEsJee6icCUn_AbWxhDpk2CLxQhzaartuI"
# VOLK backup production bot @VOLK_informer2_bot
# POSTER_BOT_TOKEN = "8001674071:AAFyGW4xibAFnuApEaSJvshqEyYHmD7RRts"
# VOLK responder bot
# RESPONDER_BOT_TOKEN = "8072579275:AAFiyKN1EPPnP5ZVZNTDGEriYJf1xADbfsY"
# RESPONDER_BOT_NAME = "VOLK_responder_bot"
# @posto_1638_bot
RESPONDER_BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
RESPONDER_BOT_NAME = "posto_1638_bot"
# Zilant info bot
# BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
#
# Responder bot
# Zilant poster bot
# BOT_TOKEN = "8115998792:AAHamcNBlTgTXG0Birc1gNbZ5B7z4jMEdik"
#
# POSTER_BOT_TOKEN = "8115998792:AAHamcNBlTgTXG0Birc1gNbZ5B7z4jMEdik"
# RESPONDER_BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
# RESPONDER_BOT_NAME = "Zilant_info_bot"
# RESPONDER_BOT_NAME = "posto_1638_bot"
# Parameters
#
# Tag to detect org info
# Org info is normally long, publish without picture
ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
# tag to detect posts to not repost from VK
VK_NO_REPOST_TAG = "#окоЗиланта"
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
VK_POST_MIN_LETTERS = 5
# Minutes delay before publishing an event
# calculated as diff between modified field and current datetime
# !! DB datetime is UTC !!
#
# Set 1 hour when Zilant is near
# EVENT_POST_DELAY = 60
# keep 1 day when far from Zilant
EVENT_POST_DELAY = 1440
# Poster channel
# Debug channel
CHANNEL_ID = "@posto1638"
# DB_NAME = "vk_posts.db"
# Zilant production channel
# CHANNEL_ID = "@zilantkon"
# VOLK production channel
# CHANNEL_ID = "@VOLKinformer"
MAX_CAPTION_LENGTH = 1000
MAX_TEXT_LENGTH = 4000
# Button scripts
RESCAN_SCRIPT = "vk_load.py"
PUBLISH_SCRIPT = "dummy.py"
# Zilant API call - План Захвата
#
# add event number and trailing /
#
PZK_PREFIX = "https://reg.konvent.ru/zk2026/draft/plan/api/add/"
EVT_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/api/"
DESC_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/"
# VOLK mode API links
# add category ID (volk24_larp for ex.)
# VOLK_API_VERSION = "1.0"
# VOLK_CATEGORY_LIST = "https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event"
# VOLK_CATEGORY_DESC_PREFIX = "https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category="
# VOLK_EVENT_LIST_PREFIX = "https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category="
# Event status
IS_EVENT = false
AUTO_PUBLISH = false
IS_EVENT = true
AUTO_PUBLISH = true
AUTO_PUB_EVT = true
PROCESS_EVENT_UPDATES = false
PUBLISH_SILENTLY = true
USE_SUBSCRIPTION_BOT = true
LOG_DEBUG_DATA = false
# RouterAI (генерация shortname)
RA_KEY = "sk-7pJxRlbYWUGMNX9y2mHzi2soKDIsZPzp"
RA_MODEL = "moonshotai/kimi-k2-thinking"
# OpenRouter (не используется, оставлен для справки)
# OR_KEY = "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"
# OR_MODEL_NAME = "anthropic/claude-sonnet-5"
+128
View File
@@ -0,0 +1,128 @@
# =============================================================================
# Шаблон окружения для инстанса проекта.
# Скопируйте в .env и заполните значения:
# cp .env.example .env
# Генерация SECRET_KEY / WEBHOOK_SECRET:
# python tools/ops/genkey.py
# =============================================================================
# --- Общие настройки ---------------------------------------------------------
SECRET_KEY=CHANGE_ME_hex_64_chars
LOG_FILE=./logs/app.log
# Режим работы: ZILANT | VOLK
WORKMODE=ZILANT
# Код сезона для deep link / callback подписок (zk2026_post_…, subscribe_zk2026_…)
SEASON=zk2026
# --- Telegram через internal_relay (retransbot) — обязательно ----------------
# Схема http:// или https:// обязательна (иначе подставится http://)
# Примеры: http://127.0.0.1:5100 | https://relay.example.com
RELAY_URL=http://127.0.0.1:5100
# --- Webhook responder-бота --------------------------------------------------
# WEBHOOK_URL — адрес локальной доставки апдейтов от relay к gunicorn-боту
# (доступен с машины relay). Это не публичный URL для Telegram.
# ZILANT (пример): http://bot.example.com:5005/testbot
# VOLK (пример): http://bot.example.com:5010/volk_bot
WEBHOOK_URL=http://192.168.0.1:5005/testbot
WEBHOOK_PORT=5005
WEBHOOK_SECRET=CHANGE_ME_webhook_secret
# Опционально: webhook poster-бота (если используется)
# POSTHOOK_URL=https://bot.example.com/poster
# POSTHOOK_PORT=5008
# POSTHOOK_SECRET=CHANGE_ME
# --- Веб-контроллер (db_edit) ------------------------------------------------
# ZILANT (пример):
WEBCTRL_URL=https://bot.example.com/testedit
WEBCTRL_PORT=5006
WEBCTRL_PREFIX=/testedit
# VOLK (пример, раскомментировать при WORKMODE=VOLK):
# WEBCTRL_URL=https://bot.example.com/volk
# WEBCTRL_PORT=5009
# WEBCTRL_PREFIX=/volk
# Логин/пароль первого администратора веб-UI (создаётся ensure_db при пустой таблице users)
DEFNM=admin
DEFPW=CHANGE_ME_admin_password
# --- VK API ------------------------------------------------------------------
# ZILANT group (пример): -3342146
# VOLK group (пример): -58475527
VK_GROUP_ID=-3342146
VK_ACCESS_TOKEN=CHANGE_ME_vk_token
VK_API_VERSION=5.131
# --- MariaDB -----------------------------------------------------------------
# ZILANT: zilant_2026 | tempzilant | testposts
# VOLK: volkdata | volk_test
MDBASE=CHANGE_ME_database_name
MDB_HOST=localhost
MDB_USER=CHANGE_ME_db_user_name
MDB_PW=CHANGE_ME_db_password
# --- Telegram-боты -----------------------------------------------------------
# Poster — публикация в канал
POSTER_BOT_TOKEN=CHANGE_ME_poster_bot_token
# Responder — подписки / webhook
RESPONDER_BOT_TOKEN=CHANGE_ME_responder_bot_token
RESPONDER_BOT_NAME=your_responder_bot_username
# --- Канал -------------------------------------------------------------------
# ZILANT debug: @posto1638 | production: @zilantkon
# VOLK production: @VOLKinformer
CHANNEL_ID=@your_channel
MAX_CAPTION_LENGTH=1000
MAX_TEXT_LENGTH=4000
# --- Теги VK (ZILANT) --------------------------------------------------------
# Org messages go without picture
# to fit the long text into TG message limit
ORG_MESSAGE_TAG=#оргинфаЗиланткона
# VK posts with no repost tag are skipped by poster
VK_NO_REPOST_TAG=#окоЗиланта
# Минимум букв (isalpha) в тексте VK-поста. Меньше — в БД как «- = Пустой пост = -», не для публикации
VK_POST_MIN_LETTERS=5
# --- Публикация событий ------------------------------------------------------
# Задержка перед публикацией события (минуты). В БД время в UTC.
# Ближе к событию: 60; далеко: 1440
EVENT_POST_DELAY=1440
# --- Ссылки ZILANT (План Захвата / карточки событий) --------------------------
# В конце обычно trailing slash; к URL добавляется номер события
PZK_PREFIX=https://reg.konvent.ru/zk2026/draft/plan/api/add/
EVT_PREFIX=https://reg.konvent.ru/zk2026/events/actions/api/
DESC_PREFIX=https://reg.konvent.ru/zk2026/events/actions/
# --- API VOLK (нужны при WORKMODE=VOLK) --------------------------------------
# Раскомментируйте и заполните для режима VOLK:
# VOLK_API_VERSION=1.0
# VOLK_CATEGORY_LIST=https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event
# VOLK_CATEGORY_DESC_PREFIX=https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category=
# VOLK_EVENT_LIST_PREFIX=https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category=
# --- Флаги поведения ---------------------------------------------------------
# IS_EVENT=true - каждый пост в ВК считать пригодным для репоста в канал
# AUTO_PUBLISH=true - автоматическая публикация потсов из ВК в канал
# AUTO_PUB_EVT=true - автоматическая публикация событий в канал
# PROCESS_EVENT_UPDATES=false - не репостить уже опубликованные события при обновлениях
# PUBLISH_SILENTLY=true - публиковать в канал в режиме "без звука"
# USE_SUBSCRIPTION_BOT=true - использовать бота подписок
# LOG_DEBUG_DATA=false - не вести расширенного журналирования отладочных данных
IS_EVENT=true
AUTO_PUBLISH=true
AUTO_PUB_EVT=true
PROCESS_EVENT_UPDATES=false
PUBLISH_SILENTLY=true
USE_SUBSCRIPTION_BOT=true
LOG_DEBUG_DATA=false
# --- RouterAI (генерация shortname через «Назвать») ---------------------------
RA_KEY=CHANGE_ME_routerai_key
RA_MODEL=moonshotai/kimi-k2-thinking
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*$py.class
# Секреты инстанса — только шаблон .env.example в репозитории
.env
.env.local
.env.*.local
# Логи инстанса
logs/
+26 -18
View File
@@ -1,31 +1,39 @@
pipeline:
deploy:
steps:
- name: deploy
image: alpine:latest
environment:
SSH_PRIVATE_KEY:
from_secret: SSH_PRIVATE_KEY
commands:
# Установка SSH-клиента
- apk add --no-cache openssh-client rsync
# Настройка SSH-ключа из секрета
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan 192.168.0.109 >> ~/.ssh/known_hosts
# Копирование файлов (вариант 1: rsync)
- rsync -av ./ deployer@192.168.0.109:/opt/testbot-git/
# Копирование файлов
- |
rsync -av \
--exclude='.git' \
--exclude='.woodpecker.yml' \
--exclude='.cursorignore' \
./ deployer@192.168.0.109:/opt/testbot-git/
# Или (вариант 2: git clone на целевом сервере)
# - ssh -i ~/.ssh/id_rsa deployer@целевой-сервер "cd /var/www/myapp && git pull"
- |
ssh -i ~/.ssh/id_rsa deployer@192.168.0.109 \
"cd /opt/testbot-git/services && chmod +x post-deploy.sh && ./post-deploy.sh"
# Выполнение команд на целевом сервере
# - ssh -i ~/.ssh/id_rsa deployer@целевой-сервер "
# cd /var/www/myapp &&
# systemctl --user daemon-reload || sudo systemctl daemon-reload &&
# sudo systemctl restart myservice &&
# echo '✅ Deploy completed'
# "
when:
event: push
branch: main
environment:
- SSH_PRIVATE_KEY
# - ssh -i ~/.ssh/id_rsa deployer@целевой-сервер "
# cd /var/www/myapp &&
# systemctl --user daemon-reload || sudo systemctl daemon-reload &&
# sudo systemctl restart myservice &&
# echo '✅ Deploy completed'
# "
when:
event: [push, manual]
branch: main
+67 -2
View File
@@ -1,3 +1,68 @@
# Zilant2025
# Zilant / VOLK — Telegram poster и подписки
Zilantkon bot testarea
**Версия:** 1.1.4 ([`VERSION`](VERSION))
Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link.
## Режимы
| `WORKMODE` | Описание |
|------------|----------|
| `ZILANT` | VK + Zilant JSON → канал (основной пайплайн) |
| `VOLK` | VOLK API → категории и события → канал |
Подробнее: [`docs/volk-mode.md`](docs/volk-mode.md).
## Быстрый старт (сервер)
```bash
# Deploy через Woodpecker → rsync + post-deploy.sh
# Сервисы:
sudo systemctl status testbot-git.service # бот :5005
sudo systemctl status testedit-git.service # UI :5006
sudo systemctl status runner.timer # hourly pipeline
```
Конфигурация — скопируйте [`.env.example`](.env.example) в `.env` и заполните значения
(файл `.env` в git не коммитится). Ключевые переменные: `WORKMODE`, `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
Генерация секретов: `python tools/ops/genkey.py`.
## Пайплайн (ZILANT)
`runner.sh` по расписанию:
`vk_load``zk_load``db_update_shortname``tg_publish``evtg_publish`
Настройка timer: [`docs/runner-setup.md`](docs/runner-setup.md).
## Документация
| Документ | О чём |
|----------|--------|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Компоненты, карта файлов, deploy |
| [`docs/volk-mode.md`](docs/volk-mode.md) | Режим VOLK |
| [`docs/telegram-messaging.md`](docs/telegram-messaging.md) | API отправки сообщений пользователям |
| [`docs/runner-setup.md`](docs/runner-setup.md) | Systemd timer / cron для runner |
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | История версий |
| [`tools/README.md`](tools/README.md) | Вспомогательные и legacy-скрипты |
## Версионирование
SemVer в [`VERSION`](VERSION). При изменениях обновлять `VERSION` и [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
## Структура каталогов
```
├── tg_mainbot.py, wsgi_bot.py # responder-бот
├── db_edit.py, wsgi_edit.py # веб-UI
├── tg_publish.py, evtg_publish.py # публикация (ZILANT)
├── volk_load.py, volk_cat_publish.py # VOLK
├── runner.sh, backupdb.sh
├── services/ # systemd units, post-deploy
├── templates/ # HTML UI
├── docs/ # документация
└── tools/
├── ops/ # genkey, register_tg_webhook, copy_databases
├── dev/ # send_telegram_example, volk_*_testing
└── legacy/ # evt_prefetch
```
+3
View File
@@ -0,0 +1,3 @@
# Перенесено
Документация: [`docs/telegram-messaging.md`](docs/telegram-messaging.md)
+1
View File
@@ -0,0 +1 @@
1.1.4
-45
View File
@@ -1,45 +0,0 @@
import os
import mysql.connector
from dotenv import load_dotenv
# Загрузка переменных окружения из .env файла
load_dotenv()
# Конфигурация базы данных из переменных окружения
DB_CONFIG = {
'host': os.getenv('MDB_HOST'),
'user': os.getenv('MDB_USER'),
'password': os.getenv('MDB_PW'),
'database': os.getenv('MDBASE')
}
def alter_column_type():
try:
# Устанавливаем соединение с базой данных
connection = mysql.connector.connect(**DB_CONFIG)
cursor = connection.cursor()
# Выполняем ALTER TABLE запрос
alter_query = """
ALTER TABLE events
MODIFY COLUMN about TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""
cursor.execute(alter_query)
# Подтверждаем изменения
connection.commit()
print("Тип поля 'about' успешно изменен на TEXT")
except mysql.connector.Error as error:
print(f"Ошибка при изменении типа поля: {error}")
if connection:
connection.rollback()
finally:
if cursor:
cursor.close()
if connection:
connection.close()
if __name__ == "__main__":
alter_column_type()
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# Настройки
DB_NAME="testposts"
BACKUP_DIR="/opt/testbot-git/dbbackup"
DATE=$(date +"%Y-%m-%d_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${DATE}.sql"
# Создаем каталог для бэкапов если не существует
mkdir -p "$BACKUP_DIR"
# Делаем дамп базы данных
sudo mysqldump --databases "$DB_NAME" > "$BACKUP_FILE"
# Проверяем успешность выполнения
if [ $? -eq 0 ]; then
echo "Бэкап успешно создан: $BACKUP_FILE"
else
echo "Ошибка при создании бэкапа!"
exit 1
fi
-3
View File
@@ -1,3 +0,0 @@
#! /bin/bash
# Backup database
mysqldump -utestuser -pT38tU83r69 testposts > test_posts_backup.sql
+758 -58
View File
@@ -24,6 +24,9 @@ from tg_publish import publish_to_tg_all, publish_to_tg
from db_update_shortname import ai_shortname_all, generate_ai_shortname
from evtg_publish import tg_post_event_by_id, tg_post_all_events
from zk_load import load_json_all
from volk_load import load_categories, load_all_events
from volk_cat_publish import tg_post_category_by_id, tg_post_all_categories
from test_tg_poster import make_test_post
# Загружаем настройки из .env
load_dotenv()
@@ -113,7 +116,7 @@ class DatabaseManager:
with self.conn.cursor() as cursor:
cursor.execute("""
SELECT id, vk_post_id, shortname, text, published_at,
marked_for_publication, published_in_tg, is_event, is_poll, action_number
marked_for_publication, published_in_tg, is_event, is_poll, action_number, auto_unit
FROM posts
ORDER BY published_at DESC
""")
@@ -305,8 +308,8 @@ class DatabaseManager:
vk_post_id, shortname, text, image_url, vk_post_url, published_at,
is_event, is_poll, poll_question, poll_options, poll_multiple, poll_end_date,
marked_for_publication, published_in_tg, tg_publication_date, tg_message_id,
tg_poll_id, tg_markpost_id, tg_poll_results, action_number
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
tg_poll_id, tg_markpost_id, tg_poll_results, action_number, auto_unit
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
values = (
clean_int(data['vk_post_id']),
@@ -328,7 +331,8 @@ class DatabaseManager:
clean_int(data.get('tg_poll_id', '')),
clean_int(data.get('tg_markpost_id', '')),
data.get('tg_poll_results', ''),
clean_int(data.get('action_number', ''))
clean_int(data.get('action_number', '')),
data.get('auto_unit', '')
)
cursor.execute(query, values)
self.conn.commit()
@@ -354,7 +358,7 @@ class DatabaseManager:
published_at = %s, is_event = %s, is_poll = %s, poll_question = %s, poll_options = %s,
poll_multiple = %s, poll_end_date = %s, marked_for_publication = %s,
published_in_tg = %s, tg_publication_date = %s, tg_message_id = %s,
tg_poll_id = %s, tg_markpost_id = %s, tg_poll_results = %s, action_number = %s
tg_poll_id = %s, tg_markpost_id = %s, tg_poll_results = %s, action_number = %s, auto_unit = %s
WHERE id = %s
"""
values = (
@@ -378,6 +382,7 @@ class DatabaseManager:
clean_int(data.get('tg_markpost_id', '')),
data.get('tg_poll_results', ''),
clean_int(data.get('action_number', '')),
data.get('auto_unit', ''),
post_id
)
cursor.execute(query, values)
@@ -509,6 +514,10 @@ def load_user(user_id):
@bp.before_request
def before_request():
g.prefix = app.config['PREFIX']
g.bot_port = os.getenv('WEBHOOK_PORT', '5005')
g.webhook_secret = os.getenv('WEBHOOK_SECRET')
g.webhook_url = os.getenv('WEBHOOK_URL')
g.workmode = os.getenv('WORKMODE', 'ZILANT')
# Маршруты Blueprint
@bp.route('/')
@@ -612,7 +621,8 @@ def api_posts():
'published_in_tg': bool(post['published_in_tg']),
'is_event': bool(post['is_event']),
'is_poll': bool(post['is_poll']),
'action_number': post['action_number']
'action_number': post['action_number'],
'auto_unit': post['auto_unit']
})
return jsonify(posts_list)
finally:
@@ -668,6 +678,28 @@ def api_toggle_event_publication(event_id):
finally:
db.close()
@bp.route('/api/events/mark_all_for_publication', methods=['POST'])
@login_required
def api_mark_all_events_for_publication():
"""Отмечает все события для публикации"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
UPDATE events
SET marked_to_publication = 1
WHERE marked_to_publication = 0
""")
count = cursor.rowcount
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Отмечено для публикации: {count} событий, IP {client_ip}")
return jsonify({'success': True, 'count': count})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/events', methods=['POST'])
@login_required
def api_add_event():
@@ -795,6 +827,28 @@ def api_toggle_publication(post_id):
finally:
db.close()
@bp.route('/api/posts/mark_all_for_publication', methods=['POST'])
@login_required
def api_mark_all_posts_for_publication():
"""Отмечает все посты для публикации"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
UPDATE posts
SET marked_for_publication = 1
WHERE marked_for_publication = 0
""")
count = cursor.rowcount
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Отмечено для публикации: {count} постов, IP {client_ip}")
return jsonify({'success': True, 'count': count})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/posts', methods=['POST'])
@login_required
def api_add_post():
@@ -932,6 +986,24 @@ def api_generate_all_shortnames():
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/test_post', methods=['POST'])
@login_required
def api_test_post():
try:
log_event("Запуск публикации тестового сообщения...")
result = make_test_post()
if result:
log_event("Тестовое сообщение успешно опубликовано")
return jsonify({'success': True})
else:
error_msg = "Не удалось опубликовать тестовое сообщение"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
except Exception as e:
error_msg = f"Ошибка при публикации тестового сообщения: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/generate_shortname/<int:post_id>', methods=['POST'])
@login_required
def api_generate_shortname(post_id):
@@ -939,12 +1011,23 @@ def api_generate_shortname(post_id):
try:
post = db.get_post(post_id)
if not post:
return jsonify({'error': 'Post not found'}), 404
return jsonify({'success': False, 'error': 'Post not found'}), 404
log_event(f"Генерация названия для поста ID: {post_id}")
shortname = generate_ai_shortname(post['text'])
if not shortname:
return jsonify({
'success': False,
'error': 'Не удалось сгенерировать название (пустой ответ AI)',
}), 500
# После долгого AI-запроса MySQL-соединение могло протухнуть
try:
db.conn.ping(reconnect=True)
except Exception:
db.close()
db = get_db()
# Обновляем shortname в базе данных
with db.conn.cursor() as cursor:
cursor.execute(
"UPDATE posts SET shortname = %s WHERE id = %s",
@@ -959,7 +1042,10 @@ def api_generate_shortname(post_id):
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
finally:
db.close()
try:
db.close()
except Exception:
pass
@bp.route('/api/publish_post/<int:post_id>', methods=['POST'])
@login_required
@@ -979,11 +1065,49 @@ def api_publish_post(post_id):
# Функция для запуска в отдельном потоке
def run_async_func():
try:
# Устанавливаем правильную политику event loop для работы с anyio
# Это критично для правильной работы httpx в отдельном потоке
if sys.platform == 'win32':
# На Windows используем ProactorEventLoop
policy = asyncio.WindowsProactorEventLoopPolicy()
else:
# На Linux используем DefaultEventLoopPolicy
policy = asyncio.DefaultEventLoopPolicy()
asyncio.set_event_loop_policy(policy)
# Создаем новый event loop для этого потока
# Это важно при работе с gunicorn, который может иметь свой event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(publish_to_tg(post['vk_post_id']))
return result
# Устанавливаем переменную окружения для anyio backend
import os
os.environ['ANYIO_BACKEND'] = 'asyncio'
try:
result = loop.run_until_complete(publish_to_tg(post['vk_post_id']))
return result
finally:
# Закрываем event loop после завершения
# Это важно для предотвращения утечек ресурсов
try:
# Отменяем все незавершенные задачи
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Ждем завершения отмененных задач
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
loop.close()
# Сбрасываем политику event loop
asyncio.set_event_loop_policy(None)
except Exception as e:
import traceback
log_event(f"Ошибка в run_async_func: {str(e)}\n{traceback.format_exc()}")
return e
# Создаем очередь для передачи результата
@@ -996,6 +1120,12 @@ def api_publish_post(post_id):
if isinstance(result, Exception):
raise result
# Проверяем, что публикация действительно успешна (результат True)
if result is not True:
error_msg = f"Публикация поста ID: {post_id} не удалась (результат: {result})"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
log_event(f"Публикация поста ID: {post_id} выполнена успешно")
return jsonify({'success': True})
@@ -1011,6 +1141,13 @@ def api_publish_post(post_id):
@login_required
def api_run_zk_rescan():
try:
# Проверка режима работы
workmode = os.getenv('WORKMODE', 'ZILANT')
if workmode != 'ZILANT':
error_msg = "Рескан Зилант-сервера доступен только в режиме ZILANT"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 403
load_json_all()
log_event("Рескан Зилант-сервера выполнен успешно")
return jsonify({'success': True})
@@ -1057,6 +1194,422 @@ def api_publish_all_events():
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/publish_all_categories', methods=['POST'])
@login_required
def api_publish_all_categories():
try:
log_event("Запуск публикации всех площадок...")
# Создаем асинхронную обертку для запуска в отдельном потоке
import asyncio
from threading import Thread
import queue
def run_async_func():
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(tg_post_all_categories())
return result
except Exception as e:
return e
# Создаем очередь для передачи результата
q = queue.Queue()
# Запускаем в отдельном потоке
thread = Thread(target=lambda: q.put(run_async_func()))
thread.start()
thread.join()
result = q.get()
if isinstance(result, Exception):
raise result
log_event("Публикация всех площадок выполнена успешно")
return jsonify({'success': True})
except Exception as e:
error_msg = f"Ошибка при публикации всех площадок: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/publish_category/<category_id>', methods=['POST'])
@login_required
def api_publish_category(category_id):
try:
# Сначала проверяем существование категории
db = get_db()
with db.conn.cursor() as cursor:
cursor.execute("SELECT ID, TITLE FROM categories WHERE ID = %s", (category_id,))
category = cursor.fetchone()
if not category:
return jsonify({'success': False, 'error': 'Category not found'}), 404
db.close()
log_event(f"Запуск публикации площадки ID: {category_id}")
# Создаем асинхронную обертку для запуска в отдельном потоке
import asyncio
from threading import Thread
import queue
def run_async_func():
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(tg_post_category_by_id(category_id))
return result
except Exception as e:
return e
# Создаем очередь для передачи результата
q = queue.Queue()
# Запускаем в отдельном потоке
thread = Thread(target=lambda: q.put(run_async_func()))
thread.start()
thread.join()
result = q.get()
if isinstance(result, Exception):
raise result
log_event(f"Публикация площадки ID {category_id} выполнена успешно")
return jsonify({'success': True})
except Exception as e:
error_msg = f"Ошибка при публикации площадки: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/load_categories', methods=['POST'])
@login_required
def api_load_categories():
try:
# Проверка режима работы
workmode = os.getenv('WORKMODE', 'ZILANT')
if workmode != 'VOLK':
error_msg = "Загрузка категорий доступна только в режиме VOLK"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 403
log_event("Запуск загрузки категорий...")
load_categories()
log_event("Загрузка категорий выполнена успешно")
return jsonify({'success': True})
except Exception as e:
error_msg = f"Ошибка при загрузке категорий: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/categories')
@login_required
def api_categories():
"""Получает список всех категорий (площадок) из таблицы categories"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
SELECT AUTO_ID, ID, TITLE, description, marked_for_publication, is_tg_published
FROM categories
ORDER BY TITLE, ID
""")
categories = cursor.fetchall()
categories_list = []
for cat in categories:
categories_list.append({
'auto_id': cat['AUTO_ID'],
'id': cat['ID'],
'title': cat['TITLE'] or '',
'description': cat['description'] or '',
'marked_for_publication': bool(cat['marked_for_publication']) if cat['marked_for_publication'] is not None else False,
'is_tg_published': bool(cat['is_tg_published']) if cat['is_tg_published'] is not None else False
})
return jsonify(categories_list)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories/<category_id>')
@login_required
def api_category_details(category_id):
"""Получает детали категории по ID"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
SELECT AUTO_ID, ID, TITLE, description, image_url, tg_id, is_tg_published, tg_published_date, marked_for_publication
FROM categories
WHERE ID = %s
""", (category_id,))
category = cursor.fetchone()
if not category:
return jsonify({'error': 'Category not found'}), 404
# Получаем количество событий для этой категории
cursor.execute("""
SELECT COUNT(*) as event_count
FROM events
WHERE unit_name = %s
""", (category_id,))
event_count_result = cursor.fetchone()
event_count = event_count_result['event_count'] if event_count_result else 0
return jsonify({
'auto_id': category['AUTO_ID'],
'id': category['ID'],
'title': category['TITLE'] or '',
'description': category['description'] or '',
'image_url': category['image_url'] or '',
'tg_id': category['tg_id'],
'is_tg_published': bool(category['is_tg_published']) if category['is_tg_published'] is not None else False,
'tg_published_date': category['tg_published_date'].isoformat() if category['tg_published_date'] else None,
'marked_for_publication': bool(category['marked_for_publication']) if category['marked_for_publication'] is not None else False,
'event_count': event_count
})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories', methods=['POST'])
@login_required
def api_create_category():
"""Создает новую категорию"""
db = get_db()
try:
data = request.json
with db.conn.cursor() as cursor:
cursor.execute("""
INSERT INTO categories (ID, TITLE, description, image_url, tg_id, marked_for_publication, is_tg_published, tg_published_date)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
data.get('id'),
data.get('title', ''),
data.get('description', ''),
data.get('image_url', ''),
data.get('tg_id'),
bool(data.get('marked_for_publication', False)),
bool(data.get('is_tg_published', False)),
data.get('tg_published_date') or None
))
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Создана площадка: {data.get('id')}, IP {client_ip}")
return jsonify({'success': True})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories/<category_id>', methods=['PUT'])
@login_required
def api_update_category(category_id):
"""Обновляет категорию"""
db = get_db()
try:
data = request.json
new_id = data.get('id', '').strip()
if not new_id:
return jsonify({'success': False, 'error': 'ID is required'}), 400
with db.conn.cursor() as cursor:
# Сначала проверяем существование категории по старому ID
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (category_id,))
category = cursor.fetchone()
if not category:
return jsonify({'success': False, 'error': 'Category not found'}), 404
auto_id = category['AUTO_ID']
# Если новый ID отличается от старого, проверяем, не занят ли он
if new_id != category_id:
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (new_id,))
existing = cursor.fetchone()
if existing and existing['AUTO_ID'] != auto_id:
return jsonify({'success': False, 'error': f'Category with ID "{new_id}" already exists'}), 400
# Обновляем категорию, включая ID если он изменился
cursor.execute("""
UPDATE categories
SET ID = %s, TITLE = %s, description = %s, image_url = %s, tg_id = %s,
marked_for_publication = %s, is_tg_published = %s, tg_published_date = %s
WHERE AUTO_ID = %s
""", (
new_id,
data.get('title', ''),
data.get('description', ''),
data.get('image_url', ''),
data.get('tg_id'),
bool(data.get('marked_for_publication', False)),
bool(data.get('is_tg_published', False)),
data.get('tg_published_date') or None,
auto_id
))
db.conn.commit()
if cursor.rowcount > 0:
client_ip = get_client_ip()
log_event(f"Обновлена площадка: {category_id} -> {new_id}, IP {client_ip}")
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': 'Category not found'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories/<category_id>', methods=['DELETE'])
@login_required
def api_delete_category(category_id):
"""Удаляет категорию"""
db = get_db()
try:
with db.conn.cursor() as cursor:
# Проверяем наличие привязанных событий
cursor.execute("""
SELECT COUNT(*) as event_count
FROM events
WHERE unit_name = %s
""", (category_id,))
event_count_result = cursor.fetchone()
event_count = event_count_result['event_count'] if event_count_result else 0
if event_count > 0:
return jsonify({
'success': False,
'error': 'Cannot delete category with linked events',
'event_count': event_count
}), 400
# Получаем название категории для лога
cursor.execute("SELECT TITLE FROM categories WHERE ID = %s", (category_id,))
category = cursor.fetchone()
category_title = category['TITLE'] if category else category_id
cursor.execute("DELETE FROM categories WHERE ID = %s", (category_id,))
db.conn.commit()
if cursor.rowcount > 0:
client_ip = get_client_ip()
log_event(f"Удалена площадка: {category_title}, ID: {category_id}, IP {client_ip}")
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': 'Category not found'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories/<category_id>/toggle_publication', methods=['POST'])
@login_required
def api_toggle_category_publication(category_id):
"""Переключает статус публикации категории"""
db = get_db()
try:
with db.conn.cursor() as cursor:
# Получаем текущее значение
cursor.execute("SELECT marked_for_publication FROM categories WHERE ID = %s", (category_id,))
category = cursor.fetchone()
if not category:
return jsonify({'success': False, 'error': 'Category not found'}), 404
# Переключаем значение
new_value = not bool(category['marked_for_publication'])
cursor.execute("""
UPDATE categories
SET marked_for_publication = %s
WHERE ID = %s
""", (new_value, category_id))
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Изменен статус публикации площадки: {category_id}, новый статус: {new_value}, IP {client_ip}")
return jsonify({'success': True, 'marked_for_publication': new_value})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories/mark_all_for_publication', methods=['POST'])
@login_required
def api_mark_all_categories_for_publication():
"""Отмечает все категории для публикации"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
UPDATE categories
SET marked_for_publication = 1
WHERE marked_for_publication = 0
""")
count = cursor.rowcount
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Отмечено для публикации: {count} площадок, IP {client_ip}")
return jsonify({'success': True, 'count': count})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories/<category_id>/events_count')
@login_required
def api_category_events_count(category_id):
"""Получает количество событий, привязанных к категории"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
SELECT COUNT(*) as event_count
FROM events
WHERE unit_name = %s
""", (category_id,))
result = cursor.fetchone()
return jsonify({'count': result['event_count'] if result else 0})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/events/count')
@login_required
def api_events_count():
"""Получает количество событий в таблице events"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("SELECT COUNT(*) as count FROM events")
result = cursor.fetchone()
return jsonify({'count': result['count'] if result else 0})
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/run_volk_rescan', methods=['POST'])
@login_required
def api_run_volk_rescan():
try:
# Проверка режима работы
workmode = os.getenv('WORKMODE', 'ZILANT')
if workmode != 'VOLK':
error_msg = "Рескан ВОЛК доступен только в режиме VOLK"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 403
log_event("Запуск рескана ВОЛК...")
load_all_events()
log_event("Рескан ВОЛК выполнен успешно")
return jsonify({'success': True})
except Exception as e:
error_msg = f"Ошибка при рескане ВОЛК: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/publish_event/<int:event_id>', methods=['POST'])
@login_required
def api_publish_event(event_id):
@@ -1171,11 +1724,21 @@ def api_get_linked_events(post_id):
events = db.get_linked_events(post_id)
events_list = []
for event in events:
# Определяем статус события
status = "Не видим"
if event.get('is_posted_tg'):
status = "Опубликован"
elif event.get('marked_to_publication'):
status = "Для публикации"
elif event.get('is_visible'):
status = "Видим"
events_list.append({
'id': event['id'],
'name': event['name'],
'number': event['number'],
'unit_name': event['unit_name']
'unit_name': event['unit_name'],
'status': status
})
return jsonify(events_list)
except Exception as e:
@@ -1191,11 +1754,19 @@ def api_get_linked_posts(event_id):
posts = db.get_linked_posts(event_id)
posts_list = []
for post in posts:
# Определяем статус поста
status = "Не для публикации"
if post.get('published_in_tg'):
status = "Опубликован"
elif post.get('marked_for_publication'):
status = "Для публикации"
posts_list.append({
'id': post['id'],
'vk_post_id': post['vk_post_id'],
'shortname': post['shortname'],
'text_preview': post['text'][:50] + '...' if post['text'] else ''
'text_preview': post['text'][:50] + '...' if post['text'] else '',
'status': status
})
return jsonify(posts_list)
except Exception as e:
@@ -1215,6 +1786,157 @@ def api_get_all_interlinks():
finally:
db.close()
# Новые API endpoints для работы с площадками
@bp.route('/api/venues')
@login_required
def api_venues():
"""Получает список всех уникальных площадок с количеством событий"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
SELECT unit_name as name, COUNT(*) as event_count
FROM events
WHERE unit_name IS NOT NULL AND unit_name != ''
GROUP BY unit_name
ORDER BY unit_name
""")
venues = cursor.fetchall()
venues_list = []
for venue in venues:
venues_list.append({
'name': venue['name'],
'event_count': venue['event_count']
})
return jsonify(venues_list)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/venues/<venue_name>/events')
@login_required
def api_venue_events(venue_name):
"""Получает список событий для указанной площадки с информацией о привязке к посту"""
post_id = request.args.get('post_id')
if not post_id:
return jsonify({'error': 'post_id parameter is required'}), 400
db = get_db()
try:
with db.conn.cursor() as cursor:
# Получаем события площадки с информацией о привязке к посту
cursor.execute("""
SELECT e.id, e.name, e.number, e.unit_name,
CASE WHEN i.post_id IS NOT NULL THEN 1 ELSE 0 END as linked
FROM events e
LEFT JOIN interlinks i ON e.id = i.event_id AND i.post_id = %s
WHERE e.unit_name = %s
ORDER BY e.name
""", (post_id, venue_name))
events = cursor.fetchall()
events_list = []
for event in events:
events_list.append({
'id': event['id'],
'name': event['name'],
'number': event['number'],
'unit_name': event['unit_name'],
'linked': bool(event['linked'])
})
return jsonify(events_list)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
# API endpoints для работы с тэгами
@bp.route('/api/tags')
@login_required
def api_tags():
"""Получает список всех уникальных тэгов с количеством событий"""
db = get_db()
try:
with db.conn.cursor() as cursor:
# Получаем все тэги и разбиваем их по пробелам
cursor.execute("""
SELECT tags
FROM events
WHERE tags IS NOT NULL AND tags != ''
""")
events = cursor.fetchall()
# Собираем все уникальные тэги
tag_counts = {}
for event in events:
tags = event['tags'].split()
for tag in tags:
tag = tag.strip()
if tag:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
# Сортируем по количеству событий (убывание), затем по названию
sorted_tags = sorted(tag_counts.items(), key=lambda x: (-x[1], x[0]))
tags_list = []
for tag, count in sorted_tags:
tags_list.append({
'name': tag,
'event_count': count
})
return jsonify(tags_list)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/tags/<tag_name>/events')
@login_required
def api_tag_events(tag_name):
"""Получает список событий для указанного тэга с информацией о привязке к посту"""
post_id = request.args.get('post_id')
if not post_id:
return jsonify({'error': 'post_id parameter is required'}), 400
db = get_db()
try:
with db.conn.cursor() as cursor:
# Получаем события с указанным тэгом с информацией о привязке к посту
cursor.execute("""
SELECT e.id, e.name, e.number, e.unit_name, e.tags,
CASE WHEN i.post_id IS NOT NULL THEN 1 ELSE 0 END as linked
FROM events e
LEFT JOIN interlinks i ON e.id = i.event_id AND i.post_id = %s
WHERE e.tags LIKE %s
ORDER BY e.name
""", (post_id, f'%{tag_name}%'))
events = cursor.fetchall()
events_list = []
for event in events:
# Проверяем, что тэг действительно присутствует (не подстрока другого тэга)
tags = event['tags'].split() if event['tags'] else []
if tag_name in tags:
events_list.append({
'id': event['id'],
'name': event['name'],
'number': event['number'],
'unit_name': event['unit_name'],
'tags': event['tags'],
'linked': bool(event['linked'])
})
return jsonify(events_list)
except Exception as e:
return jsonify({'error': str(e)}), 500
finally:
db.close()
@bp.route('/image_proxy')
@login_required
def image_proxy():
@@ -1286,6 +2008,29 @@ def edit_event_page(event_id):
def add_event_page():
return render_template('edit_event.html', event_id=None)
@bp.route('/edit_category/<category_id>')
@login_required
def edit_category_page(category_id):
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("SELECT ID, TITLE FROM categories WHERE ID = %s", (category_id,))
category = cursor.fetchone()
if not category:
flash('Площадка не найдена', 'danger')
return redirect(url_for('zilant.index'))
client_ip = get_client_ip()
log_event(f"Открыта площадка на редактирование: {category['TITLE']}, ID: {category_id}, IP {client_ip}")
finally:
db.close()
return render_template('edit_category.html', category_id=category_id)
@bp.route('/add_category')
@login_required
def add_category_page():
return render_template('edit_category.html', category_id=None)
# Регистрируем Blueprint в приложении
app.register_blueprint(bp)
@@ -1297,51 +2042,6 @@ def init_db():
ensure_database_structure()
print("Структура базы данных проверена и создана при необходимости")
# Проверяем и создаем таблицу events, если она не существует
# with db.conn.cursor() as cursor:
# cursor.execute("""
# CREATE TABLE IF NOT EXISTS events (
# id INT AUTO_INCREMENT PRIMARY KEY,
# id_event BIGINT NOT NULL,
# number BIGINT NOT NULL,
# unit_name VARCHAR(255),
# name VARCHAR(255),
# about VARCHAR(2048),
# about_social_picture VARCHAR(2048),
# modified DATETIME NULL,
# added DATETIME NULL,
# tags VARCHAR(255),
# is_canceled BOOLEAN DEFAULT 0,
# accepted BOOLEAN DEFAULT 0,
# denied BOOLEAN DEFAULT 0,
# is_visible BOOLEAN DEFAULT 0,
# marked_to_publication BOOLEAN DEFAULT 0,
# is_posted_tg BOOLEAN DEFAULT 0,
# tg_message_id BIGINT NULL DEFAULT NULL,
# tg_posted_date DATETIME NULL,
# announcement_link BIGINT NULL DEFAULT NULL
# ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
# """)
# db.conn.commit()
# print("Таблица events проверена и создана при необходимости")
# Создаем администратора по умолчанию
with db.conn.cursor() as cursor:
cursor.execute("SELECT COUNT(*) as count FROM users")
if cursor.fetchone()['count'] == 0:
username = os.getenv('DEFNM')
password = os.getenv('DEFPW')
if username and password:
hashed_password = generate_password_hash(password)
cursor.execute(
"INSERT INTO users (username, password) VALUES (%s, %s)",
(username, hashed_password)
)
print(f"Created default user: {username}")
else:
print("Environment variables DEFNM/DEFPW not set. Admin not created.")
db.conn.commit()
except Exception as e:
print(f"Database initialization error: {str(e)}")
raise
+152 -25
View File
@@ -21,14 +21,88 @@ DB_CONFIG = {
'charset': 'utf8mb4'
}
OPENROUTER_CONFIG = {
'api_key': os.getenv('OR_KEY'),
'model': os.getenv('OR_MODEL_NAME')
ROUTERAI_CONFIG = {
'api_key': os.getenv('RA_KEY'),
'model': os.getenv('RA_MODEL'),
'base_url': 'https://routerai.ru/api/v1',
}
# Настройки журналирования
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе
def _truncate_for_log(value, max_len=None):
"""Обрезает длинные значения для журнала, не теряя информацию о размере."""
if value is None:
return None
if not isinstance(value, str):
value = json.dumps(value, ensure_ascii=False, default=str)
limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX
if len(value) <= limit:
return value
return f"{value[:limit]}... [обрезано, всего {len(value)} символов]"
def _message_fields_for_log(message):
"""Все значимые поля message из ответа chat/completions."""
if not message:
return {}
logged = {}
for key in (
'role', 'content', 'reasoning_content', 'reasoning',
'refusal', 'tool_calls', 'function_call', 'audio',
):
if key not in message:
continue
value = message.get(key)
if value in (None, '', [], {}):
logged[key] = value
elif isinstance(value, str):
logged[key] = _truncate_for_log(value)
else:
logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str))
return logged
def _log_routerai_request(text, prompt):
"""Журнал параметров исходящего запроса (без API-ключа)."""
log_message(
"Запрос RouterAI: "
f"model={ROUTERAI_CONFIG['model']}, "
f"text_len={len(text)}, prompt_len={len(prompt)}, "
f"text_preview={_truncate_for_log(text.strip(), 300)}"
)
def _log_routerai_response(response, result=None, error_body=None):
"""Подробный журнал HTTP-ответа RouterAI."""
payload = {
"http_status": getattr(response, 'status_code', None),
"model": ROUTERAI_CONFIG.get('model'),
}
if result is not None:
payload["response_id"] = result.get("id")
payload["object"] = result.get("object")
payload["created"] = result.get("created")
payload["usage"] = result.get("usage")
payload["system_fingerprint"] = result.get("system_fingerprint")
choices = result.get("choices") or []
payload["choices_count"] = len(choices)
payload["choices"] = []
for i, choice in enumerate(choices):
payload["choices"].append({
"index": choice.get("index", i),
"finish_reason": choice.get("finish_reason"),
"message": _message_fields_for_log(choice.get("message") or {}),
"logprobs": choice.get("logprobs"),
})
if result.get("error"):
payload["api_error"] = result.get("error")
if error_body is not None:
payload["error_body"] = _truncate_for_log(error_body)
log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}")
def log_message(message, max_retries=5, retry_delay=0.1):
"""
@@ -59,11 +133,16 @@ def log_message(message, max_retries=5, retry_delay=0.1):
return False
def _generate_shortname_by_api(text):
"""Внутренняя функция для генерации краткого названия через OpenRouter API"""
url = "https://openrouter.ai/api/v1/chat/completions"
"""Внутренняя функция для генерации краткого названия через RouterAI API"""
if not ROUTERAI_CONFIG['api_key'] or not ROUTERAI_CONFIG['model']:
log_message("RA_KEY или RA_MODEL не заданы в .env")
print("Ошибка: RA_KEY или RA_MODEL не заданы в .env")
return None
url = f"{ROUTERAI_CONFIG['base_url']}/chat/completions"
headers = {
"Authorization": f"Bearer {OPENROUTER_CONFIG['api_key']}",
"Content-Type": "application/json"
"Authorization": f"Bearer {ROUTERAI_CONFIG['api_key']}",
"Content-Type": "application/json",
}
prompt = f"""
@@ -78,14 +157,16 @@ def _generate_shortname_by_api(text):
Название:"""
data = {
"model": OPENROUTER_CONFIG['model'],
"model": ROUTERAI_CONFIG['model'],
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 5000,
"temperature": 0.3
"temperature": 0.3,
}
_log_routerai_request(text, prompt)
try:
response = requests.post(url, headers=headers, json=data, timeout=60)
response = requests.post(url, headers=headers, json=data, timeout=180)
# Обработка ошибки 429 (Too Many Requests)
if response.status_code == 429:
@@ -98,27 +179,55 @@ def _generate_shortname_by_api(text):
# Пытаемся распарсить JSON ответ
try:
error_info["body"] = response.json()
except:
except Exception:
pass
_log_routerai_response(response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
sys.exit(1)
response.raise_for_status()
# Не sys.exit — эта функция вызывается и из веб-API Flask
raise RuntimeError("RouterAI 429: превышен лимит запросов")
if not response.ok:
_log_routerai_response(response, error_body=response.text)
response.raise_for_status()
result = response.json()
_log_routerai_response(response, result=result)
# Извлекаем только текст ответа
shortname = result['choices'][0]['message']['content'].strip()
# Извлекаем только текст ответа (у thinking-моделей ответ в content,
# рассуждения — в reasoning_content / reasoning)
choices = result.get('choices') or []
if not choices:
log_message("RouterAI вернул пустой choices — название не извлечь")
return None
message = choices[0].get('message') or {}
shortname = (message.get('content') or '').strip()
if not shortname:
reasoning = (message.get('reasoning_content') or message.get('reasoning') or '').strip()
log_message(
"Пустой content в ответе RouterAI; "
f"finish_reason={choices[0].get('finish_reason')!r}, "
f"message_keys={list(message.keys())}, "
f"reasoning_len={len(reasoning)}"
)
return None
# Удаляем возможные кавычки и лишние символы
shortname = re.sub(r'^["\']|["\']$', '', shortname)
# Если модель вернула многострочный ответ — берём первую непустую строку
for line in shortname.splitlines():
line = line.strip()
if line:
shortname = line
break
# Обрезаем до 80 символов
return shortname[:80]
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
if e.response is not None and e.response.status_code == 429:
error_info = {
"status_code": e.response.status_code,
"headers": dict(e.response.headers),
@@ -128,17 +237,35 @@ def _generate_shortname_by_api(text):
# Пытаемся распарсить JSON ответ
try:
error_info["body"] = e.response.json()
except:
except Exception:
pass
_log_routerai_response(e.response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
sys.exit(1)
raise RuntimeError("RouterAI 429: превышен лимит запросов")
else:
if e.response is not None:
try:
_log_routerai_response(e.response, error_body=e.response.text)
except Exception:
pass
error_msg = f"HTTP ошибка при генерации названия: {e}"
log_message(error_msg)
print(error_msg)
return None
except requests.exceptions.Timeout as e:
error_msg = f"Таймаут запроса RouterAI ({e})"
log_message(error_msg)
print(error_msg)
return None
except requests.exceptions.RequestException as e:
error_msg = f"Сетевая ошибка RouterAI: {e}"
log_message(error_msg)
print(error_msg)
return None
except RuntimeError:
raise
except Exception as e:
error_msg = f"Ошибка при генерации названия: {e}"
log_message(error_msg)
@@ -228,10 +355,10 @@ def ai_shortname_all():
log_message("Обработка всех записей завершена")
print("Обработка завершена")
except SystemExit:
# Перехватываем системный выход для корректного закрытия соединения
log_message("Завершение работы из-за ошибки 429")
print("Завершение работы из-за ошибки 429")
except RuntimeError as e:
# 429 и прочие явные ошибки RouterAI
log_message(f"Завершение работы: {e}")
print(f"Завершение работы: {e}")
except Exception as e:
error_msg = f"Произошла ошибка при обработке записей: {e}"
log_message(error_msg)
+165
View File
@@ -0,0 +1,165 @@
# Архитектура проекта
Версия: см. [`VERSION`](../VERSION). История изменений: [`docs/CHANGELOG.md`](CHANGELOG.md).
## Назначение
Система загружает посты и события из внешних источников, хранит их в MariaDB, публикует анонсы в Telegram-канал и даёт веб-интерфейс для редактирования и управления подписками через responder-бота.
Поддерживаются два режима работы (`WORKMODE` в `.env`):
| Режим | Источники данных | Публикация |
|-------|------------------|------------|
| **ZILANT** | VK (`vk_load.py`), JSON Zilant (`zk_load.py`) | `tg_publish.py`, `evtg_publish.py` |
| **VOLK** | VOLK API (`volk_load.py`) | `volk_cat_publish.py` |
Подробнее о VOLK: [`docs/volk-mode.md`](volk-mode.md).
## Компоненты runtime
```
┌─────────────────┐ rsync ┌──────────────────┐
│ Woodpecker CI │ ─────────────► │ /opt/testbot-git │
└─────────────────┘ └────────┬─────────┘
┌──────────────────────────────────┼──────────────────────────┐
▼ ▼ ▼
runner.timer testbot-git.service testedit-git.service
→ runner.sh gunicorn :5005 gunicorn :5006
vk_load, zk_load, wsgi_bot → tg_mainbot wsgi_edit → db_edit
db_update_shortname,
tg_publish, evtg_publish
```
### Responder-бот (подписки)
- **`tg_mainbot.py`** — Flask + webhook, обработка `/start`, callback-кнопок подписки.
- **`wsgi_bot.py`** — точка входа gunicorn; однократная регистрация webhook с file lock.
- **`season_links.py`** — сезонные payload (`zk2026_post_*`, `subscribe_zk2026_*` и т.д.).
- Старые ссылки без префикса сезона → сообщение «сезон завершён» + кнопка в канал.
### Poster (публикация в канал)
- **`tg_publish.py`** — посты VK → Telegram.
- **`evtg_publish.py`** — события → Telegram.
- **`volk_cat_publish.py`** — категории VOLK → Telegram (режим VOLK).
- Все исходящие вызовы Bot API — через **`telegram_relay.py`** (`RELAY_URL`).
### Веб-интерфейс
- **`db_edit.py`** — CRUD постов/событий, кнопки рескана, публикации, «Назвать» (AI shortname).
- **`db_update_shortname.py`** — генерация коротких названий через RouterAI.
- **`test_tg_poster.py`** — тестовый пост в канал (кнопка «Тест» в UI).
- Шаблоны: `templates/`.
### База данных
- **`ensure_db.py`** — создание/миграция схемы (вызывается из loader-скриптов).
- **`backupdb.sh`** — дамп БД при deploy (`post-deploy.sh`).
## Пайплайн runner.sh (ZILANT)
Порядок выполнения (systemd timer `runner.timer`):
1. `vk_load.py` — загрузка постов из VK
2. `zk_load.py` — загрузка событий из JSON
3. `db_update_shortname.py` — AI shortname для новых записей
4. `tg_publish.py` — публикация постов
5. `evtg_publish.py` — публикация событий
> **`tools/legacy/evt_prefetch.py`** раньше стоял перед `evtg_publish.py`; с 1.0.0 снят с пайплайна.
## Deploy
- **`.woodpecker.yml`** — rsync на сервер, затем `services/post-deploy.sh`.
- **`post-deploy.sh`** — venv, pip, backup БД, обновление systemd units, restart сервисов.
## Конфигурация (.env)
Шаблон для нового инстанса: [`.env.example`](../.env.example).
```bash
cp .env.example .env
# заполнить CHANGE_ME_*; секреты: python tools/ops/genkey.py
```
| Переменная | Назначение |
|------------|------------|
| `WORKMODE` | `ZILANT` или `VOLK` |
| `SEASON` | Префикс сезона для deep link (`zk2026`) |
| `RELAY_URL` | Internal relay для Bot API |
| `WEBHOOK_URL`, `WEBHOOK_SECRET` | Доставка апдейтов боту |
| `MDB_*`, `MDBASE` | MariaDB |
| `POSTER_BOT_TOKEN`, `RESPONDER_BOT_TOKEN` | Токены ботов |
| `PZK_PREFIX`, `EVT_PREFIX`, `DESC_PREFIX` | Ссылки ZILANT |
| `VK_NO_REPOST_TAG` | Тег VK, отключающий автопубликацию |
| `VK_POST_MIN_LETTERS` | Минимум букв в тексте VK-поста (иначе «пустой пост», не публиковать) |
| `VOLK_*` | API VOLK (при `WORKMODE=VOLK`) |
| `RA_KEY`, `RA_MODEL` | RouterAI для shortname |
## Карта файлов
### Ядро (runtime)
| Файл | Роль |
|------|------|
| `tg_mainbot.py` | Responder-бот, webhook |
| `wsgi_bot.py` | Gunicorn entry (бот) |
| `db_edit.py` | Веб-UI |
| `wsgi_edit.py` | Gunicorn entry (UI) |
| `tg_publish.py` | Публикация постов |
| `evtg_publish.py` | Публикация событий |
| `vk_load.py` | Загрузка VK |
| `zk_load.py` | Загрузка Zilant JSON |
| `volk_load.py` | Загрузка VOLK (режим VOLK) |
| `volk_cat_publish.py` | Публикация VOLK (режим VOLK) |
| `db_update_shortname.py` | AI shortname |
| `formatter.py` | Форматирование текста постов/событий |
| `ensure_db.py` | Схема БД |
| `telegram_relay.py` | Relay для Bot API |
| `season_links.py` | Сезонные ссылки и callback |
| `test_tg_poster.py` | Тестовая публикация (UI) |
| `runner.sh` | Пайплайн по расписанию |
| `backupdb.sh` | Бэкап БД |
| `gunicorn_bot.conf.py` | Stub-конфиг gunicorn (бот) |
### Инфраструктура
| Путь | Роль |
|------|------|
| `services/testbot-git.service` | systemd: бот :5005 |
| `services/testedit-git.service` | systemd: UI :5006 |
| `services/runner.service`, `runner.timer` | systemd: hourly runner |
| `services/post-deploy.sh` | Post-deploy |
| `.woodpecker.yml` | CI deploy |
### Вспомогательные (`tools/`)
| Путь | Роль |
|------|------|
| `tools/ops/genkey.py` | Генерация секретов |
| `tools/ops/register_tg_webhook.py` | Ручная регистрация webhook |
| `tools/ops/copy_databases.sh` | Клонирование MariaDB |
| `tools/dev/send_telegram_example.py` | CLI-пример `/send_message` |
| `tools/dev/volk_telegram_testing.py` | Отладка публикации в Telegram |
| `tools/dev/volk_category_testing.py` | Отладка загрузки категории VOLK |
| `tools/legacy/evt_prefetch.py` | Legacy: prefetch событий |
См. [`tools/README.md`](../tools/README.md).
### Документация
| Файл | Содержание |
|------|------------|
| `README.md` | Быстрый старт |
| `docs/ARCHITECTURE.md` | Этот файл |
| `docs/volk-mode.md` | Режим VOLK |
| `docs/telegram-messaging.md` | API `/send_message` |
| `docs/runner-setup.md` | Настройка runner timer |
| `docs/CHANGELOG.md` | История версий |
## Версионирование
- Версия в корне: **`VERSION`** (SemVer: `MAJOR.MINOR.PATCH`).
- При каждом значимом изменении обновлять `VERSION` и запись в `docs/CHANGELOG.md`.
- Правило для агентов: `.cursor/rules/version-and-docs.mdc`.
+98
View File
@@ -0,0 +1,98 @@
# Changelog
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
Версия проекта — в файле [`VERSION`](../VERSION) (SemVer).
## [1.1.4] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: убрано сравнение `min_length` (HTML длиннее plain text от Telegram) — лишние retry, мигание ссылок и обрыв пакетной публикации из UI.
### Changed
- Проверка footer-ссылок: убрано сравнение `min_length` (HTML длиннее plain text от Telegram).
## [1.1.3] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: сравнение по конкретным URL (Инфо/Иду/Подписка, ВК), а не по любому `text_link` или словам в тексте тела события — устраняет ложный «успех» при пакетной публикации.
## [1.1.2] - 2026-09-02
### Fixed
- Проверка успешного edit ссылок: Telegram отдаёт plain text и `text_link` entities, а не `href=` в caption; `message is not modified` трактуется как успех (ссылки уже применены). Устраняет ложные ошибки при успешной публикации.
## [1.1.1] - 2026-09-02
### Fixed
- Публикация постов и событий: второй шаг (edit со строкой ссылок) повторяется до появления `href=` в ответе API; без подтверждённых ссылок запись не помечается опубликованной. Устраняет редкие случаи, когда edit логировался как успех, а ссылки в канале отсутствовали.
## [1.1.0] - 2026-08-30
### Added
- Импорт VK (`vk_load.py`): посты с числом букв меньше `VK_POST_MIN_LETTERS` (по умолчанию 5) сохраняются с названием `- = Пустой пост = -` и без отметки «к публикации».
### Documentation
- Параметр `VK_POST_MIN_LETTERS` в `.env.example` и `.env`.
## [1.0.4] - 2026-08-30
### Changed
- `runner.sh` пишет лог в `logs/runner.log` (каталог создаётся при первом запуске).
## [1.0.3] - 2026-08-30
### Fixed
- Публикация событий (`evtg_publish.py`): картинка скачивается на сервере и уходит в `sendPhoto` файлом, как у постов VK. Раньше в Bot API передавался URL (`http://reg.konvent.ru/...`), Telegram не мог его забрать (`failed to get HTTP URL content`) и пост уходил текстом.
## [1.0.2] - 2026-08-30
### Added
- Шаблон окружения [`.env.example`](../.env.example) для ZILANT и VOLK (секреты — плейсхолдеры).
- `.env` добавлен в [`.gitignore`](../.gitignore).
### Documentation
- README и `docs/ARCHITECTURE.md`: копирование `.env.example``.env` при настройке инстанса.
## [1.0.1] - 2026-08-30
### Changed
- Утилиты перенесены в `tools/`:
- `tools/ops/``register_tg_webhook.py`, `copy_databases.sh` (рядом с `genkey.py`)
- `tools/dev/``send_telegram_example.py`, `volk_telegram_testing.py`, `volk_category_testing.py`
- Скрипты в `tools/` загружают `.env` из корня проекта и добавляют корень в `sys.path` при импорте модулей ядра.
### Documentation
- Обновлены `tools/README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`, `docs/telegram-messaging.md`, `README.md`.
## [1.0.0] - 2026-08-30
### Added
- Файл версии `VERSION` (SemVer).
- Документация: `README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`.
- Каталог `tools/` с README; `tools/ops/genkey.py`, `tools/legacy/evt_prefetch.py`.
- Сезонные deep link и callback с префиксом `SEASON` (`season_links.py`, по умолчанию `zk2026`).
- Интеграция Telegram Bot API через internal relay (`telegram_relay.py`, `RELAY_URL`).
### Changed
- `evt_prefetch.py` перенесён в `tools/legacy/` — не входит в `runner.sh`.
- `genkey.py` перенесён в `tools/ops/`.
- Документация по runner и отправке сообщений — в `docs/`.
### Documentation
- Правило Cursor `.cursor/rules/version-and-docs.mdc` — поддержка версии и changelog при изменениях.
+77
View File
@@ -0,0 +1,77 @@
# Настройка автоматического запуска runner.sh
См. также [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) — состав пайплайна.
Есть два способа настроить запуск `runner.sh` каждый час:
## Вариант 1: Systemd Timer (рекомендуется)
В проекте уже используются systemd-сервисы.
### Установка
1. Скопируйте файлы в systemd:
```bash
sudo cp services/runner.service /etc/systemd/system/
sudo cp services/runner.timer /etc/systemd/system/
```
2. Или используйте скрипт установки:
```bash
chmod +x services/install_runner_timer.sh
sudo bash services/install_runner_timer.sh
```
3. Вручную:
```bash
sudo systemctl daemon-reload
sudo systemctl enable runner.timer
sudo systemctl start runner.timer
```
### Управление
- Статус: `sudo systemctl status runner.timer`
- Остановить: `sudo systemctl stop runner.timer`
- Запустить: `sudo systemctl start runner.timer`
- Отключить: `sudo systemctl disable runner.timer`
- Логи: `sudo journalctl -u runner.service -f`
- Список timers: `sudo systemctl list-timers`
### Расписание
Файл `/etc/systemd/system/runner.timer`:
- `OnCalendar=hourly` — каждый час
- `OnCalendar=*-*-* *:00:00` — в начале каждого часа
- `OnCalendar=*-*-* 0,6,12,18:00:00` — в 0, 6, 12, 18 часов
- `OnCalendar=Mon..Fri 09:00:00` — будни в 9:00
После изменения: `sudo systemctl daemon-reload`
---
## Вариант 2: Cron
```bash
sudo crontab -e
```
```bash
0 * * * * /bin/bash /opt/testbot-git/runner.sh
```
Другие варианты: `30 * * * *`, `*/30 * * * *`, `0 9-17 * * *`.
---
## Состав runner.sh (ZILANT)
`vk_load.py``zk_load.py``db_update_shortname.py``tg_publish.py``evtg_publish.py`
Legacy `tools/legacy/evt_prefetch.py` в пайплайн не входит (с 1.0.0).
Лог пайплайна: `/opt/testbot-git/logs/runner.log` (каталог `logs/` создаётся автоматически).
+94
View File
@@ -0,0 +1,94 @@
# Отправка сообщений через Telegram бота
## Описание
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
1. **API endpoint** для отправки сообщений из других скриптов
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
3. **Обработку ошибок** и логирование всех операций
## Архитектура системы
Система состоит из двух основных компонентов:
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
### Конфигурация портов
Порты и URL настраиваются в файле `.env`:
- `RELAY_URL` - базовый URL **internal_relay** (все исходящие вызовы Bot API идут только через него)
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
- `WEBHOOK_URL` - URL доставки апдейтов от internal_relay к боту
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
## Использование через веб-интерфейс
В заголовке страницы добавлены поля **ID пользователя**, **Сообщение** и кнопка **Отправить**.
## Использование через API
### Endpoint
```
POST /send_message
POST {WEBHOOK_PATH}/send_message
```
### Заголовки
```
Content-Type: application/json
X-API-Secret-Token: <WEBHOOK_SECRET>
```
### Тело запроса
```json
{
"user_id": 123456789,
"message": "Текст сообщения"
}
```
### Пример (Python)
```python
import requests
def send_telegram_message(user_id, message):
url = "http://192.168.0.109:5005/testbot/send_message"
headers = {
"Content-Type": "application/json",
"X-API-Secret-Token": "ваш_секретный_токен",
}
response = requests.post(url, json={"user_id": user_id, "message": message}, headers=headers)
return response.json()
```
### Пример (curl)
```bash
curl -X POST http://localhost:5005/testbot/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
```
Опционально: CLI-скрипт `tools/dev/send_telegram_example.py`.
## Коды ошибок API
- `INVALID_TOKEN`, `NO_DATA`, `NO_USER_ID`, `NO_MESSAGE`, `INVALID_USER_ID`
- `TELEGRAM_ERROR`, `INTERNAL_ERROR`
## Требования
- Бот запущен (webhook через `wsgi_bot.py`)
- Пользователь начал диалог с ботом (`/start`)
См. также [`ARCHITECTURE.md`](ARCHITECTURE.md).
+53
View File
@@ -0,0 +1,53 @@
# Режим VOLK
Режим включается в `.env` (скопируйте из [`.env.example`](../.env.example)):
```env
WORKMODE = "VOLK"
```
Проект поддерживает оба режима — **ZILANT** и **VOLK**; переключение только через `WORKMODE`, без смены кодовой базы.
## Отличия от ZILANT
| Аспект | ZILANT | VOLK |
|--------|--------|------|
| Загрузка данных | `vk_load.py`, `zk_load.py` | `volk_load.py` |
| Публикация | `tg_publish.py`, `evtg_publish.py` | `volk_cat_publish.py` |
| Runner | Полный пайплайн ZILANT | Отдельные кнопки рескана в UI |
| UI | Посты + события VK/Zilant | Категории (площадки) + события VOLK |
## Модули VOLK
### `volk_load.py`
- Загружает список категорий и событий с VOLK API.
- Переменные: `VOLK_CATEGORY_LIST`, `VOLK_CATEGORY_DESC_PREFIX`, `VOLK_EVENT_LIST_PREFIX`, `VOLK_API_VERSION`.
- Вызывается из `db_edit.py` (API `/api/run_volk_rescan`, кнопка «Рескан ВОЛК»).
### `volk_cat_publish.py`
- Публикует категории (площадки) в Telegram-канал.
- Функции `tg_post_category_by_id`, `tg_post_all_categories` — из веб-UI.
### Шаблоны
- `templates/edit_category.html` — редактирование площадки (только VOLK).
- Блоки `{% if g.workmode == 'VOLK' %}` в `templates/index.html`.
## Отладочные скрипты (не в production-пайплайне)
| Файл | Запуск |
|------|--------|
| `tools/dev/volk_category_testing.py` | `python tools/dev/volk_category_testing.py <category_id>` |
| `tools/dev/volk_telegram_testing.py` | `python tools/dev/volk_telegram_testing.py` |
См. [`tools/README.md`](../tools/README.md).
## Переключение режима на сервере
1. Изменить `WORKMODE` в `/opt/testbot-git/.env`.
2. Перезапустить UI: `sudo systemctl restart testedit-git.service`.
3. Responder-бот (`testbot-git`) от режима не зависит; сезонные ссылки общие (`SEASON`).
При смене режима проверьте переменные VOLK/ZILANT в `.env` (префиксы URL, API, `LOG_FILE`).
+111 -9
View File
@@ -6,6 +6,7 @@ import random
from datetime import datetime
from mysql.connector import errorcode
from dotenv import load_dotenv
from werkzeug.security import generate_password_hash
# Загрузка переменных окружения
load_dotenv()
@@ -44,6 +45,10 @@ def ensure_database_structure():
"""Основная функция проверки и создания БД/таблиц"""
conn = None
cursor = None
# Проверка режима отладки
log_debug = os.getenv('LOG_DEBUG_DATA', 'false').lower() == 'true'
changes_made = False # Флаг для отслеживания изменений
try:
# Подключение без указания базы данных
@@ -55,9 +60,24 @@ def ensure_database_structure():
cursor = conn.cursor(buffered=True) # Используем буферизованный курсор
# Создание базы данных если не существует
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
# Проверяем существование базы данных
cursor.execute("""
SELECT COUNT(*)
FROM information_schema.schemata
WHERE schema_name = %s
""", (DB_CONFIG['database'],))
db_exists = cursor.fetchone()[0] > 0
if not db_exists:
cursor.execute(f"CREATE DATABASE {DB_CONFIG['database']} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
changes_made = True
log_message(f"Database {DB_CONFIG['database']} created")
else:
if log_debug:
log_message(f"Database {DB_CONFIG['database']} already exists")
cursor.execute(f"USE {DB_CONFIG['database']}")
log_message("Database checked/created successfully")
# Список таблиц и их структур
tables = {
@@ -83,7 +103,8 @@ def ensure_database_structure():
tg_poll_results TEXT DEFAULT '',
is_event BOOLEAN DEFAULT 0,
shortname VARCHAR(100),
action_number BIGINT NULL
action_number BIGINT NULL,
auto_unit VARCHAR(255)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''',
'marks': '''
@@ -122,8 +143,9 @@ def ensure_database_structure():
number BIGINT NOT NULL,
unit_name VARCHAR(255),
name VARCHAR(255),
about VARCHAR(2048),
about VARCHAR(4096),
about_social_picture VARCHAR(2048),
authors VARCHAR(255),
modified DATETIME NULL,
added DATETIME NULL,
tags VARCHAR(255),
@@ -145,6 +167,7 @@ def ensure_database_structure():
post_id INT NOT NULL,
event_id INT NOT NULL,
created_at DATETIME NULL,
auto_added BOOLEAN DEFAULT 0,
FOREIGN KEY (post_id)
REFERENCES posts(id)
ON DELETE CASCADE,
@@ -152,6 +175,19 @@ def ensure_database_structure():
REFERENCES events(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''',
'categories': '''
CREATE TABLE categories (
AUTO_ID INT AUTO_INCREMENT PRIMARY KEY,
ID VARCHAR(255),
TITLE VARCHAR(2048),
description TEXT,
tg_id BIGINT NULL,
image_url VARCHAR(2048),
is_tg_published BOOLEAN DEFAULT 0,
tg_published_date DATETIME NULL,
marked_for_publication BOOLEAN DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
'''
}
@@ -168,16 +204,42 @@ def ensure_database_structure():
if cursor.fetchone()[0] == 0:
# Таблица не существует, создаем её
cursor.execute(table_sql)
changes_made = True
log_message(f"Table {table_name} created")
else:
log_message(f"Table {table_name} exists")
if log_debug:
log_message(f"Table {table_name} exists")
except mysql.connector.Error as err:
log_message(f"Error checking/creating table {table_name}: {err}")
raise err
# Создание дефолтного админа, если таблица users пуста
try:
cursor.execute("SELECT COUNT(*) as count FROM users")
user_count = cursor.fetchone()[0]
if user_count == 0:
username = os.getenv('DEFNM')
password = os.getenv('DEFPW')
if username and password:
hashed_password = generate_password_hash(password)
cursor.execute(
"INSERT INTO users (username, password) VALUES (%s, %s)",
(username, hashed_password)
)
changes_made = True
log_message(f"Created default admin user: {username}")
else:
# Важное предупреждение - всегда логируем
log_message("Environment variables DEFNM/DEFPW not set. Admin not created.")
except mysql.connector.Error as err:
log_message(f"Error creating default admin: {err}")
# Проверка и добавление новых полей в существующие таблицы
table_alterations = {
'posts': [
{'column': 'auto_unit', 'type': 'VARCHAR(255)'}
],
'marks': [
{'column': 'autolink', 'type': 'BOOLEAN DEFAULT 0'},
{'column': 'reminded_at', 'type': 'DATETIME NULL'}
@@ -185,6 +247,20 @@ def ensure_database_structure():
'marks_evt': [
{'column': 'autolink', 'type': 'BOOLEAN DEFAULT 0'},
{'column': 'reminded_at', 'type': 'DATETIME NULL'}
],
'interlinks': [
{'column': 'auto_added', 'type': 'BOOLEAN DEFAULT 0'}
],
'categories': [
{'column': 'description', 'type': 'TEXT'},
{'column': 'tg_id', 'type': 'BIGINT NULL'},
{'column': 'image_url', 'type': 'VARCHAR(2048)'},
{'column': 'is_tg_published', 'type': 'BOOLEAN DEFAULT 0'},
{'column': 'tg_published_date', 'type': 'DATETIME NULL'},
{'column': 'marked_for_publication', 'type': 'BOOLEAN DEFAULT 0'}
],
'events': [
{'column': 'authors', 'type': 'VARCHAR(255)'}
]
}
@@ -204,9 +280,11 @@ def ensure_database_structure():
if cursor.fetchone()[0] == 0:
# Столбец не существует, добавляем его
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column} {column_type}")
changes_made = True
log_message(f"Added column {column} to table {table_name}")
else:
log_message(f"Column {column} already exists in table {table_name}")
if log_debug:
log_message(f"Column {column} already exists in table {table_name}")
except mysql.connector.Error as err:
log_message(f"Error checking/adding column {column} to table {table_name}: {err}")
@@ -246,18 +324,42 @@ def ensure_database_structure():
'CREATE UNIQUE INDEX IF NOT EXISTS uniq_post_event ON interlinks (post_id, event_id)'
]
# Создание индексов для таблицы categories
categories_indexes = [
'CREATE INDEX IF NOT EXISTS idx_categories_id ON categories (ID)'
]
# Объединяем все индексы
all_indexes = marks_indexes + marks_evt_indexes + events_indexes + interlinks_indexes
all_indexes = marks_indexes + marks_evt_indexes + events_indexes + interlinks_indexes + categories_indexes
for index_sql in all_indexes:
try:
cursor.execute(index_sql)
# Извлекаем имя индекса и таблицы из SQL
index_name = index_sql.split('IF NOT EXISTS')[1].split(' ON ')[0].strip()
log_message(f"Index created: {index_name}")
table_name = index_sql.split(' ON ')[1].split(' (')[0].strip()
# Проверяем существование индекса
cursor.execute("""
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = %s AND table_name = %s AND index_name = %s
""", (DB_CONFIG['database'], table_name, index_name))
index_exists = cursor.fetchone()[0] > 0
if not index_exists:
# Индекс не существует, создаем его
cursor.execute(index_sql)
changes_made = True
log_message(f"Index created: {index_name}")
else:
if log_debug:
log_message(f"Index {index_name} already exists")
except mysql.connector.Error as err:
log_message(f"Error creating index: {err}")
conn.commit()
# Финальное сообщение выводится всегда
log_message("Database structure ensured successfully")
except mysql.connector.Error as err:
+640 -118
View File
@@ -4,11 +4,20 @@ import asyncio
import pymysql
import time
import html
import httpx
from datetime import datetime, timezone, timedelta
from telegram import Bot, InlineKeyboardMarkup, InlineKeyboardButton
from telegram.error import TelegramError, RetryAfter
from urllib.parse import urlparse, unquote
from dotenv import load_dotenv
from formatter import get_event_text
from telegram_relay import (
bot_api_method_url,
EDIT_LINKS_INITIAL_DELAY_SEC,
EDIT_LINKS_MAX_ATTEMPTS,
EDIT_LINKS_RETRY_INTERVAL_SEC,
published_message_has_expected_links,
extract_text_link_urls,
)
from season_links import subscription_start_link
# Загрузка переменных окружения
load_dotenv()
@@ -25,11 +34,18 @@ DESC_PREFIX = os.getenv('DESC_PREFIX')
PZK_PREFIX = os.getenv('PZK_PREFIX')
LOG_FILE = os.getenv('LOG_FILE', 'evtg_publisher.log')
EVENT_POST_DELAY = int(os.getenv('EVENT_POST_DELAY', 0)) # Задержка в минутах
WORKMODE = os.getenv('WORKMODE', 'ZILANT') # Режим работы: ZILANT или VOLK
# Параметры длины сообщений
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
# Режим публикации без звука
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
# Использование бота подписки
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
# Настройка логирования
logger = logging.getLogger('TG_EV_post')
logger.setLevel(logging.INFO)
@@ -54,6 +70,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Класс для обработки RetryAfter (FloodWait 429)
class RetryAfterException(Exception):
"""Исключение для обработки FloodWait (429) от Telegram API"""
def __init__(self, retry_after):
self.retry_after = retry_after
super().__init__(f"RetryAfter: {retry_after}")
def format_tags(tags_str):
"""Форматирование тегов с добавлением #"""
if not tags_str:
@@ -77,14 +100,188 @@ def prepare_text(text):
return text
async def tg_post_event(bot, event_data):
def _filename_from_image_url(image_url):
path = unquote(urlparse(image_url).path)
name = os.path.basename(path) or "image.jpg"
if "." not in name:
name = f"{name}.jpg"
return name
async def download_image(image_url, httpx_client):
"""Скачивает картинку самим сервером (как tg_publish), не отдаёт URL Telegram.
sendPhoto с photo=http://... Telegram качает сам и часто отвечает
«failed to get HTTP URL content» (http, недоступность с DC Telegram).
"""
logger.info(f"Скачивание изображения: {image_url}")
started = time.time()
response = await httpx_client.get(
image_url,
timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0),
)
response.raise_for_status()
image_data = response.content
content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
if not content_type.startswith("image/"):
content_type = "image/jpeg"
logger.info(
f"Изображение скачано за {time.time() - started:.2f} с, "
f"размер: {len(image_data)} байт, type={content_type}"
)
return image_data, content_type, _filename_from_image_url(image_url)
async def _edit_event_add_links(
httpx_client, has_image, message_id, final_text, entity_label, expected_urls
):
"""Edit caption/text со ссылками; повтор до подтверждения всех expected_urls в text_link."""
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
method = "editMessageCaption" if has_image else "editMessageText"
field = "caption" if has_image else "text"
entities_field = "caption_entities" if has_image else "entities"
api_url = bot_api_method_url(BOT_TOKEN, method)
logger.info(
f"Попытка редактирования {entity_label}: message_id={message_id}, "
f"final_text_length={len(final_text)}"
)
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
field: final_text,
'parse_mode': 'HTML',
}
if not has_image:
payload['disable_web_page_preview'] = True
try:
response = await httpx_client.post(api_url, json=payload)
except httpx.TimeoutException as e:
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
logger.error(
f"Таймаут edit ссылок для {entity_label} после "
f"{EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
)
return False
logger.warning(
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}"
)
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
continue
if response.status_code == 200:
data = response.json()
if data.get('ok'):
result = data.get('result') or {}
result_text = result.get(field) or ""
entities = result.get(entities_field) or []
if published_message_has_expected_links(entities, expected_urls):
logger.info(
f"Ссылки добавлены для {entity_label} "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
found = extract_text_link_urls(entities)
logger.warning(
f"edit ok, но footer-ссылки не подтверждены "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}; "
f"ожидались {expected_urls}, найдены text_link: {found}"
)
else:
error_code = data.get('error_code')
error_description = (data.get('description') or '').lower()
if error_code == 429:
retry_after = data.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
if error_code == 400 and "not modified" in error_description:
logger.info(
f"Caption уже содержит ссылки для {entity_label} "
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"API error edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {data.get('description')}"
)
elif response.status_code == 429:
retry_after = 60
try:
retry_after = response.json().get('parameters', {}).get('retry_after', 60)
except Exception:
pass
raise Exception(f"RetryAfter:{retry_after}")
elif response.status_code == 400:
try:
error_description = (response.json().get('description') or '').lower()
if "not modified" in error_description:
logger.info(
f"Caption уже содержит ссылки для {entity_label} "
f"(message is not modified HTTP 400, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
except Exception:
logger.warning(
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
else:
logger.warning(
f"HTTP {response.status_code} edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
logger.error(
f"Не удалось добавить строку ссылок для {entity_label} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
)
return False
async def tg_post_event(httpx_client, event_data):
"""Публикация одного события"""
try:
# Форматирование текста
tags = format_tags(event_data['tags'])
unit_name = prepare_text(event_data['unit_name'] or "")
unit_name_raw = event_data.get('unit_name') or ""
name = prepare_text(event_data['name'] or "")
# Формируем название площадки с возможной ссылкой
unit_name = ""
if unit_name_raw:
unit_tg_id = event_data.get('unit_tg_id')
unit_title = event_data.get('unit_title') or unit_name_raw
unit_title_escaped = prepare_text(unit_title)
if unit_tg_id:
# Если площадка опубликована, делаем ссылку на сообщение
# Используем логику из format_channel_link из tg_mainbot.py
if CHANNEL_ID.startswith('@'):
# Публичный канал
channel_link = f"https://t.me/{CHANNEL_ID[1:]}/{unit_tg_id}"
elif CHANNEL_ID.startswith('-100'):
# Приватный канал с префиксом -100
channel_link = f"https://t.me/c/{CHANNEL_ID[4:]}/{unit_tg_id}"
else:
# Приватный канал без префикса или другой формат
channel_link = f"https://t.me/c/{CHANNEL_ID}/{unit_tg_id}"
# Формируем ссылку на сообщение
unit_name = f'<a href="{channel_link}">{unit_title_escaped}</a>'
else:
# Если площадка не опубликована, просто текст
unit_name = unit_title_escaped
# Получаем и подготавливаем описание события
about_raw = event_data['about']
@@ -102,8 +299,23 @@ async def tg_post_event(bot, event_data):
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
number = event_data['number']
placeholder_message_id = '0' * 20 # Заполнитель для message_id
links_line_placeholder = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{placeholder_message_id}">Подписка</a>'
links_line_length = len(links_line_placeholder)
# Формируем ссылки в зависимости от USE_SUBSCRIPTION_BOT и наличия DESC_PREFIX
links_parts = []
if DESC_PREFIX:
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
if USE_SUBSCRIPTION_BOT:
links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
links_parts.append(
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", placeholder_message_id)}">Подписка</a>'
)
if links_parts:
links_line_placeholder = ' | '.join(links_parts)
links_line_length = len(links_line_placeholder)
else:
links_line_placeholder = ""
links_line_length = 0
# Определяем максимальную длину в зависимости от типа сообщения
image_url = event_data['about_social_picture']
@@ -121,82 +333,275 @@ async def tg_post_event(bot, event_data):
# Формируем первоначальный текст
initial_text = f"{base_text}\n\n{about}" if about else base_text
# Логируем текст для отладки
logger.info(f"Текст для публикации события номер {event_data['number']}: {initial_text}")
# Логируем детальную информацию о данных для публикации
logger.info(f"=== Детали публикации события номер {event_data['number']} ===")
logger.info(f"Длина initial_text: {len(initial_text)} символов")
logger.info(f"Длина base_text: {base_text_length} символов")
logger.info(f"Длина about: {len(about)} символов")
logger.info(f"Длина links_line_placeholder: {links_line_length} символов")
logger.info(f"Максимальная длина (max_length): {max_length} символов")
logger.info(f"Доступная длина для about: {available_length} символов")
logger.info(f"Есть изображение: {has_image}")
if has_image:
logger.info(f"URL изображения: {image_url}")
logger.info(f"Длина URL изображения: {len(image_url)} символов")
logger.info(f"Текст для публикации: {initial_text[:200]}..." if len(initial_text) > 200 else f"Текст для публикации: {initial_text}")
# Публикация первоначального сообщения без ссылок
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
message_is_photo = False
if has_image:
try:
message = await bot.send_photo(
chat_id=CHANNEL_ID,
photo=image_url,
caption=initial_text,
parse_mode="HTML",
disable_notification=True
logger.info(
f"Попытка отправки фото для события {event_data['number']}: "
f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
f"image_url={image_url}"
)
except TelegramError as e:
image_data, content_type, filename = await download_image(image_url, httpx_client)
photo_data = {
'chat_id': CHANNEL_ID,
'caption': initial_text,
'parse_mode': 'HTML',
'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
}
photo_files = {
'photo': (filename, image_data, content_type),
}
photo_response = await httpx_client.post(
send_photo_url, data=photo_data, files=photo_files
)
if photo_response.status_code == 200:
photo_result = photo_response.json()
if photo_result.get('ok'):
message_data = photo_result.get('result', {})
message_id = message_data.get('message_id')
message_is_photo = True
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение
error_code = photo_result.get('error_code')
if error_code == 429:
# FloodWait - пробрасываем как RetryAfter
retry_after = photo_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {photo_result.get('description', 'Unknown error')}")
else:
if photo_response.status_code == 429:
# FloodWait
retry_after = 60
try:
error_data = photo_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {photo_response.status_code}: {photo_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при отправке изображения для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" image_url: {image_url}")
logger.error(f" caption_length: {len(initial_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
retry_after = int(error_msg.split(":")[1])
raise Exception(f"RetryAfter:{retry_after}")
logger.warning(f"Не удалось отправить изображение для события номер {event_data['number']}: {e}. Отправляем текстовое сообщение.")
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=initial_text,
parse_mode="HTML",
disable_notification=True,
disable_web_page_preview=True # Отключаем предпросмотр ссылок
)
logger.error(f"Полная информация об ошибке Telegram при отправке изображения для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
logger.info(f"Попытка отправки текстового сообщения вместо фото для события {event_data['number']}")
try:
text_payload = {
'chat_id': CHANNEL_ID,
'text': initial_text,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY,
'disable_web_page_preview': True
}
text_response = await httpx_client.post(send_message_url, json=text_payload)
if text_response.status_code == 200:
text_result = text_response.json()
if text_result.get('ok'):
message_data = text_result.get('result', {})
message_id = message_data.get('message_id')
logger.info(f"Текстовое сообщение успешно отправлено для события {event_data['number']}, message_id={message_id}")
else:
error_code = text_result.get('error_code')
if error_code == 429:
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
else:
if text_response.status_code == 429:
retry_after = 60
try:
error_data = text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" text_length: {len(initial_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
raise
else:
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=initial_text,
parse_mode="HTML",
disable_notification=True,
disable_web_page_preview=True # Отключаем предпросмотр ссылок
)
# Получаем ID сообщения для использования в ссылке подписки
message_id = message.message_id
logger.info(f"Попытка отправки текстового сообщения для события {event_data['number']}: chat_id={CHANNEL_ID}, text_length={len(initial_text)}")
try:
text_payload = {
'chat_id': CHANNEL_ID,
'text': initial_text,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY,
'disable_web_page_preview': True
}
text_response = await httpx_client.post(send_message_url, json=text_payload)
if text_response.status_code == 200:
text_result = text_response.json()
if text_result.get('ok'):
message_data = text_result.get('result', {})
message_id = message_data.get('message_id')
logger.info(f"Текстовое сообщение успешно отправлено для события {event_data['number']}, message_id={message_id}")
else:
error_code = text_result.get('error_code')
if error_code == 429:
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
else:
if text_response.status_code == 429:
retry_after = 60
try:
error_data = text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" text_length: {len(initial_text)}")
logger.error(f" parse_mode: HTML")
raise
# Формируем финальный текст с ссылками
links_line = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{message_id}">Подписка</a>'
final_text = f"{initial_text}\n\n{links_line}"
await asyncio.sleep(2)
# Редактируем сообщение, добавляя ссылки
if has_image:
try:
await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode="HTML"
)
except TelegramError as e:
logger.warning(f"Не удалось отредактировать подпись для события номер {event_data['number']}: {e}. Пробуем отредактировать текстовое сообщение.")
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode="HTML",
disable_web_page_preview=True # Отключаем предпросмотр ссылок
)
else:
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode="HTML",
disable_web_page_preview=True # Отключаем предпросмотр ссылок
links_parts = []
if DESC_PREFIX:
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
if USE_SUBSCRIPTION_BOT:
links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
links_parts.append(
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)}">Подписка</a>'
)
if links_parts:
links_line = ' | '.join(links_parts)
final_text = f"{initial_text}\n\n{links_line}"
else:
final_text = initial_text
expected_urls = []
if DESC_PREFIX:
expected_urls.append(f"{DESC_PREFIX}{number}/")
if USE_SUBSCRIPTION_BOT:
expected_urls.append(f"{PZK_PREFIX}{number}/")
expected_urls.append(
subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)
)
# Проверяем, изменился ли текст. Если нет - не редактируем сообщение
if final_text == initial_text:
logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
return message_id
entity_label = f"события {event_data['number']}"
links_ok = await _edit_event_add_links(
httpx_client, message_is_photo, message_id, final_text, entity_label,
expected_urls,
)
if not links_ok and message_is_photo:
links_ok = await _edit_event_add_links(
httpx_client, False, message_id, final_text, entity_label,
expected_urls,
)
if not links_ok:
logger.error(
f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
f"но ссылки не добавлены"
)
return None
return message_id # Возвращаем только message_id
except RetryAfter as e:
# Обработка ошибки FloodWait (429)
logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}: {e}")
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
raise # Пробрасываем исключение выше для обработки на уровне цикла
except TelegramError as e:
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при публикации события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Контекст события:")
logger.error(f" number: {event_data.get('number')}")
logger.error(f" has_image: {bool(event_data.get('about_social_picture'))}")
logger.error(f" image_url_length: {len(event_data.get('about_social_picture', '') or '')}")
logger.error(f" initial_text_length: {len(initial_text) if 'initial_text' in locals() else 'N/A'}")
logger.error(f" final_text_length: {len(final_text) if 'final_text' in locals() else 'N/A'}")
return None
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
# Обработка ошибки FloodWait (429)
retry_after = int(error_msg.split(":")[1])
logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}")
logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
logger.error(f"Полная информация об ошибке RetryAfter для события {event_data['number']}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {error_msg}")
logger.error(f" retry_after: {retry_after}")
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации события номер {event_data['number']}: {e}")
logger.error(f"Полная информация об ошибке Telegram для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
return None
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации события номер {event_data['number']}: {e}")
@@ -213,60 +618,147 @@ async def tg_post_event_by_id(id_event):
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
cursorclass=pymysql.cursors.DictCursor,
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
)
# Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
# Проверка времени изменения только в режиме ZILANT
with conn.cursor() as cursor:
cursor.execute("""
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
FROM events
WHERE id_event = %s
AND marked_to_publication = True
AND is_posted_tg = False
AND modified <= %s
""", (id_event, modified_before))
event = cursor.fetchone()
if WORKMODE == "ZILANT":
# Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
cursor.execute("""
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
FROM events
WHERE id_event = %s
AND marked_to_publication = True
AND is_posted_tg = False
AND modified <= %s
""", (id_event, modified_before))
event = cursor.fetchone()
if not event:
logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад")
return
if not event:
logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад")
return
else:
# В режиме VOLK проверка времени не выполняется
cursor.execute("""
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
FROM events
WHERE id_event = %s
AND marked_to_publication = True
AND is_posted_tg = False
""", (id_event,))
event = cursor.fetchone()
if not event:
logger.info(f"Событие ID {id_event} не найдено или уже опубликовано")
return
# Получаем информацию о категории (площадке), если указана unit_name
unit_tg_id = None
unit_title = None
if event.get('unit_name'):
cursor.execute("""
SELECT TITLE, tg_id
FROM categories
WHERE ID = %s
""", (event['unit_name'],))
category = cursor.fetchone()
if category:
unit_title = category.get('TITLE')
unit_tg_id = category.get('tg_id')
# Добавляем информацию о категории в данные события
event['unit_tg_id'] = unit_tg_id
event['unit_title'] = unit_title
old_message_id = event.get('tg_message_id') # Сохраняем старый ID сообщения
bot = Bot(token=BOT_TOKEN)
tg_message_id = await tg_post_event(bot, event) # Получаем новый message_id
# Создаем httpx клиент с отключенным HTTP/2
httpx_client = httpx.AsyncClient(
http2=False,
timeout=20.0,
follow_redirects=True
)
tg_message_id = None
try:
tg_message_id = await tg_post_event(httpx_client, event) # Получаем новый message_id
finally:
await httpx_client.aclose()
if tg_message_id:
# Используем UTC время для записи в базу данных
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Обновляем подписки если был предыдущий message_id
if old_message_id:
cursor.execute("""
UPDATE marks_evt
SET tg_event_id = %s
WHERE tg_event_id = %s
""", (tg_message_id, old_message_id))
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
# Обновляем базу данных в отдельном блоке try-except для гарантии сохранения
try:
# Обновляем подписки если был предыдущий message_id
if old_message_id:
cursor.execute("""
UPDATE marks_evt
SET tg_event_id = %s
WHERE tg_event_id = %s
""", (tg_message_id, old_message_id))
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
cursor.execute("""
UPDATE events
SET is_posted_tg = True,
tg_message_id = %s,
tg_posted_date = %s
WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event))
conn.commit()
logger.info(f"Событие номер {event['number']} успешно опубликовано")
# Обновляем данные о публикации события
cursor.execute("""
UPDATE events
SET is_posted_tg = True,
tg_message_id = %s,
tg_posted_date = %s
WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event))
conn.commit()
logger.info(f"Событие номер {event['number']} успешно опубликовано. Данные сохранены в БД: message_id={tg_message_id}, date={current_time_utc}")
except Exception as db_error:
logger.error(f"Ошибка при сохранении данных о публикации события {id_event} в БД: {db_error}")
# Пытаемся выполнить commit даже при ошибке
try:
conn.rollback()
# Повторная попытка обновления
cursor.execute("""
UPDATE events
SET is_posted_tg = True,
tg_message_id = %s,
tg_posted_date = %s
WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event))
conn.commit()
logger.info(f"Данные о публикации события {id_event} успешно сохранены после повторной попытки")
except Exception as retry_error:
logger.error(f"Критическая ошибка при сохранении данных о публикации события {id_event}: {retry_error}")
raise
else:
logger.error(f"Не удалось опубликовать событие ID {id_event}")
except RetryAfter as e:
raise e
except RetryAfterException as e:
# Обработка RetryAfter (FloodWait)
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" retry_after: {e.retry_after}")
logger.error(f" Полное представление: {repr(e)}")
raise
except Exception as e:
# Проверяем, не RetryAfter ли это в строковом виде
error_msg = str(e)
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')):
retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {error_msg}")
logger.error(f" retry_after: {retry_after}")
logger.error(f" Полное представление: {repr(e)}")
raise RetryAfterException(retry_after)
# Если это не RetryAfter, обрабатываем как обычную ошибку
logger.error(f"Ошибка при публикации: {e}")
logger.error(f"Полная информация об ошибке в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
finally:
if conn:
conn.close()
@@ -282,21 +774,33 @@ async def tg_post_all_events():
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
cursorclass=pymysql.cursors.DictCursor,
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
)
# Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
with conn.cursor() as cursor:
cursor.execute("""
SELECT id_event
FROM events
WHERE marked_to_publication = True
AND is_posted_tg = False
AND modified <= %s
LIMIT 5
""", (modified_before,))
# Проверка времени изменения только в режиме ZILANT
if WORKMODE == "ZILANT":
# Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
cursor.execute("""
SELECT id_event
FROM events
WHERE marked_to_publication = True
AND is_posted_tg = False
AND modified <= %s
LIMIT 5
""", (modified_before,))
else:
# В режиме VOLK проверка времени не выполняется
cursor.execute("""
SELECT id_event
FROM events
WHERE marked_to_publication = True
AND is_posted_tg = False
LIMIT 5
""")
events = cursor.fetchall()
event_count = 0
@@ -309,11 +813,29 @@ async def tg_post_all_events():
if event_count < len(events):
await asyncio.sleep(2)
except RetryAfter as e:
except RetryAfterException as e:
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_all_events для события ID {event['id_event']}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" retry_after: {e.retry_after}")
logger.error(f" Полное представление: {repr(e)}")
break
except Exception as e:
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
logger.error(f"Полная информация об ошибке в tg_post_all_events для события ID {event['id_event']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
# Продолжаем публикацию следующих событий, несмотря на ошибку
except Exception as e:
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
logger.error(f"Полная информация об ошибке в tg_post_all_events для события ID {event['id_event']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
# Продолжаем публикацию следующих событий, несмотря на ошибку
logger.info(f"Опубликовано событий в этом запуске: {event_count}")
+63 -5
View File
@@ -1,7 +1,7 @@
import re
def truncate_text(text, max_length):
"""Обрезает текст до максимальной длины с учетом границ слов"""
"""Обрезает текст до максимальной длины с учетом границ слов и HTML-тегов"""
if text is None or not text:
return ""
@@ -12,6 +12,18 @@ def truncate_text(text, max_length):
truncated = text[:max_length]
last_space = truncated.rfind(' ')
# Проверяем, не обрезали ли мы HTML-тег
# Если после обрезки есть незакрытый тег <a, нужно найти его начало и обрезать до него
open_tag_pos = truncated.rfind('<a ')
if open_tag_pos > 0:
# Проверяем, закрыт ли тег
after_tag = truncated[open_tag_pos:]
if '</a>' not in after_tag:
# Тег не закрыт, обрезаем до начала тега
truncated = truncated[:open_tag_pos]
# Ищем последний пробел перед обрезанным тегом
last_space = truncated.rfind(' ')
if last_space > 0:
return truncated[:last_space] + "..."
return truncated[:max_length-3] + "..."
@@ -25,15 +37,61 @@ def replace_vk_links(text):
Заменяет ссылки на сообщества ВК в форматах:
[club123|Название сообщества] -> Название сообщества
[id456|Имя пользователя] -> Имя пользователя
[текст](url) -> <a href="url">текст</a> (для Telegram)
[url|текст] -> <a href="url">текст</a> (для Telegram)
"""
if not text:
return text
# Регулярное выражение для поиска ссылок формата [типID|Текст]
pattern = r'\[(?:club|id)\d+\|([^\]]+)\]'
# Сначала обрабатываем ВК-специфичные ссылки [club*|текст] и [id*|текст]
# Это нужно сделать до обработки обычных URL, чтобы не перехватывать их
vk_pattern = r'\[(?:club|id)\d+\|([^\]]+)\]'
text = re.sub(vk_pattern, r'\1', text)
# Заменяем все вхождения на текст внутри ссылки
return re.sub(pattern, r'\1', text)
# Затем обрабатываем Markdown-ссылки [текст](url) и преобразуем их в HTML-ссылки
def replace_markdown_link(match):
link_text = match.group(1).strip()
url = match.group(2).strip()
# Проверяем, что URL похож на ссылку (содержит http://, https://, или начинается с /)
# Это предотвращает обработку обычного текста в квадратных скобках как ссылки
if not (url.startswith('http://') or url.startswith('https://') or url.startswith('/') or url.startswith('#')):
# Если это не похоже на URL, возвращаем исходный текст без изменений
return match.group(0)
# Экранируем кавычки в URL для безопасности
url = url.replace('"', '&quot;')
# Экранируем HTML-сущности в тексте ссылки
link_text = link_text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
return f'<a href="{url}">{link_text}</a>'
# Регулярное выражение для поиска Markdown-ссылок формата [текст](url)
markdown_pattern = r'\[([^\]]+)\]\(([^\)]+)\)'
text = re.sub(markdown_pattern, replace_markdown_link, text)
# Затем обрабатываем ссылки формата [url|текст] и преобразуем их в HTML-ссылки
def replace_url_link(match):
url = match.group(1).strip()
link_text = match.group(2).strip()
# Проверяем, что URL похож на ссылку (содержит http://, https://, или начинается с /)
# Это предотвращает обработку обычного текста в квадратных скобках как ссылки
if not (url.startswith('http://') or url.startswith('https://') or url.startswith('/') or url.startswith('#')):
# Если это не похоже на URL, возвращаем исходный текст без изменений
return match.group(0)
# Экранируем кавычки в URL для безопасности
url = url.replace('"', '&quot;')
# Экранируем HTML-сущности в тексте ссылки
link_text = link_text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
return f'<a href="{url}">{link_text}</a>'
# Регулярное выражение для поиска ссылок формата [url|текст]
# URL должен содержать хотя бы один символ, не быть пустым
url_pattern = r'\[([^\|\]]+)\|([^\]]+)\]'
text = re.sub(url_pattern, replace_url_link, text)
return text
def remove_html_tags(text):
"""Удаляет HTML-теги с учетом специальной обработки <p> и <a>"""
-2
View File
@@ -1,2 +0,0 @@
import secrets
print(secrets.token_hex(32))
+5
View File
@@ -0,0 +1,5 @@
"""Конфиг gunicorn для responder-бота.
Регистрация webhook выполняется в wsgi_bot.py (с file lock), чтобы сработала
даже если unit забыли обновить с --config.
"""
View File
+1 -1
View File
@@ -36,4 +36,4 @@ sniffio==1.3.1
typing_extensions==4.15.0
urllib3==2.5.0
Werkzeug==3.1.3
yarl==1.20.1
yarl==1.20.1
+24 -13
View File
@@ -1,18 +1,23 @@
#!/bin/bash
# Директория со скриптами
SCRIPTS_DIR="/opt/testbot"
SCRIPTS_DIR="/opt/testbot-git"
# Активация виртуального окружения
VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate"
# Список скриптов для запуска в порядке выполнения
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
# Legacy prefetch (не в пайплайне): tools/legacy/evt_prefetch.py
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tools/legacy/evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py")
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py")
# Интервалы между скриптами (в секундах)
DELAYS=(2 2 2 2 30)
# DELAYS=(2 2 2 2 30)
DELAYS=(2 2 2)
# Лог-файл
LOG_FILE="/opt/testbot/runner.log"
LOG_DIR="$SCRIPTS_DIR/logs"
LOG_FILE="$LOG_DIR/runner.log"
# Функция для логирования
log_message() {
@@ -22,11 +27,20 @@ log_message() {
# Проверка и настройка лог-файла
setup_logfile() {
# Проверяем, существует ли файл и можно ли в него писать
if [ ! -d "$LOG_DIR" ]; then
if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
if sudo mkdir -p "$LOG_DIR" 2>/dev/null; then
sudo chown "$(whoami)" "$LOG_DIR"
sudo chmod 755 "$LOG_DIR"
else
echo "ОШИБКА: Не удалось создать каталог $LOG_DIR!"
exit 1
fi
fi
fi
if [ ! -f "$LOG_FILE" ]; then
# Пытаемся создать файл
if ! touch "$LOG_FILE" 2>/dev/null; then
# Если не получается, пробуем с sudo
if sudo touch "$LOG_FILE" 2>/dev/null; then
sudo chown $(whoami) "$LOG_FILE"
sudo chmod 644 "$LOG_FILE"
@@ -37,10 +51,8 @@ setup_logfile() {
fi
fi
fi
# Проверяем права на запись
if [ ! -w "$LOG_FILE" ]; then
# Пытаемся изменить права
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
log_message "Изменены права на лог-файл: $LOG_FILE"
else
@@ -81,7 +93,7 @@ cd "$SCRIPTS_DIR" || {
# Запуск скриптов
for i in "${!SCRIPTS[@]}"; do
script="${SCRIPTS[i]}"
# Проверка существования скрипта
if [ ! -f "$script" ]; then
log_message "ПРЕДУПРЕЖДЕНИЕ: Скрипт $script не найден, пропускаем."
@@ -89,7 +101,7 @@ for i in "${!SCRIPTS[@]}"; do
fi
log_message "Запуск скрипта: $script"
# Запуск Python-скрипта с записью вывода в лог
if python3 "$script" >> "$LOG_FILE" 2>&1; then
log_message "УСПЕХ: Скрипт $script завершился успешно."
@@ -110,4 +122,3 @@ done
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
log_message "=== ЗАВЕРШЕНИЕ ==="
+127
View File
@@ -0,0 +1,127 @@
"""
Сезонные deep link и callback для подписок.
Формат текущего сезона (SEASON=zk2026):
start=zk2026_post_{tg_message_id}
start=zk2026_event_{tg_message_id}
callback: subscribe_zk2026_{id} / unsubscribe_zk2026_{id}
callback: subscribe_evt_zk2026_{id} / unsubscribe_evt_zk2026_{id}
Старые форматы (post_123, event_123, subscribe_123, …) и чужой сезон
считаются завершённым прошлым сезоном.
"""
import os
import re
from typing import Optional, Tuple
from dotenv import load_dotenv
_ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
load_dotenv(_ENV_PATH)
load_dotenv()
SEASON = (os.getenv("SEASON") or "zk2026").strip().strip('"').strip("'")
# start: zk2026_post_123 / zk2026_event_123
_START_RE = re.compile(
rf"^(?P<season>[A-Za-z0-9]+)_((?P<kind>post|event)_)(?P<id>\d+)$"
)
# legacy start: post_123 / event_123
_START_LEGACY_RE = re.compile(r"^(?P<kind>post|event)_(?P<id>\d+)$")
# callback posts: subscribe_zk2026_123
_CB_POST_RE = re.compile(
r"^(?P<action>subscribe|unsubscribe)_(?P<season>[A-Za-z0-9]+)_(?P<id>\d+)$"
)
# legacy: subscribe_123
_CB_POST_LEGACY_RE = re.compile(r"^(?P<action>subscribe|unsubscribe)_(?P<id>\d+)$")
# callback events: subscribe_evt_zk2026_123
_CB_EVT_RE = re.compile(
r"^(?P<action>subscribe|unsubscribe)_evt_(?P<season>[A-Za-z0-9]+)_(?P<id>\d+)$"
)
# legacy: subscribe_evt_123
_CB_EVT_LEGACY_RE = re.compile(r"^(?P<action>subscribe|unsubscribe)_evt_(?P<id>\d+)$")
def subscription_start_link(bot_name: str, kind: str, message_id) -> str:
"""HTTPS deep link на бота: kind = 'post' | 'event'."""
payload = f"{SEASON}_{kind}_{message_id}"
return f"https://t.me/{bot_name}?start={payload}"
def callback_subscribe_post(message_id) -> str:
return f"subscribe_{SEASON}_{message_id}"
def callback_unsubscribe_post(message_id) -> str:
return f"unsubscribe_{SEASON}_{message_id}"
def callback_subscribe_event(message_id) -> str:
return f"subscribe_evt_{SEASON}_{message_id}"
def callback_unsubscribe_event(message_id) -> str:
return f"unsubscribe_evt_{SEASON}_{message_id}"
def parse_start_payload(payload: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Разбор аргумента /start.
Returns:
(kind, id, is_current_season)
kind: 'post' | 'event' | None
is_current_season: False → показать «сезон завершён»
"""
if not payload:
return None, None, True
m = _START_RE.match(payload)
if m:
return m.group("kind"), m.group("id"), m.group("season") == SEASON
m = _START_LEGACY_RE.match(payload)
if m:
return m.group("kind"), m.group("id"), False
# Неизвестный payload — считаем устаревшим/чужим, без действия по id
return None, None, False
def parse_post_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Returns:
(action, id, is_current_season) для кнопок подписки на посты.
action: 'subscribe' | 'unsubscribe' | None
"""
if data.startswith("subscribe_evt_") or data.startswith("unsubscribe_evt_"):
return None, None, True
m = _CB_POST_RE.match(data)
if m:
return m.group("action"), m.group("id"), m.group("season") == SEASON
m = _CB_POST_LEGACY_RE.match(data)
if m:
return m.group("action"), m.group("id"), False
return None, None, True
def parse_event_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Returns:
(action, id, is_current_season) для кнопок подписки на события.
"""
m = _CB_EVT_RE.match(data)
if m:
return m.group("action"), m.group("id"), m.group("season") == SEASON
m = _CB_EVT_LEGACY_RE.match(data)
if m:
return m.group("action"), m.group("id"), False
return None, None, True
+3
View File
@@ -0,0 +1,3 @@
# Перенесено
Документация: [`docs/runner-setup.md`](../docs/runner-setup.md)
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Скрипт для установки systemd timer для runner.sh
# Путь к директории проекта
PROJECT_DIR="/opt/testbot-git"
SERVICES_DIR="$PROJECT_DIR/services"
echo "Установка systemd timer для runner.sh..."
# Копирование файлов в systemd
sudo cp "$SERVICES_DIR/runner.service" /etc/systemd/system/
sudo cp "$SERVICES_DIR/runner.timer" /etc/systemd/system/
# Перезагрузка systemd для чтения новых файлов
sudo systemctl daemon-reload
# Включение и запуск timer
sudo systemctl enable runner.timer
sudo systemctl start runner.timer
# Проверка статуса
echo ""
echo "Статус timer:"
sudo systemctl status runner.timer --no-pager
echo ""
echo "Список всех активных timers:"
sudo systemctl list-timers --no-pager
echo ""
echo "Для просмотра логов используйте:"
echo " sudo journalctl -u runner.service -f"
echo " sudo journalctl -u runner.timer -f"
+273
View File
@@ -0,0 +1,273 @@
#!/bin/bash
# Скрипт post-deploy.sh для автоматического развертывания проекта
# Выполняется после развертывания проекта
set -e # Остановка при любой ошибке
# Цвета для вывода
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Функция для записи в лог-файл
write_to_log() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [PostDeploy] $message" >> "$LOG_FILE"
}
# Функция для логирования
log() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "${GREEN}[$timestamp] $1${NC}"
write_to_log "$message"
}
error() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "${RED}[$timestamp] ERROR: $1${NC}"
write_to_log "ERROR: $message"
exit 1
}
warning() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo -e "${YELLOW}[$timestamp] WARNING: $1${NC}"
write_to_log "WARNING: $message"
}
# Определяем корневую директорию проекта
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENV_DIR="$PROJECT_ROOT/venv"
REQUIREMENTS_FILE="$PROJECT_ROOT/requirements.txt"
BACKUP_SCRIPT="$PROJECT_ROOT/backupdb.sh"
ENSURE_DB_SCRIPT="$PROJECT_ROOT/ensure_db.py"
SERVICES_DIR="$PROJECT_ROOT/services"
SERVICES_BACKUP_DIR="$SERVICES_DIR/backups"
SYSTEMD_DIR="/etc/systemd/system"
# Загружаем переменные окружения из .env файла
if [ -f "$PROJECT_ROOT/.env" ]; then
# Безопасная загрузка переменных из .env файла
while IFS= read -r line || [ -n "$line" ]; do
# Пропускаем пустые строки и комментарии
if [[ -n "$line" && ! "$line" =~ ^[[:space:]]*# ]]; then
# Проверяем, что строка содержит знак равенства
if [[ "$line" =~ ^[^=]+=.* ]]; then
# Убираем пробелы вокруг знака равенства и экспортируем переменную
cleaned_line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*=[[:space:]]*/=/')
export "$cleaned_line"
fi
fi
done < "$PROJECT_ROOT/.env"
fi
# Определяем лог-файл из переменной окружения или используем значение по умолчанию
LOG_FILE="${LOG_FILE:-$PROJECT_ROOT/post-deploy.log}"
# Выводим информацию о лог-файле (только в консоль, чтобы не создавать циклическое логирование)
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] Лог-файл: $LOG_FILE${NC}"
log "Начинаем post-deploy процесс для проекта: $PROJECT_ROOT"
# 0. Установка прав на исполнение для всех .sh файлов в основном каталоге проекта
log "Устанавливаем права на исполнение для всех .sh файлов в проекте..."
# Находим все .sh файлы в основном каталоге проекта (не в подкаталогах)
for sh_file in "$PROJECT_ROOT"/*.sh; do
if [ -f "$sh_file" ]; then
chmod +x "$sh_file"
log "Установлены права на исполнение для: $(basename "$sh_file")"
fi
done
# 1. Проверка и создание виртуального окружения Python
log "Проверяем наличие виртуального окружения Python..."
if [ ! -d "$VENV_DIR" ]; then
log "Виртуальное окружение не найдено. Создаем новое..."
# Проверяем наличие python3
if ! command -v python3 &> /dev/null; then
error "Python3 не найден в системе. Установите Python3 перед продолжением."
fi
# Создаем виртуальное окружение
python3 -m venv "$VENV_DIR"
if [ $? -eq 0 ]; then
log "Виртуальное окружение успешно создано"
else
error "Ошибка при создании виртуального окружения"
fi
else
log "Виртуальное окружение уже существует"
fi
# Активируем виртуальное окружение
log "Активируем виртуальное окружение..."
source "$VENV_DIR/bin/activate"
# Проверяем наличие requirements.txt
if [ ! -f "$REQUIREMENTS_FILE" ]; then
error "Файл requirements.txt не найден в $REQUIREMENTS_FILE"
fi
# Обновляем pip и устанавливаем зависимости
log "Обновляем pip и устанавливаем зависимости из requirements.txt..."
pip install --upgrade pip
pip install -r "$REQUIREMENTS_FILE"
if [ $? -eq 0 ]; then
log "Зависимости успешно установлены"
else
error "Ошибка при установке зависимостей"
fi
# 2. Запуск backupdb.sh для бэкапа базы данных
log "Запускаем бэкап базы данных..."
if [ ! -f "$BACKUP_SCRIPT" ]; then
error "Скрипт backupdb.sh не найден в $BACKUP_SCRIPT"
fi
# Делаем скрипт исполняемым
chmod +x "$BACKUP_SCRIPT"
# Запускаем бэкап
bash "$BACKUP_SCRIPT"
if [ $? -eq 0 ]; then
log "Бэкап базы данных выполнен успешно"
else
error "Ошибка при выполнении бэкапа базы данных"
fi
# 3. Проверка и создание структуры базы данных пропущена
# Будет выполнена в другом месте
log "Проверка структуры базы данных пропущена - будет выполнена отдельно"
# 3. Проверка и обновление systemd сервисов
log "Проверяем и обновляем systemd сервисы..."
# Создаем каталог для резервных копий сервисов
mkdir -p "$SERVICES_BACKUP_DIR"
# Список сервисов для проверки
SERVICES=("testbot-git.service" "testedit-git.service")
for service in "${SERVICES[@]}"; do
log "Обрабатываем сервис: $service"
SERVICE_FILE="$SERVICES_DIR/$service"
SYSTEMD_SERVICE="$SYSTEMD_DIR/$service"
# Проверяем наличие файла сервиса в проекте
if [ ! -f "$SERVICE_FILE" ]; then
warning "Файл сервиса $SERVICE_FILE не найден, пропускаем"
continue
fi
# Проверяем, нужно ли обновлять сервис
UPDATE_NEEDED=false
if [ ! -f "$SYSTEMD_SERVICE" ]; then
log "Сервис $service не найден в systemd, будет создан"
UPDATE_NEEDED=true
else
# Сравниваем даты модификации файлов
if [ "$SERVICE_FILE" -nt "$SYSTEMD_SERVICE" ]; then
log "Файл сервиса $service в проекте новее, обновляем"
UPDATE_NEEDED=true
else
log "Сервис $service актуален, пропускаем"
fi
fi
if [ "$UPDATE_NEEDED" = true ]; then
log "Копируем файл сервиса $service в systemd..."
# Создаем резервную копию существующего файла в каталог проекта
if [ -f "$SYSTEMD_SERVICE" ]; then
BACKUP_FILE="$SERVICES_BACKUP_DIR/${service}.backup.$(date +%Y%m%d_%H%M%S)"
sudo cp "$SYSTEMD_SERVICE" "$BACKUP_FILE"
log "Создана резервная копия: $BACKUP_FILE"
fi
# Копируем новый файл сервиса
sudo cp "$SERVICE_FILE" "$SYSTEMD_SERVICE"
if [ $? -eq 0 ]; then
log "Файл сервиса $service успешно скопирован"
else
error "Ошибка при копировании файла сервиса $service"
fi
# Устанавливаем правильные права доступа
sudo chmod 644 "$SYSTEMD_SERVICE"
sudo chown root:root "$SYSTEMD_SERVICE"
fi
done
# 4. Перезапуск сервисов
log "Перезапускаем systemd сервисы..."
for service in "${SERVICES[@]}"; do
SYSTEMD_SERVICE="$SYSTEMD_DIR/$service"
if [ -f "$SYSTEMD_SERVICE" ]; then
log "Останавливаем сервис $service..."
sudo systemctl stop "$service" || warning "Не удалось остановить сервис $service (возможно, не запущен)"
log "Перезагружаем конфигурацию systemd..."
sudo systemctl daemon-reload
log "Запускаем сервис $service..."
sudo systemctl start "$service"
if [ $? -eq 0 ]; then
log "Сервис $service успешно запущен"
# Проверяем статус сервиса
sleep 2
if sudo systemctl is-active --quiet "$service"; then
log "Сервис $service активен и работает"
else
warning "Сервис $service запущен, но может работать некорректно"
sudo systemctl status "$service" --no-pager -l
fi
else
error "Ошибка при запуске сервиса $service"
fi
# Включаем автозапуск сервиса
sudo systemctl enable "$service"
log "Автозапуск для сервиса $service включен"
else
warning "Файл сервиса $SYSTEMD_SERVICE не найден, пропускаем перезапуск"
fi
done
log "Post-deploy процесс завершен успешно!"
log "Все сервисы обновлены и перезапущены"
# Показываем статус всех сервисов
log "Текущий статус сервисов:"
for service in "${SERVICES[@]}"; do
if sudo systemctl list-unit-files | grep -q "$service"; then
echo -n " $service: "
if sudo systemctl is-active --quiet "$service"; then
echo -e "${GREEN}АКТИВЕН${NC}"
else
echo -e "${RED}НЕАКТИВЕН${NC}"
fi
else
echo -e " $service: ${YELLOW}НЕ НАЙДЕН${NC}"
fi
done
+16
View File
@@ -0,0 +1,16 @@
# /etc/systemd/system/runner.service
[Unit]
Description=Zilant Bot Runner - Executes scheduled scripts
After=network.target
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/testbot-git
ExecStart=/bin/bash /opt/testbot-git/runner.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+18
View File
@@ -0,0 +1,18 @@
# /etc/systemd/system/runner.timer
[Unit]
Description=Zilant Bot Runner Timer - Runs every hour
Requires=runner.service
[Timer]
# Запуск каждый час
OnCalendar=*-*-* *:10:00
# Запуск сразу после загрузки системы (опционально)
OnBootSec=5min
# Если пропущен запуск (например, сервер был выключен), запустить сразу после включения
Persistent=true
# Точность запуска (по умолчанию 1 минута, можно уменьшить)
AccuracySec=1min
[Install]
WantedBy=timers.target
+13
View File
@@ -0,0 +1,13 @@
# /etc/systemd/system/testbot-git.service
[Unit]
Description=AUTOTEST Zilant Bot service group - Channel Bot
After=network.target
[Service]
User=root
WorkingDirectory=/opt/testbot-git
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5005 --workers 4 --config gunicorn_bot.conf.py wsgi_bot:app
Restart=always
[Install]
WantedBy=multi-user.target
+13
View File
@@ -0,0 +1,13 @@
# /etc/systemd/system/testedit-git.service
[Unit]
Description=AUTOTEST Zilant Bot service group - Reposter Control
After=network.target
[Service]
User=root
WorkingDirectory=/opt/testbot-git
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5006 --workers 4 --timeout=240 wsgi_edit:app
Restart=always
[Install]
WantedBy=multi-user.target
+98
View File
@@ -0,0 +1,98 @@
"""
Конфигурация доступа к Telegram Bot API через internal_relay (retransbot).
Все исходящие вызовы Bot API должны идти на RELAY_URL, а не на api.telegram.org.
"""
import os
from typing import Tuple
from dotenv import load_dotenv
def get_relay_url() -> str:
"""Базовый URL internal_relay без завершающего слэша."""
load_dotenv()
relay = (os.getenv("RELAY_URL") or "").strip().rstrip("/")
if not relay:
raise RuntimeError(
"Требуется переменная окружения RELAY_URL "
"(базовый URL internal_relay, например http://127.0.0.1:8081)"
)
# Допускаем host:port без схемы — httpx требует http:// или https://
if "://" not in relay:
relay = f"http://{relay}"
return relay
def configure_telebot_api() -> str:
"""
Настраивает pyTelegramBotAPI (telebot.apihelper) на работу через relay.
Вызывать до создания TeleBot.
"""
from telebot import apihelper
relay = get_relay_url()
apihelper.API_URL = f"{relay}/bot{{0}}/{{1}}"
apihelper.FILE_URL = f"{relay}/file/bot{{0}}/{{1}}"
return relay
def get_ptb_base_urls() -> Tuple[str, str]:
"""
base_url и base_file_url для python-telegram-bot.Bot.
Формат как в Bot API: .../bot и .../file/bot (библиотека сама дописывает token/method).
"""
relay = get_relay_url()
return f"{relay}/bot", f"{relay}/file/bot"
def bot_api_method_url(token: str, method: str) -> str:
"""Полный URL метода Bot API через relay (для сырых httpx/requests вызовов)."""
return f"{get_relay_url()}/bot{token}/{method}"
# Публикация в два шага (send → edit ссылок): пауза и повторы при гонке с Telegram
EDIT_LINKS_INITIAL_DELAY_SEC = 3
EDIT_LINKS_MAX_ATTEMPTS = 5
EDIT_LINKS_RETRY_INTERVAL_SEC = 2
def extract_text_link_urls(entities) -> list:
"""URL из entities типа text_link (ответ Telegram Bot API или python-telegram-bot)."""
urls = []
for ent in entities or []:
if isinstance(ent, dict):
if ent.get("type") == "text_link" and ent.get("url"):
urls.append(ent["url"])
elif getattr(ent, "type", None) == "text_link":
url = getattr(ent, "url", None)
if url:
urls.append(url)
return urls
def published_message_has_expected_links(entities, expected_urls) -> bool:
"""
Проверка, что в сообщении есть именно footer-ссылки публикации.
Сравнение только по URL в text_link entities (plain text короче HTML).
"""
expected_urls = [u for u in (expected_urls or []) if u]
if not expected_urls:
return True
found = set(extract_text_link_urls(entities))
return all(url in found for url in expected_urls)
def published_message_has_links(text: str, entities=None, expected_urls=None) -> bool:
"""Проверка footer-ссылок; expected_urls обязателен при вызове из publisher."""
if expected_urls is not None:
return published_message_has_expected_links(entities, expected_urls)
return bool(extract_text_link_urls(entities))
def published_text_has_links(text: str) -> bool:
"""Устаревший вызов без expected_urls — только для совместимости."""
return published_message_has_links(text)
+314
View File
@@ -0,0 +1,314 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if category_id %}Редактирование площадки{% else %}Добавление площадки{% endif %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.form-container {
max-width: 800px;
margin: 0 auto;
}
.preview-image {
max-width: 200px;
max-height: 200px;
object-fit: contain;
}
.required:after {
content: " *";
color: red;
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
.form-label {
font-weight: bold;
display: flex;
align-items: center;
margin-bottom: 0;
height: 38px;
}
.form-control, .form-select {
height: 38px;
}
textarea.form-control {
height: auto;
}
.label-narrow {
flex: 0 0 140px;
}
.field-wide {
flex: 1;
}
</style>
</head>
<body>
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>{% if category_id %}Редактирование площадки{% else %}Добавление площадки{% endif %}</h2>
<a href="#" id="back-button" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Назад
</a>
</div>
<div class="card form-container">
<div class="card-body">
<form id="category-form">
<input type="hidden" id="category-id" value="{{ category_id if category_id else '' }}">
<!-- ID -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="id" class="form-label required">ID:</label>
</div>
<div class="field-wide">
<input type="text" class="form-control" id="id" maxlength="255" required>
</div>
</div>
<!-- Название -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="title" class="form-label">Название:</label>
</div>
<div class="field-wide">
<input type="text" class="form-control" id="title" maxlength="2048">
</div>
</div>
<!-- Описание -->
<div class="mb-3">
<label for="description" class="form-label">Описание:</label>
<textarea class="form-control" id="description" rows="6"></textarea>
</div>
<!-- URL изображения и предпросмотр -->
<div class="row mb-3">
<div class="col-md-8">
<div class="d-flex align-items-center">
<div class="label-narrow">
<label for="image_url" class="form-label">Картинка:</label>
</div>
<div class="field-wide">
<input type="url" class="form-control" id="image_url" maxlength="2048">
</div>
</div>
</div>
<div class="col-md-4 text-end">
<div id="image-preview-container" style="display: none;">
<img id="image-preview" class="preview-image img-thumbnail" style="max-width: 200px; max-height: 200px; object-fit: contain;">
</div>
</div>
</div>
<!-- TG ID -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="tg_id" class="form-label">TG ID:</label>
</div>
<div style="flex: 0 0 200px;">
<input type="text" class="form-control" id="tg_id" pattern="[0-9]*" inputmode="numeric">
</div>
</div>
<!-- Переключатель публикации -->
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="marked_for_publication">
<label class="form-check-label" for="marked_for_publication">Отметить для публикации</label>
</div>
<!-- Опубликовано в TG -->
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="is_tg_published">
<label class="form-check-label" for="is_tg_published">Опубликовано в TG</label>
</div>
<!-- Дата публикации в TG -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="tg_published_date" class="form-label">Дата публикации в TG:</label>
</div>
<div style="flex: 0 0 300px;">
<input type="datetime-local" class="form-control" id="tg_published_date">
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<button type="button" class="btn btn-secondary me-2" id="cancel-btn">Отмена</button>
<button type="submit" class="btn btn-primary">
{% if category_id %}Обновить{% else %}Создать{% endif %}
</button>
</div>
</form>
</div>
</div>
<!-- Контейнер для уведомлений -->
<div class="toast-container">
<div id="success-toast" class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="fas fa-check-circle me-2"></i> Площадка успешно сохранена!
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const PREFIX = "{{ g.prefix }}";
console.log("Category Editor Prefix is:", PREFIX);
document.addEventListener('DOMContentLoaded', () => {
const categoryId = document.getElementById('category-id').value;
const form = document.getElementById('category-form');
const imageUrlInput = document.getElementById('image_url');
const imagePreviewContainer = document.getElementById('image-preview-container');
const imagePreview = document.getElementById('image-preview');
const successToast = new bootstrap.Toast(document.getElementById('success-toast'));
// Обработчики кнопок отмены и назад
const cancelHandler = () => {
if (categoryId) {
window.location.href = `${PREFIX}/?selected_category=${categoryId}&tab=categories`;
} else {
window.location.href = `${PREFIX}/?tab=categories`;
}
};
document.getElementById('cancel-btn').addEventListener('click', cancelHandler);
document.getElementById('back-button').addEventListener('click', cancelHandler);
// Предпросмотр изображения
imageUrlInput.addEventListener('change', () => {
const url = imageUrlInput.value.trim();
if (url) {
imagePreview.src = `${PREFIX}/image_proxy?url=${encodeURIComponent(url)}`;
imagePreviewContainer.style.display = 'block';
} else {
imagePreviewContainer.style.display = 'none';
}
});
// Загрузка данных категории при редактировании
if (categoryId) {
loadCategoryData(categoryId);
}
// Обработка отправки формы
form.addEventListener('submit', async (e) => {
e.preventDefault();
await saveCategory(categoryId);
});
});
// Загрузка данных категории
async function loadCategoryData(categoryId) {
try {
const response = await fetch(`${PREFIX}/api/categories/${encodeURIComponent(categoryId)}`);
if (!response.ok) {
throw new Error('Площадка не найдена');
}
const category = await response.json();
// Заполняем форму
document.getElementById('id').value = category.id || '';
document.getElementById('title').value = category.title || '';
document.getElementById('description').value = category.description || '';
document.getElementById('image_url').value = category.image_url || '';
document.getElementById('tg_id').value = category.tg_id || '';
document.getElementById('marked_for_publication').checked = category.marked_for_publication || false;
document.getElementById('is_tg_published').checked = category.is_tg_published || false;
// Устанавливаем дату публикации, если она есть
if (category.tg_published_date) {
// Преобразуем ISO дату в формат datetime-local (YYYY-MM-DDTHH:mm)
const date = new Date(category.tg_published_date);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
document.getElementById('tg_published_date').value = localDate.toISOString().slice(0, 16);
} else {
document.getElementById('tg_published_date').value = '';
}
// Показываем предпросмотр изображения, если есть
if (category.image_url) {
const imagePreview = document.getElementById('image-preview');
const imagePreviewContainer = document.getElementById('image-preview-container');
imagePreview.src = `${PREFIX}/image_proxy?url=${encodeURIComponent(category.image_url)}`;
imagePreviewContainer.style.display = 'block';
}
} catch (error) {
alert('Ошибка загрузки данных площадки: ' + error.message);
}
}
// Сохранение категории
async function saveCategory(categoryId) {
const form = document.getElementById('category-form');
const tgPublishedDateValue = document.getElementById('tg_published_date').value;
let tgPublishedDate = null;
if (tgPublishedDateValue) {
// Преобразуем локальное время в UTC для сохранения в БД
const localDate = new Date(tgPublishedDateValue);
tgPublishedDate = localDate.toISOString().slice(0, 19).replace('T', ' ');
}
const formData = {
id: document.getElementById('id').value.trim(),
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
image_url: document.getElementById('image_url').value.trim(),
tg_id: document.getElementById('tg_id').value.trim() || null,
marked_for_publication: document.getElementById('marked_for_publication').checked,
is_tg_published: document.getElementById('is_tg_published').checked,
tg_published_date: tgPublishedDate
};
if (!formData.id) {
alert('ID площадки обязателен для заполнения');
return;
}
try {
const url = categoryId
? `${PREFIX}/api/categories/${encodeURIComponent(categoryId)}`
: `${PREFIX}/api/categories`;
const method = categoryId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok && result.success) {
const successToast = new bootstrap.Toast(document.getElementById('success-toast'));
successToast.show();
setTimeout(() => {
window.location.href = `${PREFIX}/?selected_category=${encodeURIComponent(formData.id)}&tab=categories`;
}, 1000);
} else {
throw new Error(result.error || 'Ошибка сохранения площадки');
}
} catch (error) {
alert('Ошибка сохранения площадки: ' + error.message);
}
}
</script>
</body>
</html>
+271 -83
View File
@@ -12,7 +12,8 @@
margin: 0 auto;
}
.preview-image {
max-height: 400px;
max-width: 200px;
max-height: 200px;
object-fit: contain;
}
.required:after {
@@ -25,6 +26,53 @@
right: 20px;
z-index: 1050;
}
/* Скрытие крутилок у числовых полей */
input[type="text"][pattern="[0-9]*"]::-webkit-outer-spin-button,
input[type="text"][pattern="[0-9]*"]::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
input[type="text"][pattern="[0-9]*"] {
-moz-appearance: textfield;
appearance: textfield;
}
/* Принудительное отображение 24-часового формата */
input[type="datetime-local"] {
font-family: monospace;
}
/* Жирные заголовки и выравнивание */
.form-label {
font-weight: bold;
display: flex;
align-items: center;
margin-bottom: 0;
height: 38px; /* Высота стандартного поля ввода Bootstrap */
}
/* Выравнивание полей с заголовками */
.form-control, .form-select {
height: 38px;
}
textarea.form-control {
height: auto;
}
/* Убираем отступы слева у полей для выравнивания */
.form-control, .form-select {
margin-left: 0 !important;
}
/* Тонкое регулирование ширины для заголовков */
.label-narrow {
flex: 0 0 100px; /* Фиксированная ширина 100px */
}
.label-medium {
flex: 0 0 140px; /* Фиксированная ширина 140px */
}
.label-wide {
flex: 0 0 180px; /* Фиксированная ширина 180px */
}
.field-wide {
flex: 1; /* Занимает оставшееся место */
}
</style>
</head>
<body>
@@ -41,61 +89,144 @@
<form id="post-form">
<input type="hidden" id="post-id" value="{{ post_id if post_id else '' }}">
<!-- Заголовок и поле Название в одну строку -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="shortname" class="form-label">Название:</label>
</div>
<div class="field-wide">
<input type="text" class="form-control" id="shortname" maxlength="100">
</div>
</div>
<!-- Секция с площадкой и VK ID слева, изображением справа -->
<div class="row mb-3">
<div class="col-md-8">
<!-- Площадка -->
<div class="d-flex align-items-center mb-2">
<div class="label-narrow">
<label for="auto_unit" class="form-label">Площадка:</label>
</div>
<div class="field-wide">
<select class="form-control" id="auto_unit">
<option value="">Выберите площадку</option>
</select>
</div>
</div>
<!-- VK ID -->
<div class="d-flex align-items-center mb-2">
<div class="label-narrow">
<label for="vk_post_id" class="form-label">VK ID:</label>
</div>
<div style="flex: 0 0 100px;">
<input type="text" class="form-control" id="vk_post_id" maxlength="8" required>
</div>
</div>
<!-- Переключатели -->
<div class="row mb-2">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="is_event">
<label class="form-check-label" for="is_event">Событие</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="is_poll">
<label class="form-check-label" for="is_poll">Опрос</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="marked_for_publication">
<label class="form-check-label" for="marked_for_publication">К публикации</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="published_in_tg">
<label class="form-check-label" for="published_in_tg">Опубликовано в TG</label>
</div>
</div>
</div>
</div>
<div class="col-md-4 text-end">
<div id="image-preview-container" style="display: none;">
<img id="image-preview" class="preview-image img-thumbnail" style="max-width: 200px; max-height: 200px; object-fit: contain;">
</div>
</div>
</div>
<!-- Текстовое поле поста -->
<div class="mb-3">
<label for="vk_post_id" class="form-label required">VK ID поста</label>
<input type="text" class="form-control" id="vk_post_id" required>
<div class="form-text">Числовой идентификатор поста во ВКонтакте</div>
<textarea class="form-control" id="text" rows="4" required placeholder="Введите текст поста"></textarea>
</div>
<div class="mb-3">
<label for="shortname" class="form-label">Название</label>
<input type="text" class="form-control" id="shortname" maxlength="100">
<div class="form-text">Короткое имя поста (максимум 100 символов)</div>
<!-- URL изображения -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="image_url" class="form-label">Картинка:</label>
</div>
<div class="field-wide">
<input type="url" class="form-control" id="image_url">
</div>
</div>
<div class="mb-3">
<label for="action_number" class="form-label">Номер события в системе расписания</label>
<input type="number" class="form-control" id="action_number">
<!-- URL поста ВК с датой -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="vk_post_url" class="form-label">Пост в ВК:</label>
</div>
<div class="field-wide" style="margin-right: 15px;">
<input type="url" class="form-control" id="vk_post_url">
</div>
<div class="label-narrow">
<label for="published_at" class="form-label">датирован:</label>
</div>
<div style="flex: 0 0 200px;">
<input type="datetime-local" class="form-control" id="published_at" step="60">
</div>
</div>
<div class="mb-3">
<label for="text" class="form-label required">Текст поста</label>
<textarea class="form-control" id="text" rows="4" required></textarea>
<!-- Дата публикации в TG -->
<div class="d-flex align-items-center mb-3">
<div class="label-wide">
<label for="tg_publication_date" class="form-label">Опубликовано в TG:</label>
</div>
<div style="flex: 0 0 200px;">
<input type="datetime-local" class="form-control" id="tg_publication_date" step="60">
</div>
</div>
<div class="mb-3">
<label for="image_url" class="form-label">URL изображения</label>
<input type="url" class="form-control" id="image_url">
<div class="form-text">Ссылка на изображение для поста</div>
</div>
<div class="mb-3 text-center" id="image-preview-container" style="display: none;">
<img id="image-preview" class="preview-image img-thumbnail">
</div>
<div class="mb-3">
<label for="vk_post_url" class="form-label">Ссылка на пост VK</label>
<input type="url" class="form-control" id="vk_post_url">
</div>
<div class="mb-3">
<label for="published_at" class="form-label">Дата публикации</label>
<input type="datetime-local" class="form-control" id="published_at">
<div class="form-text">Формат: ГГГГ-ММ-ДД ЧЧ:ММ</div>
</div>
<!-- Новое поле: is_event -->
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="is_event">
<label class="form-check-label" for="is_event">Это событие</label>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="is_poll">
<label class="form-check-label" for="is_poll">Это опрос</label>
<!-- ID TG в одну строку -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="tg_message_id" class="form-label">ID TG:</label>
</div>
<div style="flex: 0 0 100px; margin-right: 15px;">
<input type="text" class="form-control" id="tg_message_id" pattern="[0-9]*" inputmode="numeric">
</div>
<div style="flex: 0 0 120px;">
<label for="tg_poll_id" class="form-label">ID опроса TG:</label>
</div>
<div style="flex: 0 0 100px; margin-right: 15px;">
<input type="text" class="form-control" id="tg_poll_id" pattern="[0-9]*" inputmode="numeric">
</div>
<div style="flex: 0 0 120px;">
<label for="tg_markpost_id" class="form-label">ID отметок TG:</label>
</div>
<div style="flex: 0 0 100px;">
<input type="text" class="form-control" id="tg_markpost_id" pattern="[0-9]*" inputmode="numeric">
</div>
</div>
<!-- Блок параметров опроса -->
<div id="poll-fields" style="display: none;">
<div class="border-top mt-4 pt-3">
<h5>Параметры опроса</h5>
<div class="mb-3">
<label for="poll_question" class="form-label">Вопрос опроса</label>
<input type="text" class="form-control" id="poll_question">
@@ -114,44 +245,13 @@
<div class="mb-3">
<label for="poll_end_date" class="form-label">Дата окончания опроса</label>
<input type="datetime-local" class="form-control" id="poll_end_date">
</div>
</div>
<div class="border-top mt-4 pt-3">
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="marked_for_publication">
<label class="form-check-label" for="marked_for_publication">Отметить для публикации</label>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="published_in_tg">
<label class="form-check-label" for="published_in_tg">Опубликовано в Telegram</label>
</div>
<div class="mb-3">
<label for="tg_publication_date" class="form-label">Дата публикации в Telegram</label>
<input type="datetime-local" class="form-control" id="tg_publication_date">
</div>
<div class="mb-3">
<label for="tg_message_id" class="form-label">ID сообщения в Telegram</label>
<input type="number" class="form-control" id="tg_message_id">
</div>
<div class="mb-3">
<label for="tg_poll_id" class="form-label">ID опроса в Telegram</label>
<input type="number" class="form-control" id="tg_poll_id">
</div>
<div class="mb-3">
<label for="tg_markpost_id" class="form-label">ID поста для отметок в Telegram</label>
<input type="number" class="form-control" id="tg_markpost_id">
<input type="datetime-local" class="form-control" id="poll_end_date" step="60">
</div>
<div class="mb-3">
<label for="tg_poll_results" class="form-label">Результаты опроса</label>
<textarea class="form-control" id="tg_poll_results" rows="4"></textarea>
</div>
</div>
</div>
@@ -221,11 +321,55 @@
}
});
// Валидация поля названия
const shortnameInput = document.getElementById('shortname');
shortnameInput.addEventListener('input', () => {
const value = shortnameInput.value;
if (value.length > 100) {
alert('Название не может превышать 100 символов');
shortnameInput.value = value.substring(0, 100);
}
});
// Валидация поля VK ID
const vkPostIdInput = document.getElementById('vk_post_id');
vkPostIdInput.addEventListener('input', () => {
const value = vkPostIdInput.value;
if (value.length > 8) {
alert('VK ID не может превышать 8 символов');
vkPostIdInput.value = value.substring(0, 8);
}
});
// Загрузка списка площадок
loadVenues();
// Загрузка данных поста (если редактирование)
if (postId) {
loadPostData(postId);
}
// Принудительная установка 24-часового формата для полей даты-времени
const datetimeInputs = document.querySelectorAll('input[type="datetime-local"]');
datetimeInputs.forEach(input => {
// Устанавливаем атрибут step для округления до минут
input.step = '60';
// Добавляем обработчик для принудительного отображения 24-часового формата
input.addEventListener('change', function() {
if (this.value) {
const date = new Date(this.value);
// Форматируем в 24-часовом формате
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
this.value = `${year}-${month}-${day}T${hours}:${minutes}`;
}
});
});
// Обработка отправки формы
form.addEventListener('submit', (e) => {
e.preventDefault();
@@ -233,6 +377,31 @@
});
});
// Загрузка списка площадок
async function loadVenues() {
try {
const response = await fetch(`${PREFIX}/api/venues`);
if (!response.ok) {
throw new Error('Ошибка загрузки списка площадок');
}
const venues = await response.json();
const select = document.getElementById('auto_unit');
// Очищаем список, оставляя только первую опцию
select.innerHTML = '<option value="">Выберите площадку</option>';
// Добавляем площадки
venues.forEach(venue => {
const option = document.createElement('option');
option.value = venue.name;
option.textContent = venue.name;
select.appendChild(option);
});
} catch (error) {
console.error('Ошибка загрузки площадок:', error);
}
}
// Загрузка данных поста
async function loadPostData(postId) {
try {
@@ -246,16 +415,25 @@
// Заполняем форму
document.getElementById('vk_post_id').value = post.vk_post_id || '';
document.getElementById('shortname').value = post.shortname || '';
document.getElementById('action_number').value = post.action_number || '';
document.getElementById('auto_unit').value = post.auto_unit || '';
document.getElementById('text').value = post.text || '';
document.getElementById('image_url').value = post.image_url || '';
document.getElementById('vk_post_url').value = post.vk_post_url || '';
// Форматирование дат для input[type=datetime-local]
// Конвертация UTC в локальное время с округлением до минут в 24-часовом формате
const formatForInput = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toISOString().slice(0, 16);
// Округляем до минут (убираем секунды и миллисекунды)
date.setSeconds(0, 0);
// Возвращаем в локальном времени в формате YYYY-MM-DDTHH:MM (24-часовой формат)
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
document.getElementById('published_at').value = formatForInput(post.published_at);
@@ -292,6 +470,16 @@
}
}
// Функция конвертации локального времени в UTC для отправки на сервер
const convertLocalToUTC = (localString) => {
if (!localString) return null;
const date = new Date(localString);
// Округляем до минут
date.setSeconds(0, 0);
// Возвращаем в UTC формате для сервера (24-часовой формат)
return date.toISOString().slice(0, 19).replace('T', ' ');
};
// Сохранение поста
async function savePost() {
const postId = document.getElementById('post-id').value;
@@ -312,20 +500,20 @@
const formData = {
vk_post_id: document.getElementById('vk_post_id').value,
shortname: document.getElementById('shortname').value,
action_number: document.getElementById('action_number').value,
auto_unit: document.getElementById('auto_unit').value,
text: document.getElementById('text').value,
image_url: document.getElementById('image_url').value,
vk_post_url: document.getElementById('vk_post_url').value,
published_at: document.getElementById('published_at').value,
published_at: convertLocalToUTC(document.getElementById('published_at').value),
is_event: document.getElementById('is_event').checked, // Новое поле
is_poll: document.getElementById('is_poll').checked,
poll_question: document.getElementById('poll_question').value,
poll_options: document.getElementById('poll_options').value,
poll_multiple: document.getElementById('poll_multiple').checked,
poll_end_date: document.getElementById('poll_end_date').value,
poll_end_date: convertLocalToUTC(document.getElementById('poll_end_date').value),
marked_for_publication: document.getElementById('marked_for_publication').checked,
published_in_tg: document.getElementById('published_in_tg').checked,
tg_publication_date: document.getElementById('tg_publication_date').value,
tg_publication_date: convertLocalToUTC(document.getElementById('tg_publication_date').value),
tg_message_id: document.getElementById('tg_message_id').value,
tg_poll_id: document.getElementById('tg_poll_id').value,
tg_markpost_id: document.getElementById('tg_markpost_id').value,
+2175 -224
View File
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
import os
import logging
import asyncio
import traceback
from dotenv import load_dotenv
from telegram import Bot
from telegram.error import TelegramError, TimedOut
from telegram.request import HTTPXRequest
from telegram_relay import get_ptb_base_urls, get_relay_url
# Загрузка переменных окружения
load_dotenv()
# Получение переменных окружения
POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
LOG_FILE = os.getenv('LOG_FILE', 'test_tg_poster.log')
CHANNEL_ID = os.getenv('CHANNEL_ID')
# Настройка логирования
logger = logging.getLogger('test_tg_poster')
logger.setLevel(logging.INFO)
# Форматтер для логов
formatter = logging.Formatter('[%(asctime)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
# Обработчик для консоли
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Обработчик для файла с повторными попытками при ошибках записи
class RetryFileHandler(logging.FileHandler):
def emit(self, record):
import time
for _ in range(5):
try:
super().emit(record)
return
except (IOError, PermissionError):
time.sleep(0.5)
print(f"Failed to write to log file after 5 attempts: {record.msg}")
file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
async def make_test_post_async():
"""
Асинхронная функция для публикации тестового сообщения в Telegram канал.
Публикует сообщение, затем редактирует его, добавляя Message ID.
"""
bot = None
try:
# Проверка наличия необходимых переменных окружения
if not POSTER_BOT_TOKEN:
logger.error("Переменная окружения POSTER_BOT_TOKEN не установлена")
return False
if not CHANNEL_ID:
logger.error("Переменная окружения CHANNEL_ID не установлена")
return False
logger.info("Начало публикации тестового сообщения")
logger.info(f"Используется канал: {CHANNEL_ID}")
# Настройка таймаутов для HTTPXRequest
# Увеличиваем таймауты для избежания ошибок подключения
# Используем параметры, аналогичные тем, что используются в tg_publish.py
request = HTTPXRequest(
read_timeout=60.0, # Таймаут на чтение (увеличен)
write_timeout=60.0, # Таймаут на запись
connect_timeout=30.0, # Таймаут на подключение (увеличен)
pool_timeout=10.0, # Таймаут на получение соединения из пула
media_write_timeout=120.0 # Таймаут для медиа (на будущее)
)
# Создание экземпляра бота через internal_relay
base_url, base_file_url = get_ptb_base_urls()
bot = Bot(
token=POSTER_BOT_TOKEN,
request=request,
base_url=base_url,
base_file_url=base_file_url,
)
logger.info(f"Telegram Bot API через ретранслятор: {get_relay_url()}")
# Определяем путь к изображению в том же каталоге, что и скрипт
script_dir = os.path.dirname(os.path.abspath(__file__))
image_path = os.path.join(script_dir, 'testimage.jpg')
# Проверяем наличие файла изображения
if not os.path.exists(image_path):
logger.error(f"Файл изображения не найден: {image_path}")
return False
# Публикация первоначального сообщения с изображением
initial_text = "Это отладочное сообщение"
logger.info(f"Публикация сообщения с изображением: {initial_text}")
logger.info(f"Путь к изображению: {image_path}")
# Повторные попытки публикации при таймауте (до 10 раз)
message = None
message_id = None
max_retries = 10
retry_interval = 5
for attempt in range(1, max_retries + 1):
try:
with open(image_path, 'rb') as photo_file:
message = await bot.send_photo(
chat_id=CHANNEL_ID,
photo=photo_file,
caption=initial_text
)
message_id = message.message_id
logger.info(f"Сообщение успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при публикации сообщения (попытка {attempt}/{max_retries}). Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при публикации сообщения после {max_retries} попыток")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
return False
except TelegramError as e:
logger.error(f"Ошибка при публикации сообщения в Telegram: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации сообщения: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
return False
if message_id is None:
logger.error("Не удалось опубликовать сообщение после всех попыток")
return False
# Небольшая задержка перед редактированием
await asyncio.sleep(1)
# Редактирование сообщения: добавляем две пустые строки и Message ID
edited_text = f"{initial_text}\n\n\nИдентификатор сообщения - {message_id}"
logger.info(f"Редактирование сообщения, добавление Message ID: {message_id}")
# Повторные попытки редактирования при таймауте (до 10 раз)
edit_success = False
for attempt in range(1, max_retries + 1):
try:
await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=edited_text
)
logger.info(f"Сообщение успешно отредактировано")
edit_success = True
break # Успешное редактирование, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при редактировании сообщения (попытка {attempt}/{max_retries}). Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при редактировании сообщения после {max_retries} попыток")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
# Сообщение уже опубликовано, поэтому считаем это частичным успехом
return True
except TelegramError as e:
logger.error(f"Ошибка при редактировании сообщения в Telegram: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
# Сообщение уже опубликовано, поэтому считаем это частичным успехом
return True
except Exception as e:
logger.error(f"Неожиданная ошибка при редактировании сообщения: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
# Сообщение уже опубликовано, поэтому считаем это частичным успехом
return True
logger.info("Тестовое сообщение успешно опубликовано и отредактировано")
return True
except Exception as e:
logger.error(f"Критическая ошибка в make_test_post_async: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
return False
def make_test_post():
"""
Синхронная функция-обертка для публикации тестового сообщения.
Предназначена для вызова из Flask-приложения, работающего под gunicorn.
Returns:
bool: True если публикация успешна, False в противном случае
"""
try:
# Проверяем, есть ли уже запущенный event loop
try:
loop = asyncio.get_running_loop()
# Если event loop уже запущен (например, в async контексте),
# это означает, что мы в async функции, и нужно использовать другой подход
# В контексте gunicorn это маловероятно, но на всякий случай
logger.warning("Обнаружен запущенный event loop. Функция должна вызываться из синхронного контекста.")
# Пытаемся запустить в новом потоке
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, make_test_post_async())
return future.result()
except RuntimeError:
# Event loop не запущен, создаем новый
# Это нормальная ситуация для синхронного контекста (Flask/gunicorn)
logger.info("Event loop не запущен, создаем новый через asyncio.run()")
return asyncio.run(make_test_post_async())
except Exception as e:
logger.error(f"Ошибка в make_test_post при запуске асинхронной функции: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
return False
if __name__ == "__main__":
"""
Выполнение функции при запуске модуля из командной строки
"""
logger.info("Запуск модуля test_tg_poster.py из командной строки")
result = make_test_post()
if result:
logger.info("Тестовое сообщение успешно опубликовано")
exit(0)
else:
logger.error("Не удалось опубликовать тестовое сообщение")
exit(1)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

+668 -152
View File
File diff suppressed because it is too large Load Diff
+533 -146
View File
@@ -6,11 +6,37 @@ import time
import html
import json
import re
import httpx
import traceback
import io
import sys
from datetime import datetime, timezone
from telegram import Bot, PollOption
from telegram.error import TelegramError, RetryAfter
from dotenv import load_dotenv
from formatter import get_event_text
from telegram import Bot
from telegram.error import TelegramError, TimedOut
from telegram.request import HTTPXRequest
from telegram_relay import (
get_ptb_base_urls,
get_relay_url,
EDIT_LINKS_INITIAL_DELAY_SEC,
EDIT_LINKS_MAX_ATTEMPTS,
EDIT_LINKS_RETRY_INTERVAL_SEC,
published_message_has_expected_links,
extract_text_link_urls,
)
from season_links import subscription_start_link
# Настройка anyio для правильной работы в отдельном потоке
# Устанавливаем правильный backend для anyio (используем asyncio)
try:
import anyio
# Устанавливаем backend для anyio явно
if not hasattr(anyio, '_backend'):
# Используем asyncio backend
os.environ.setdefault('ANYIO_BACKEND', 'asyncio')
except ImportError:
pass
# Загрузка переменных окружения
load_dotenv()
@@ -30,6 +56,15 @@ ORG_MESSAGE_TAG = os.getenv('ORG_MESSAGE_TAG', '') # Хештег для опр
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
# Режим публикации без звука
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
# Использование бота подписки
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
# Режим отладочного логирования данных
LOG_DEBUG_DATA = os.getenv('LOG_DEBUG_DATA', 'false').lower() in ('true', '1', 'yes', 'on')
# Настройка логирования
logger = logging.getLogger('TG_Poster')
logger.setLevel(logging.INFO)
@@ -54,6 +89,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Класс для обработки RetryAfter (FloodWait 429)
class RetryAfterException(Exception):
"""Исключение для обработки FloodWait (429) от Telegram API"""
def __init__(self, retry_after):
self.retry_after = retry_after
super().__init__(f"RetryAfter: {retry_after}")
def prepare_text(text):
"""Подготовка текстовых полей с экранированием HTML-сущностей"""
if not text:
@@ -73,6 +115,133 @@ def has_org_tag(text):
pattern = r'#?' + re.escape(ORG_MESSAGE_TAG) + r'\b'
return bool(re.search(pattern, text, re.IGNORECASE))
def log_telegram_error(response, context=""):
"""Логирует полную информацию об ошибке от Telegram API
Работает как с httpx.Response, так и с requests.Response
"""
try:
if response is not None:
logger.error(f"{context} Статус код: {response.status_code}")
logger.error(f"{context} Заголовки ответа: {dict(response.headers)}")
try:
# Работает для обоих типов ответов (httpx и requests)
error_data = response.json()
logger.error(f"{context} Полный ответ от Telegram API: {json.dumps(error_data, ensure_ascii=False, indent=2)}")
except:
# Для requests используем text, для httpx тоже text
response_text = getattr(response, 'text', str(response.content[:500]) if hasattr(response, 'content') else 'N/A')
logger.error(f"{context} Текст ответа: {response_text}")
else:
logger.error(f"{context} Ответ от Telegram API отсутствует (None)")
except Exception as e:
logger.error(f"{context} Ошибка при логировании информации об ошибке: {e}")
async def download_image(image_url, httpx_client):
"""Скачивает изображение по URL и возвращает его содержимое в виде bytes"""
try:
logger.info(f"Скачивание изображения: {image_url}")
download_start_time = time.time()
response = await httpx_client.get(image_url, timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0))
response.raise_for_status()
download_duration = time.time() - download_start_time
image_data = response.content
logger.info(f"Изображение скачано за {download_duration:.2f} секунд, размер: {len(image_data)} байт")
return image_data
except Exception as e:
logger.error(f"Ошибка при скачивании изображения {image_url}: {e}")
raise
async def _edit_post_add_links(
bot, message_id, message_has_image, final_text, vk_post_id, expected_urls
):
"""
Второй шаг публикации: editMessageCaption/Text со ссылками.
Повторяет попытку, пока в ответе API нет всех expected_urls в text_link entities.
"""
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
try:
if message_has_image:
edited = await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode='HTML',
)
published_text = edited.caption or ""
entities = edited.caption_entities or []
else:
edited = await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode='HTML',
disable_web_page_preview=True,
)
published_text = edited.text or ""
entities = edited.entities or []
if published_message_has_expected_links(entities, expected_urls):
logger.info(
f"Ссылки добавлены для записи VK ID {vk_post_id} "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
found = extract_text_link_urls(entities)
logger.warning(
f"editMessage* ok, но footer-ссылки не подтверждены "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для VK ID {vk_post_id}; "
f"ожидались {expected_urls}, найдены text_link: {found}"
)
except TimedOut as e:
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
logger.error(
f"Таймаут редактирования ссылок для VK ID {vk_post_id} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
)
return False
logger.warning(
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}, повтор"
)
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
raise RetryAfterException(getattr(e, 'retry_after', 60))
err = str(e).lower()
if "not modified" in err:
logger.info(
f"Caption уже содержит ссылки для VK ID {vk_post_id} "
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"Ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}: {e}"
)
except RetryAfterException:
raise
except Exception as e:
logger.warning(
f"Неожиданная ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}: {e}"
)
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
logger.error(
f"Не удалось добавить строку ссылок для VK ID {vk_post_id} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
)
return False
async def publish_to_tg(vk_post_id):
"""Публикация одной записи по VK post ID"""
logger.info(f"Запуск публикации для записи VK ID {vk_post_id}")
@@ -102,168 +271,386 @@ async def publish_to_tg(vk_post_id):
logger.info(f"Запись VK ID {vk_post_id} не найдена или уже опубликована")
return False
bot = Bot(token=BOT_TOKEN)
# Настройка таймаутов для HTTPXRequest (аналогично test_tg_poster.py)
request = HTTPXRequest(
read_timeout=120.0, # Таймаут на чтение (увеличен для больших изображений)
write_timeout=120.0, # Таймаут на запись (увеличен для больших файлов)
connect_timeout=30.0, # Таймаут на подключение (увеличен)
pool_timeout=10.0, # Таймаут на получение соединения из пула
media_write_timeout=120.0 # Таймаут для медиа
)
# Проверяем наличие организационного хештега
is_org_message = has_org_tag(post['text'])
# Bot API только через internal_relay
base_url, base_file_url = get_ptb_base_urls()
bot = Bot(
token=BOT_TOKEN,
request=request,
base_url=base_url,
base_file_url=base_file_url,
)
logger.info(f"Telegram Bot API через ретранслятор: {get_relay_url()}")
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
placeholder_message_id = '0' * 20 # Заполнитель для message_id
# Создаем httpx клиент только для скачивания изображений
timeout_config = httpx.Timeout(
connect=10.0, # Таймаут на подключение
read=120.0, # Таймаут на чтение (увеличен для больших изображений)
write=120.0, # Таймаут на запись (увеличен для больших файлов)
pool=10.0 # Таймаут на получение соединения из пула
)
httpx_client = httpx.AsyncClient(
http2=False,
timeout=timeout_config,
follow_redirects=True
)
# Формируем ссылки
links = []
# Параметры для повторных попыток при таймаутах
max_retries = 10
retry_interval = 5
# Ссылка на оригинал в ВК
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
# Переменная для отслеживания успешности публикации
message_id = None
publication_successful = False
# Флаг, указывающий, было ли отправлено изображение (True) или текстовое сообщение (False)
message_has_image = False
# Ссылка на подписку (только для событий)
if post['is_event']:
links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{placeholder_message_id}">🔜 Подписка</a>')
# Формируем строку ссылок
links_line = " | ".join(links) if links else ""
links_line_length = len(links_line)
# Определяем максимальную длину в зависимости от типа сообщения
# Если это организационное сообщение, используем текстовый лимит
image_url = post['image_url']
has_image = image_url and image_url.strip() and not is_org_message
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
# Вычисляем доступную длину для текста
available_length = max_length - links_line_length - 2 # -2 для символов переноса строки
if available_length < 0:
available_length = 0
# Получаем обработанный текст с учетом доступной длины
text = get_event_text(post['text'], available_length) if post['text'] else ""
# Публикация первоначального сообщения без ссылок
if has_image and not is_org_message:
try:
message = await bot.send_photo(
chat_id=CHANNEL_ID,
photo=image_url,
caption=text,
parse_mode="HTML",
disable_notification=True
)
except TelegramError as e:
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=text,
parse_mode="HTML",
disable_notification=True,
disable_web_page_preview=True
)
else:
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=text,
parse_mode="HTML",
disable_notification=True,
disable_web_page_preview=True
)
# Получаем ID сообщения для использования в ссылке подписки
message_id = message.message_id
# Формируем финальный текст с ссылками
if links:
# Обновляем ссылку подписки с реальным message_id
updated_links = []
try:
# Проверяем наличие организационного хештега
is_org_message = has_org_tag(post['text'])
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
placeholder_message_id = '0' * 20 # Заполнитель для message_id
# Формируем ссылки
links = []
# Ссылка на оригинал в ВК
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
if post['is_event']:
updated_links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{message_id}">🔜 Подписка</a>')
# Ссылка на подписку (только для событий и если включен бот подписки)
if post['is_event'] and USE_SUBSCRIPTION_BOT:
links.append(
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", placeholder_message_id)}">🔜 Подписка</a>'
)
links_line = " | ".join(updated_links)
final_text = f"{text}\n\n{links_line}"
# Формируем строку ссылок
links_line = " | ".join(links) if links else ""
links_line_length = len(links_line)
# Определяем максимальную длину в зависимости от типа сообщения
# Если это организационное сообщение, используем текстовый лимит
image_url = post['image_url']
has_image = image_url and image_url.strip() and not is_org_message
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
# Вычисляем доступную длину для текста
available_length = max_length - links_line_length - 2 # -2 для символов переноса строки
if available_length < 0:
available_length = 0
# Получаем обработанный текст с учетом доступной длины
text = get_event_text(post['text'], available_length) if post['text'] else ""
# Логирование отформатированного текста для отладки (только если включено)
if LOG_DEBUG_DATA:
logger.info(f"Начинаем форматирование текста для записи VK ID {vk_post_id}, доступная длина: {available_length}")
logger.info(f"=== Отформатированный текст для записи VK ID {vk_post_id} ===")
logger.info(f"Длина текста: {len(text)}")
logger.info(f"Текст (repr): {repr(text)}")
logger.info(f"Текст (содержимое):")
# Выводим текст построчно для лучшей читаемости в логе
for i, line in enumerate(text.split('\n'), 1):
logger.info(f" Строка {i}: {repr(line)}")
logger.info(f"=== Конец отформатированного текста ===")
# Публикация первоначального сообщения без ссылок
if has_image and not is_org_message:
try:
# Логируем информацию об изображении перед отправкой
logger.info(f"Отправка изображения для записи VK ID {vk_post_id}")
logger.info(f"URL изображения: {image_url}")
logger.info(f"Длина URL изображения: {len(image_url)} символов")
logger.info(f"Длина подписи: {len(text)} символов")
# Скачиваем изображение
image_data = None
try:
image_data = await download_image(image_url, httpx_client)
except Exception as download_error:
logger.error(f"Ошибка при скачивании изображения для записи VK ID {vk_post_id}: {download_error}")
raise # Пробрасываем ошибку, чтобы перейти к fallback
file_size_mb = len(image_data) / (1024 * 1024)
logger.info(f"Размер файла: {file_size_mb:.2f} МБ")
# Повторные попытки отправки фото при таймауте (до 10 раз)
message = None
for attempt in range(1, max_retries + 1):
try:
image_file = io.BytesIO(image_data)
message = await bot.send_photo(
chat_id=CHANNEL_ID,
photo=image_file,
caption=text,
parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY
)
message_id = message.message_id
message_has_image = True
logger.info(f"Сообщение с изображением успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при публикации изображения (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при публикации изображения после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise # Пробрасываем таймаут наверх
except TelegramError as e:
# Проверяем, является ли это ошибкой FloodWait (429)
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации изображения для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации изображения для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
if message_id is None:
raise Exception("Не удалось опубликовать изображение после всех попыток")
except RetryAfterException:
raise
except Exception as e:
# Если не удалось отправить изображение, пробуем отправить текстовое сообщение
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
message_has_image = False # Отправляем текстовое сообщение вместо изображения
# Повторные попытки отправки текста (fallback) при таймауте
for attempt in range(1, max_retries + 1):
try:
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=text,
parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True
)
message_id = message.message_id
logger.info(f"Текстовое сообщение (fallback) успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при отправке текста (fallback) (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при отправке текста (fallback) после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при отправке текста (fallback) для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
raise
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке текста (fallback) для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
raise
else:
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
message_has_image = False # Отправляем текстовое сообщение
# Повторные попытки отправки текста при таймауте
message = None
for attempt in range(1, max_retries + 1):
try:
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=text,
parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True
)
message_id = message.message_id
logger.info(f"Текстовое сообщение успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при публикации текста (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при публикации текста после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise # Пробрасываем таймаут наверх
except TelegramError as e:
# Проверяем, является ли это ошибкой FloodWait (429)
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
if message_id is None:
raise Exception("Не удалось опубликовать текстовое сообщение после всех попыток")
# Формируем финальный текст с ссылками
if links:
# Обновляем ссылку подписки с реальным message_id
updated_links = []
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
if post['is_event'] and USE_SUBSCRIPTION_BOT:
updated_links.append(
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)}">🔜 Подписка</a>'
)
links_line = " | ".join(updated_links)
final_text = f"{text}\n\n{links_line}"
expected_urls = []
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
expected_urls.append(post['vk_post_url'])
if post['is_event'] and USE_SUBSCRIPTION_BOT:
expected_urls.append(
subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
)
if not await _edit_post_add_links(
bot, message_id, message_has_image, final_text, vk_post_id,
expected_urls,
):
logger.error(
f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
f"но ссылки не добавлены — published_in_tg не обновляем"
)
return False
await asyncio.sleep(2) # задержка между обращениями к телеграм
# Редактируем сообщение, добавляя ссылки
if has_image and not is_org_message:
# Публикация опроса, если требуется
poll_message_id = None
if post['is_poll'] and post['poll_question'] and post['poll_options']:
try:
await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode="HTML"
)
except TelegramError as e:
logger.warning(f"Не удалось отредактировать подпись для записи VK ID {vk_post_id}: {e}. Пробуем отредактировать текстовое сообщение.")
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode="HTML",
disable_web_page_preview=True
)
else:
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode="HTML",
disable_web_page_preview=True
)
# Парсим варианты ответа
options = json.loads(post['poll_options'])
# Подготавливаем параметры для опроса
poll_kwargs = {
'chat_id': CHANNEL_ID,
'question': post['poll_question'],
'options': options,
'is_anonymous': True, # В каналах можно отправлять только анонимные опросы
'allows_multiple_answers': post['poll_multiple'],
'disable_notification': PUBLISH_SILENTLY
}
# Добавляем close_date, если указан
if post['poll_end_date']:
# Преобразуем datetime в Unix timestamp
if isinstance(post['poll_end_date'], datetime):
poll_kwargs['close_date'] = int(post['poll_end_date'].timestamp())
else:
poll_kwargs['close_date'] = post['poll_end_date']
# Повторные попытки отправки опроса при таймауте
poll_message = None
for attempt in range(1, max_retries + 1):
try:
poll_message = await bot.send_poll(**poll_kwargs)
poll_message_id = poll_message.message_id
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при отправке опроса (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при отправке опроса после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise # Пробрасываем таймаут наверх
except TelegramError as e:
# Проверяем, является ли это ошибкой FloodWait (429)
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации опроса для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации опроса для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
except RetryAfterException:
raise
except Exception as e:
logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}")
await asyncio.sleep(2) # задержка между обращениями к телеграм
# Проверяем, что message_id был получен (публикация успешна)
if message_id is None:
logger.error(f"Не удалось получить message_id для записи VK ID {vk_post_id}. Публикация не завершена.")
return False
# Обновляем запись в базе данных только если публикация успешна
publication_successful = True
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("""
UPDATE posts
SET published_in_tg = True,
tg_publication_date = %s,
tg_message_id = %s,
tg_poll_id = %s
WHERE vk_post_id = %s
""", (current_time_utc, message_id, poll_message_id, vk_post_id))
conn.commit()
logger.info(f"Запись VK ID {vk_post_id} успешно опубликована")
return True
finally:
await httpx_client.aclose()
# Публикация опроса, если требуется
poll_message_id = None
if post['is_poll'] and post['poll_question'] and post['poll_options']:
try:
# Парсим варианты ответа
options = json.loads(post['poll_options'])
poll_options = [PollOption(option, 0) for option in options]
# Отправляем опрос (в каналах можно отправлять только анонимные опросы)
poll_message = await bot.send_poll(
chat_id=CHANNEL_ID,
question=post['poll_question'],
options=poll_options,
is_anonymous=True, # В каналах можно отправлять только анонимные опросы
allows_multiple_answers=post['poll_multiple'],
close_date=post['poll_end_date'] if post['poll_end_date'] else None,
disable_notification=True
)
poll_message_id = poll_message.message_id
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
except Exception as e:
logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}")
# Обновляем запись в базе данных
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("""
UPDATE posts
SET published_in_tg = True,
tg_publication_date = %s,
tg_message_id = %s,
tg_poll_id = %s
WHERE vk_post_id = %s
""", (current_time_utc, message_id, poll_message_id, vk_post_id))
conn.commit()
logger.info(f"Запись VK ID {vk_post_id} успешно опубликована")
return True
except RetryAfter as e:
except RetryAfterException as e:
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}: {e}")
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
raise
except TelegramError as e:
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {e}")
except TimedOut as e:
logger.error(f"ТАЙМАУТ при публикации записи VK ID {vk_post_id}")
logger.error(f"Тип исключения: {type(e).__name__}")
logger.error(f"Сообщение об ошибке: {str(e)}")
logger.error(f"Полная информация об исключении: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
# Убеждаемся, что БД не обновляется при таймауте
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации записи VK ID {vk_post_id}: {e}")
error_msg = str(e)
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')):
retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}")
logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
# Убеждаемся, что БД не обновляется при ошибке
return False
finally:
if conn:
@@ -304,7 +691,7 @@ async def publish_to_tg_all():
if post_count < len(posts):
await asyncio.sleep(2)
except RetryAfter as e:
except RetryAfterException as e:
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
break
except Exception as e:
+41
View File
@@ -0,0 +1,41 @@
# Вспомогательные скрипты
Скрипты вне основного пайплайна (`runner.sh`) и systemd-сервисов. Запускать из **корня проекта** с активированным venv.
## ops/ — эксплуатация
| Файл | Назначение |
|------|------------|
| `genkey.py` | Генерация hex-ключа для `SECRET_KEY` / `WEBHOOK_SECRET` |
| `register_tg_webhook.py` | Ручная регистрация webhook (в норме — `wsgi_bot.py` при старте) |
| `copy_databases.sh` | Клонирование MariaDB между базами |
```bash
python tools/ops/genkey.py
./venv/bin/python tools/ops/register_tg_webhook.py
bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
```
## dev/ — отладка и примеры
| Файл | Назначение |
|------|------------|
| `send_telegram_example.py` | CLI-пример API `/send_message` |
| `volk_telegram_testing.py` | Отладочный тест публикации в Telegram |
| `volk_category_testing.py` | Отладочный тест загрузки категории VOLK |
```bash
python tools/dev/send_telegram_example.py <user_id> "<message>"
python tools/dev/volk_telegram_testing.py
python tools/dev/volk_category_testing.py <category_id>
```
## legacy/ — снятые с пайплайна
| Файл | Назначение |
|------|------------|
| `evt_prefetch.py` | Массовая пометка видимых событий `marked_to_publication=1`. Убран из `runner.sh` в 1.0.0 |
```bash
./venv/bin/python tools/legacy/evt_prefetch.py
```
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Пример скрипта для отправки сообщений через Telegram бота.
Использование (из корня проекта):
python tools/dev/send_telegram_example.py <user_id> <message>
"""
import sys
import requests
import json
import os
from dotenv import load_dotenv
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
def send_message(user_id, message_text, bot_url=None, api_token=None):
"""
Отправляет сообщение пользователю через Telegram бота
Args:
user_id (int): ID пользователя в Telegram
message_text (str): Текст сообщения
bot_url (str): URL бота (если не указан, берется из переменных окружения)
api_token (str): API токен для аутентификации (если не указан, берется из переменных окружения)
Returns:
tuple: (success: bool, message: str)
"""
if bot_url is None:
# Пытаемся получить URL из переменных окружения
webhook_url = os.getenv('WEBHOOK_URL')
webhook_port = os.getenv('WEBHOOK_PORT', '5005')
if webhook_url:
# Используем внешний URL через reverse proxy
bot_url = f"{webhook_url}/send_message"
else:
bot_url = f"http://localhost:{webhook_port}/send_message"
if api_token is None:
api_token = os.getenv('WEBHOOK_SECRET')
try:
url = f"{bot_url}/send_message"
data = {
"user_id": user_id,
"message": message_text
}
headers = {
'Content-Type': 'application/json',
'X-API-Secret-Token': api_token
}
response = requests.post(url, json=data, headers=headers, timeout=10)
result = response.json()
if response.status_code == 200 and result.get("success"):
return True, result.get("message", "Сообщение отправлено успешно")
else:
return False, result.get("error", "Неизвестная ошибка")
except requests.exceptions.RequestException as e:
return False, f"Ошибка сети: {e}"
except json.JSONDecodeError as e:
return False, f"Ошибка парсинга ответа: {e}"
except Exception as e:
return False, f"Неожиданная ошибка: {e}"
def main():
if len(sys.argv) != 3:
print("Использование: python tools/dev/send_telegram_example.py <user_id> <message>")
print("Пример: python tools/dev/send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
sys.exit(1)
try:
user_id = int(sys.argv[1])
except ValueError:
print("Ошибка: user_id должен быть числом")
sys.exit(1)
message_text = sys.argv[2]
print(f"Отправка сообщения пользователю {user_id}: {message_text}")
success, result_message = send_message(user_id, message_text)
if success:
print(f"{result_message}")
else:
print(f"{result_message}")
sys.exit(1)
if __name__ == "__main__":
main()
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""
Тестовый модуль для проверки загрузки описания категории.
Использование (из корня проекта):
python tools/dev/volk_category_testing.py <category_id>
"""
import os
import sys
import json
import time
import traceback
import socket
import argparse
from datetime import datetime
from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
from dotenv import load_dotenv
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
# Параметры из .env
VOLK_CATEGORY_DESC_PREFIX = os.getenv('VOLK_CATEGORY_DESC_PREFIX')
VOLK_API_VERSION = os.getenv('VOLK_API_VERSION')
# Лог-файл для тестирования
TEST_LOG_FILE = 'volk_category_testing.log'
def log_message(message, category="TEST"):
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [{category}] {message}\n"
# До 5 попыток записи с увеличением задержки
for attempt in range(5):
try:
with open(TEST_LOG_FILE, 'a', encoding='utf-8') as f:
f.write(log_entry)
return True
except (IOError, OSError) as e:
if attempt < 4: # Не последняя попытка
time.sleep(0.4) # Задержка 400ms между попытками
else:
return False
return False
def safe_log_direct(message, category="TEST"):
"""Безопасная функция для прямой записи в лог-файл без обработки исключений
Используется в критических ситуациях, когда обычный log_message может не сработать"""
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [{category}] {message}\n"
# Прямая запись без контекстного менеджера для максимальной надежности
f = open(TEST_LOG_FILE, 'a', encoding='utf-8')
try:
f.write(log_entry)
f.flush() # Принудительно сбрасываем буфер
finally:
f.close()
except:
# Игнорируем все исключения - эта функция должна быть максимально безопасной
pass
def test_category_description_load(category_id, category_number=None, total_count=None):
"""Тестирует загрузку описания категории
Args:
category_id: ID категории для тестирования
category_number: Номер категории в последовательности (опционально)
total_count: Общее количество категорий (опционально)
"""
if category_number and total_count:
header = f"НАЧАЛО ТЕСТИРОВАНИЯ [{category_number}/{total_count}] КАТЕГОРИИ: {category_id}"
else:
header = f"НАЧАЛО ТЕСТИРОВАНИЯ ЗАГРУЗКИ ОПИСАНИЯ КАТЕГОРИИ: {category_id}"
log_message("=" * 80)
log_message(header)
log_message("=" * 80)
# Проверка наличия префикса URL
if not VOLK_CATEGORY_DESC_PREFIX:
error_msg = "Переменная окружения VOLK_CATEGORY_DESC_PREFIX не установлена"
log_message(error_msg, "ERROR")
print(f"ОШИБКА: {error_msg}")
return False
# Формирование URL
desc_url = VOLK_CATEGORY_DESC_PREFIX + category_id
log_message(f"URL для запроса: {desc_url}")
log_message(f"Префикс URL: {VOLK_CATEGORY_DESC_PREFIX}")
log_message(f"ID категории: {category_id}")
desc_response = None
# Шаг 1: Запрос через urlopen
log_message("ШАГ 1: Вызов urlopen()")
try:
req = Request(desc_url)
log_message(f"Request объект создан: {req}")
safe_log_direct("Выполняется urlopen с таймаутом 30 секунд", "DEBUG")
response = urlopen(req, timeout=30)
safe_log_direct("urlopen успешно выполнен", "DEBUG")
log_message("urlopen успешно выполнен")
except (URLError, HTTPError, OSError, socket.gaierror, socket.timeout, ConnectionError) as e:
# Используем безопасное логирование для критических ошибок
safe_log_direct("ПЕРЕХВАЧЕНА СЕТЕВАЯ ОШИБКА urlopen", "ERROR")
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
try:
traceback_str = traceback.format_exc()
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
log_message(f"Трассировка сетевой ошибки:\n{traceback_str}", "ERROR")
except:
pass
try:
error_msg = f"Ошибка сетевого подключения при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR")
except:
pass
print(f"ОШИБКА: Сетевая ошибка при запросе - {type(e).__name__}: {e}")
return False
except Exception as e:
# Перехватываем любые другие исключения, которые могут возникнуть при urlopen
safe_log_direct("ПЕРЕХВАЧЕНА НЕОЖИДАННАЯ ОШИБКА urlopen", "ERROR")
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
try:
traceback_str = traceback.format_exc()
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
log_message(f"Трассировка неожиданной ошибки:\n{traceback_str}", "ERROR")
except:
pass
try:
error_msg = f"Неожиданная ошибка при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR")
except:
pass
print(f"ОШИБКА: Неожиданная ошибка - {type(e).__name__}: {e}")
return False
# Шаг 2: Чтение и декодирование данных
log_message("ШАГ 2: Чтение и декодирование данных")
try:
with response:
# Явно указываем кодировку UTF-8 при декодировании
raw_data = response.read()
log_message(f"raw data read, размер: {len(raw_data)} байт")
safe_log_direct(f"Размер raw_data: {len(raw_data)} байт", "DEBUG")
try:
decoded_data = raw_data.decode('utf-8')
log_message("data decoded (UTF-8)")
safe_log_direct("Декодирование UTF-8 успешно", "DEBUG")
except UnicodeDecodeError as e:
error_msg = f"Ошибка декодирования ответа для категории {category_id}: {e}. Попытка декодирования как latin-1."
log_message(error_msg, "WARNING")
decoded_data = raw_data.decode('latin-1')
log_message("data decoded (latin-1 fallback)")
log_message(f"Длина декодированной строки: {len(decoded_data)} символов")
log_message(f"Первые 200 символов ответа:\n{decoded_data[:200]}")
# Шаг 3: Парсинг JSON
log_message("ШАГ 3: Парсинг JSON")
try:
desc_response = json.loads(decoded_data)
log_message("json loaded успешно")
safe_log_direct("JSON успешно распарсен", "DEBUG")
except json.JSONDecodeError as e:
error_msg = f"Ошибка парсинга JSON для категории {category_id}: {e}. Первые 500 символов ответа: {decoded_data[:500]}"
log_message(error_msg, "ERROR")
log_message(f"Полный ответ (первые 1000 символов):\n{decoded_data[:1000]}", "ERROR")
print(f"ОШИБКА: Не удалось распарсить JSON - {e}")
return False
except Exception as e:
error_msg = f"Ошибка при чтении ответа для категории {category_id}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR")
traceback_str = traceback.format_exc()
log_message(f"Трассировка ошибки чтения:\n{traceback_str}", "ERROR")
print(f"ОШИБКА: Ошибка чтения ответа - {type(e).__name__}: {e}")
return False
# Проверяем, что desc_response был успешно получен
if desc_response is None:
error_msg = f"Не удалось получить ответ для категории {category_id}"
log_message(error_msg, "ERROR")
print(f"ОШИБКА: {error_msg}")
return False
log_message(f"Получен JSON ответ: {json.dumps(desc_response, ensure_ascii=False, indent=2)}")
# Шаг 4: Проверка версии API
log_message("ШАГ 4: Проверка версии API")
api_version = desc_response.get('api_version')
log_message(f"Получена версия API: {api_version}")
log_message(f"Ожидаемая версия API: {VOLK_API_VERSION}")
if api_version != VOLK_API_VERSION:
error_msg = f"Несовпадение версии API при загрузке описания категории {category_id}: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'."
log_message(error_msg, "ERROR")
print(f"ОШИБКА: {error_msg}")
return False
log_message("Версия API соответствует ожидаемой")
# Шаг 5: Проверка наличия блока data
log_message("ШАГ 5: Проверка наличия блока data")
if 'data' not in desc_response:
error_msg = f"В JSON ответе для категории {category_id} отсутствует блок 'data'."
log_message(error_msg, "ERROR")
log_message(f"Структура JSON ответа: {list(desc_response.keys())}", "ERROR")
print(f"ОШИБКА: {error_msg}")
return False
log_message("Блок 'data' найден в ответе")
desc_data = desc_response['data']
log_message(f"Тип desc_data: {type(desc_data)}")
# Шаг 6: Проверка типа desc_data
log_message("ШАГ 6: Проверка типа desc_data")
if not isinstance(desc_data, dict):
error_msg = f"Блок 'data' для категории {category_id} не является словарём (тип: {type(desc_data)})."
log_message(error_msg, "ERROR")
print(f"ОШИБКА: {error_msg}")
return False
log_message("desc_data является словарём")
log_message(f"Ключи в desc_data: {list(desc_data.keys())}")
# Шаг 7: Получение description
log_message("ШАГ 7: Получение description из блока data")
description = desc_data.get('description')
log_message(f"description получен, тип: {type(description)}")
if description:
log_message(f"Длина description: {len(description)} символов")
log_message(f"Первые 500 символов description:\n{description[:500]}")
log_message("Описание успешно получено")
else:
log_message("description пустое или отсутствует (будет установлено в None)")
description = None
# Итоговый результат
if category_number and total_count:
footer = f"ТЕСТИРОВАНИЕ ЗАВЕРШЕНО УСПЕШНО [{category_number}/{total_count}] КАТЕГОРИИ: {category_id}"
else:
footer = f"ТЕСТИРОВАНИЕ ЗАВЕРШЕНО УСПЕШНО ДЛЯ КАТЕГОРИИ: {category_id}"
log_message("=" * 80)
log_message(footer)
log_message("=" * 80)
return True
def main():
"""Главная функция"""
parser = argparse.ArgumentParser(
description='Тестирование загрузки описания категории из API VOLK',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Примеры использования:
python tools/dev/volk_category_testing.py volk26_nri
python tools/dev/volk_category_testing.py volk26_nri volk26_other volk26_third
"""
)
parser.add_argument(
'category_ids',
type=str,
nargs='+',
help='ID категорий для тестирования (от 1 до 10 штук, например: volk26_nri volk26_other)'
)
args = parser.parse_args()
category_ids = args.category_ids
# Проверка количества ID
if len(category_ids) == 0:
print("ОШИБКА: Не указан ни один ID категории")
parser.print_help()
sys.exit(1)
if len(category_ids) > 10:
print(f"ОШИБКА: Указано слишком много ID категорий ({len(category_ids)}). Максимум 10.")
sys.exit(1)
# Проверка наличия необходимых переменных окружения
if not VOLK_CATEGORY_DESC_PREFIX:
print("ОШИБКА: Переменная окружения VOLK_CATEGORY_DESC_PREFIX не установлена")
print("Проверьте файл .env")
sys.exit(1)
if not VOLK_API_VERSION:
print("ОШИБКА: Переменная окружения VOLK_API_VERSION не установлена")
print("Проверьте файл .env")
sys.exit(1)
log_message("=" * 80)
log_message("ЗАПУСК ТЕСТИРОВАНИЯ ЗАГРУЗКИ ОПИСАНИЯ КАТЕГОРИЙ")
log_message(f"Время запуска: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
log_message(f"Количество категорий для обработки: {len(category_ids)}")
log_message(f"ID категорий: {', '.join(category_ids)}")
log_message(f"Префикс URL: {VOLK_CATEGORY_DESC_PREFIX}")
log_message(f"Версия API: {VOLK_API_VERSION}")
log_message(f"Лог-файл: {TEST_LOG_FILE}")
log_message("=" * 80)
# Обработка каждой категории последовательно
total_count = len(category_ids)
success_count = 0
fail_count = 0
for index, category_id in enumerate(category_ids, start=1):
log_message("")
log_message("")
log_message(f">>> ОБРАБОТКА КАТЕГОРИИ {index} из {total_count}: {category_id}")
log_message("")
print(f"\n[{index}/{total_count}] Обработка категории: {category_id}")
success = test_category_description_load(category_id, category_number=index, total_count=total_count)
if success:
success_count += 1
print(f"[{index}/{total_count}] ✓ УСПЕХ: Категория {category_id} обработана успешно")
else:
fail_count += 1
print(f"[{index}/{total_count}] ✗ ОШИБКА: Категория {category_id} не обработана")
# Итоговая статистика
log_message("")
log_message("")
log_message("=" * 80)
log_message("ИТОГОВАЯ СТАТИСТИКА")
log_message("=" * 80)
log_message(f"Всего категорий обработано: {total_count}")
log_message(f"Успешно: {success_count}")
log_message(f"С ошибками: {fail_count}")
log_message(f"Время завершения: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
log_message("=" * 80)
print(f"\n{'='*60}")
print(f"ИТОГИ:")
print(f" Всего категорий: {total_count}")
print(f" Успешно: {success_count}")
print(f" С ошибками: {fail_count}")
print(f" Лог сохранен в: {TEST_LOG_FILE}")
print(f"{'='*60}")
# Возвращаем код ошибки, если были неудачи
if fail_count > 0:
sys.exit(1)
else:
sys.exit(0)
if __name__ == "__main__":
main()
+603
View File
@@ -0,0 +1,603 @@
import os
import logging
import httpx
import asyncio
import time
import sys
import json
from datetime import datetime
from dotenv import load_dotenv
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
from telegram_relay import bot_api_method_url, get_relay_url
from season_links import subscription_start_link
# Настройки из переменных окружения
POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
CHANNEL_ID = os.getenv('CHANNEL_ID')
DESC_PREFIX = os.getenv('DESC_PREFIX', '')
PZK_PREFIX = os.getenv('PZK_PREFIX', '')
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
# Настройка логирования
LOG_FILE = 'volk_telegram_testing.log'
logger = logging.getLogger('VOLK_TG_Test')
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
# Консольный обработчик
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Файловый обработчик с повторными попытками
class RetryFileHandler(logging.FileHandler):
def emit(self, record):
for _ in range(5):
try:
super().emit(record)
return
except (IOError, PermissionError):
time.sleep(0.5)
print(f"Failed to write to log file after 5 attempts: {record.msg}")
file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
async def test_telegram_publish():
"""Тестовая публикация сообщения в Telegram"""
logger.info("=" * 80)
logger.info("НАЧАЛО ТЕСТОВОЙ ПУБЛИКАЦИИ В TELEGRAM")
logger.info("=" * 80)
# Логируем загруженные параметры
logger.info("Загруженные параметры из .env:")
logger.info(f" POSTER_BOT_TOKEN: {'*' * 20 if POSTER_BOT_TOKEN else 'НЕ ЗАДАН'}")
logger.info(f" RESPONDER_BOT_NAME: {RESPONDER_BOT_NAME}")
logger.info(f" CHANNEL_ID: {CHANNEL_ID}")
logger.info(f" DESC_PREFIX: {DESC_PREFIX}")
logger.info(f" PZK_PREFIX: {PZK_PREFIX}")
logger.info(f" MAX_CAPTION_LENGTH: {MAX_CAPTION_LENGTH}")
logger.info(f" MAX_TEXT_LENGTH: {MAX_TEXT_LENGTH}")
logger.info(f" PUBLISH_SILENTLY: {PUBLISH_SILENTLY}")
logger.info(f" USE_SUBSCRIPTION_BOT: {USE_SUBSCRIPTION_BOT}")
logger.info(f" RELAY_URL: {get_relay_url()}")
# Проверка обязательных параметров
if not POSTER_BOT_TOKEN:
logger.error("ОШИБКА: POSTER_BOT_TOKEN не задан в .env")
return False
if not CHANNEL_ID:
logger.error("ОШИБКА: CHANNEL_ID не задан в .env")
return False
# Формируем тестовое сообщение
test_number = "TEST001"
test_tags = "#тест #volk_testing"
test_unit_name = "Тестовая площадка"
test_name = "Тестовое событие для проверки публикации"
test_about = "Это тестовое сообщение для проверки работы публикации в Telegram. " * 5
# Формируем базовый текст
base_text = f"{test_tags}\n{test_unit_name}\n<b>{test_name}</b>"
base_text_length = len(base_text)
# Формируем строку ссылок
placeholder_message_id = '1234567890'
if USE_SUBSCRIPTION_BOT:
links_line = (
f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a> | '
f'📌 <a href="{PZK_PREFIX}{test_number}/">Иду</a> | '
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", placeholder_message_id)}">Подписка</a>'
)
else:
links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a>'
links_line_length = len(links_line)
# Определяем максимальную длину
max_length = MAX_CAPTION_LENGTH # Используем caption для теста
# Вычисляем доступную длину для текста
available_length = max_length - base_text_length - links_line_length - 3
if available_length < 0:
available_length = 0
# Обрезаем about до доступной длины
if len(test_about) > available_length:
test_about = test_about[:available_length-3] + "..."
# Формируем финальный текст
final_text = f"{base_text}\n\n{test_about}\n\n{links_line}"
# Логируем детали сообщения
logger.info("=" * 80)
logger.info("ДЕТАЛИ ТЕСТОВОГО СООБЩЕНИЯ:")
logger.info("=" * 80)
logger.info(f"Длина base_text: {base_text_length} символов")
logger.info(f"Длина test_about: {len(test_about)} символов")
logger.info(f"Длина links_line: {links_line_length} символов")
logger.info(f"Максимальная длина (max_length): {max_length} символов")
logger.info(f"Доступная длина для about: {available_length} символов")
logger.info(f"Длина final_text: {len(final_text)} символов")
logger.info(f"Используется изображение: НЕТ")
logger.info("=" * 80)
logger.info("ТЕКСТ СООБЩЕНИЯ:")
logger.info("=" * 80)
logger.info(final_text)
logger.info("=" * 80)
# Создаем httpx клиент для прямых запросов к Telegram Bot API
httpx_client = None
try:
# Создаём клиент БЕЗ HTTP/2 с указанными параметрами
logger.info("Создание httpx клиента с отключенным HTTP/2...")
httpx_client = httpx.AsyncClient(
http2=False, # ← отключаем HTTP/2
timeout=20.0, # явный таймаут
follow_redirects=True
)
logger.info("httpx клиент успешно создан с http2=False, timeout=20.0")
# Получаем информацию о боте через Telegram Bot API
try:
logger.info("=" * 80)
logger.info("ПОПЫТКА ПОЛУЧЕНИЯ ИНФОРМАЦИИ О БОТЕ")
logger.info("=" * 80)
get_me_url = bot_api_method_url(POSTER_BOT_TOKEN, "getMe")
logger.info(f"URL запроса: {get_me_url}")
logger.info(f"Метод: GET")
start_time = datetime.now()
response = await httpx_client.get(get_me_url)
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
logger.info(f"Статус ответа: {response.status_code}")
logger.info(f"Время запроса: {duration:.2f} секунд")
if response.status_code == 200:
bot_info = response.json()
if bot_info.get('ok'):
result = bot_info.get('result', {})
logger.info("=" * 80)
logger.info("ИНФОРМАЦИЯ О БОТЕ УСПЕШНО ПОЛУЧЕНА!")
logger.info("=" * 80)
logger.info(f"ID: {result.get('id')}")
logger.info(f"Username: @{result.get('username')}")
logger.info(f"First name: {result.get('first_name')}")
logger.info(f"Is bot: {result.get('is_bot')}")
logger.info("=" * 80)
else:
logger.error(f"Ошибка в ответе API: {bot_info}")
else:
logger.error(f"HTTP ошибка: {response.status_code}")
logger.error(f"Текст ответа: {response.text}")
except httpx.TimeoutException as e:
logger.error("=" * 80)
logger.error("ТАЙМАУТ при получении информации о боте!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
except httpx.HTTPError as e:
logger.error("=" * 80)
logger.error("ОШИБКА HTTP при получении информации о боте!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
except Exception as e:
logger.error("=" * 80)
logger.error("НЕОЖИДАННАЯ ОШИБКА при получении информации о боте!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f"Полное представление: {repr(e)}")
import traceback
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
# Публикуем тестовое сообщение через Telegram Bot API
try:
logger.info("=" * 80)
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ")
logger.info("=" * 80)
send_message_url = bot_api_method_url(POSTER_BOT_TOKEN, "sendMessage")
logger.info(f"URL запроса: {send_message_url}")
logger.info(f"Метод: POST")
logger.info(f"Параметры запроса:")
logger.info(f" chat_id: {CHANNEL_ID}")
logger.info(f" text_length: {len(final_text)}")
logger.info(f" parse_mode: HTML")
logger.info(f" disable_notification: {PUBLISH_SILENTLY}")
logger.info(f" disable_web_page_preview: True")
payload = {
'chat_id': CHANNEL_ID,
'text': final_text,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY,
'disable_web_page_preview': True
}
start_time = datetime.now()
response = await httpx_client.post(send_message_url, json=payload)
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
logger.info(f"Статус ответа: {response.status_code}")
logger.info(f"Время запроса: {duration:.2f} секунд")
if response.status_code == 200:
result = response.json()
if result.get('ok'):
message_data = result.get('result', {})
message_id = message_data.get('message_id')
chat_data = message_data.get('chat', {})
logger.info("=" * 80)
logger.info("СООБЩЕНИЕ УСПЕШНО ОТПРАВЛЕНО!")
logger.info("=" * 80)
logger.info(f"Message ID: {message_id}")
logger.info(f"Chat ID: {chat_data.get('id')}")
logger.info(f"Chat type: {chat_data.get('type')}")
logger.info(f"Chat title: {chat_data.get('title', 'N/A')}")
logger.info(f"Время отправки: {duration:.2f} секунд")
logger.info(f"Дата сообщения: {message_data.get('date')}")
logger.info("=" * 80)
# Редактируем сообщение, добавляя информацию о редактировании
try:
logger.info("=" * 80)
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ")
logger.info("=" * 80)
edit_message_url = bot_api_method_url(POSTER_BOT_TOKEN, "editMessageText")
logger.info(f"URL запроса: {edit_message_url}")
logger.info(f"Метод: POST")
logger.info(f"Параметры запроса:")
logger.info(f" chat_id: {CHANNEL_ID}")
logger.info(f" message_id: {message_id}")
# Добавляем информацию о редактировании в конец сообщения
edited_text = f"{final_text}\n\n<i>Сообщение отредактировано через httpx клиент</i>"
logger.info(f" text_length (после редактирования): {len(edited_text)}")
logger.info(f" parse_mode: HTML")
edit_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': edited_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_start_time = datetime.now()
edit_response = await httpx_client.post(edit_message_url, json=edit_payload)
edit_end_time = datetime.now()
edit_duration = (edit_end_time - edit_start_time).total_seconds()
logger.info(f"Статус ответа: {edit_response.status_code}")
logger.info(f"Время запроса: {edit_duration:.2f} секунд")
if edit_response.status_code == 200:
edit_result = edit_response.json()
if edit_result.get('ok'):
logger.info("=" * 80)
logger.info("СООБЩЕНИЕ УСПЕШНО ОТРЕДАКТИРОВАНО!")
logger.info("=" * 80)
logger.info(f"Время редактирования: {edit_duration:.2f} секунд")
logger.info("=" * 80)
else:
logger.error(f"Ошибка в ответе API при редактировании: {edit_result}")
else:
logger.error(f"HTTP ошибка при редактировании: {edit_response.status_code}")
logger.error(f"Текст ответа: {edit_response.text}")
except httpx.TimeoutException as e:
logger.error("=" * 80)
logger.error("ТАЙМАУТ ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
logger.error(f"Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" text_length: {len(edited_text)}")
except httpx.HTTPError as e:
logger.error("=" * 80)
logger.error("ОШИБКА HTTP ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
except Exception as e:
logger.error("=" * 80)
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f"Полное представление: {repr(e)}")
import traceback
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
# Публикуем второе сообщение с картинкой
try:
logger.info("=" * 80)
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ С КАРТИНКОЙ")
logger.info("=" * 80)
send_photo_url = bot_api_method_url(POSTER_BOT_TOKEN, "sendPhoto")
image_url = "https://base.volk-nn.ru/uploads/events/volk26/volk26_event/20251210_175059_0.jpg"
# Формируем текст для сообщения с картинкой
photo_caption = f"{test_tags}\n{test_unit_name}\n<b>Тестовое сообщение с картинкой</b>\n\nЭто тестовое сообщение с изображением для проверки работы публикации фото в Telegram."
logger.info(f"URL запроса: {send_photo_url}")
logger.info(f"Метод: POST")
logger.info(f"Параметры запроса:")
logger.info(f" chat_id: {CHANNEL_ID}")
logger.info(f" photo_url: {image_url}")
logger.info(f" photo_url_length: {len(image_url)}")
logger.info(f" caption_length: {len(photo_caption)}")
logger.info(f" parse_mode: HTML")
logger.info(f" disable_notification: {PUBLISH_SILENTLY}")
photo_payload = {
'chat_id': CHANNEL_ID,
'photo': image_url,
'caption': photo_caption,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY
}
photo_start_time = datetime.now()
photo_response = await httpx_client.post(send_photo_url, json=photo_payload)
photo_end_time = datetime.now()
photo_duration = (photo_end_time - photo_start_time).total_seconds()
logger.info(f"Статус ответа: {photo_response.status_code}")
logger.info(f"Время запроса: {photo_duration:.2f} секунд")
if photo_response.status_code == 200:
photo_result = photo_response.json()
if photo_result.get('ok'):
photo_message_data = photo_result.get('result', {})
photo_message_id = photo_message_data.get('message_id')
photo_chat_data = photo_message_data.get('chat', {})
logger.info("=" * 80)
logger.info("СООБЩЕНИЕ С КАРТИНКОЙ УСПЕШНО ОТПРАВЛЕНО!")
logger.info("=" * 80)
logger.info(f"Message ID: {photo_message_id}")
logger.info(f"Chat ID: {photo_chat_data.get('id')}")
logger.info(f"Chat type: {photo_chat_data.get('type')}")
logger.info(f"Chat title: {photo_chat_data.get('title', 'N/A')}")
logger.info(f"Время отправки: {photo_duration:.2f} секунд")
logger.info(f"Дата сообщения: {photo_message_data.get('date')}")
logger.info("=" * 80)
# Редактируем сообщение с картинкой, добавляя информацию о редактировании
try:
logger.info("=" * 80)
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ С КАРТИНКОЙ")
logger.info("=" * 80)
edit_photo_caption_url = bot_api_method_url(POSTER_BOT_TOKEN, "editMessageCaption")
logger.info(f"URL запроса: {edit_photo_caption_url}")
logger.info(f"Метод: POST")
logger.info(f"Параметры запроса:")
logger.info(f" chat_id: {CHANNEL_ID}")
logger.info(f" message_id: {photo_message_id}")
# Добавляем информацию о редактировании в конец подписи
edited_photo_caption = f"{photo_caption}\n\n<i>Сообщение с картинкой отредактировано через httpx клиент</i>"
logger.info(f" caption_length (после редактирования): {len(edited_photo_caption)}")
logger.info(f" parse_mode: HTML")
edit_photo_payload = {
'chat_id': CHANNEL_ID,
'message_id': photo_message_id,
'caption': edited_photo_caption,
'parse_mode': 'HTML'
}
edit_photo_start_time = datetime.now()
edit_photo_response = await httpx_client.post(edit_photo_caption_url, json=edit_photo_payload)
edit_photo_end_time = datetime.now()
edit_photo_duration = (edit_photo_end_time - edit_photo_start_time).total_seconds()
logger.info(f"Статус ответа: {edit_photo_response.status_code}")
logger.info(f"Время запроса: {edit_photo_duration:.2f} секунд")
if edit_photo_response.status_code == 200:
edit_photo_result = edit_photo_response.json()
if edit_photo_result.get('ok'):
logger.info("=" * 80)
logger.info("СООБЩЕНИЕ С КАРТИНКОЙ УСПЕШНО ОТРЕДАКТИРОВАНО!")
logger.info("=" * 80)
logger.info(f"Время редактирования: {edit_photo_duration:.2f} секунд")
logger.info("=" * 80)
else:
logger.error(f"Ошибка в ответе API при редактировании подписи: {edit_photo_result}")
logger.error(f"Описание ошибки: {edit_photo_result.get('description', 'N/A')}")
logger.error(f"Код ошибки: {edit_photo_result.get('error_code', 'N/A')}")
else:
logger.error(f"HTTP ошибка при редактировании подписи: {edit_photo_response.status_code}")
logger.error(f"Текст ответа: {edit_photo_response.text}")
except httpx.TimeoutException as e:
logger.error("=" * 80)
logger.error("ТАЙМАУТ ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ С КАРТИНКОЙ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
logger.error(f"Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {photo_message_id}")
logger.error(f" caption_length: {len(edited_photo_caption)}")
except httpx.HTTPError as e:
logger.error("=" * 80)
logger.error("ОШИБКА HTTP ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ С КАРТИНКОЙ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
except Exception as e:
logger.error("=" * 80)
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ С КАРТИНКОЙ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f"Полное представление: {repr(e)}")
import traceback
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
else:
logger.error(f"Ошибка в ответе API при отправке фото: {photo_result}")
logger.error(f"Описание ошибки: {photo_result.get('description', 'N/A')}")
logger.error(f"Код ошибки: {photo_result.get('error_code', 'N/A')}")
else:
logger.error(f"HTTP ошибка при отправке фото: {photo_response.status_code}")
logger.error(f"Текст ответа: {photo_response.text}")
except httpx.TimeoutException as e:
logger.error("=" * 80)
logger.error("ТАЙМАУТ ПРИ ОТПРАВКЕ СООБЩЕНИЯ С КАРТИНКОЙ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
logger.error(f"Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" photo_url: {image_url}")
logger.error(f" caption_length: {len(photo_caption)}")
except httpx.HTTPError as e:
logger.error("=" * 80)
logger.error("ОШИБКА HTTP ПРИ ОТПРАВКЕ СООБЩЕНИЯ С КАРТИНКОЙ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
except Exception as e:
logger.error("=" * 80)
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ ОТПРАВКЕ СООБЩЕНИЯ С КАРТИНКОЙ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f"Полное представление: {repr(e)}")
import traceback
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
return True
else:
logger.error(f"Ошибка в ответе API: {result}")
logger.error(f"Описание ошибки: {result.get('description', 'N/A')}")
logger.error(f"Код ошибки: {result.get('error_code', 'N/A')}")
return False
else:
logger.error(f"HTTP ошибка: {response.status_code}")
logger.error(f"Текст ответа: {response.text}")
return False
except httpx.TimeoutException as e:
logger.error("=" * 80)
logger.error("ТАЙМАУТ ПРИ ОТПРАВКЕ СООБЩЕНИЯ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
logger.error(f"Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" text_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
return False
except httpx.HTTPError as e:
logger.error("=" * 80)
logger.error("ОШИБКА HTTP ПРИ ОТПРАВКЕ СООБЩЕНИЯ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e)}")
logger.error(f"Полное представление: {repr(e)}")
return False
except Exception as e:
logger.error("=" * 80)
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ ОТПРАВКЕ СООБЩЕНИЯ!")
logger.error("=" * 80)
logger.error(f"Тип ошибки: {type(e).__name__}")
logger.error(f"Сообщение: {str(e)}")
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f"Полное представление: {repr(e)}")
import traceback
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
return False
finally:
# Закрываем httpx клиент
if httpx_client is not None:
try:
logger.info("Закрытие httpx клиента...")
await httpx_client.aclose()
logger.info("httpx клиент успешно закрыт")
except Exception as e:
logger.error(f"Ошибка при закрытии httpx клиента: {e}")
logger.error(f" Тип ошибки: {type(e).__name__}")
import traceback
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
async def main():
"""Главная функция"""
try:
success = await test_telegram_publish()
if success:
logger.info("=" * 80)
logger.info("ТЕСТ ЗАВЕРШЕН УСПЕШНО")
logger.info("=" * 80)
sys.exit(0)
else:
logger.error("=" * 80)
logger.error("ТЕСТ ЗАВЕРШЕН С ОШИБКОЙ")
logger.error("=" * 80)
sys.exit(1)
except KeyboardInterrupt:
logger.warning("Прервано пользователем")
sys.exit(1)
except Exception as e:
logger.error(f"Критическая ошибка: {e}")
import traceback
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
@@ -1,10 +1,19 @@
#!/usr/bin/env python3
"""LEGACY — не входит в пайплайн runner.sh с версии 1.0.0.
Проставляет marked_to_publication = 1 видимым событиям (is_visible = 1),
у которых флаг ещё не установлен.
Запуск вручную из корня проекта:
./venv/bin/python tools/legacy/evt_prefetch.py
"""
import os
import mysql.connector
from mysql.connector import Error
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
# Загрузка переменных окружения
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
MDB_HOST = os.getenv('MDB_HOST')
@@ -12,54 +21,42 @@ MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE')
def prefetch_all_events():
"""
Проверяет записи в таблице events и устанавливает флаг marked_to_publication
для записей, которые:
- имеют флаг is_visible = 1
- не имеют флага marked_to_publication = 1
- modified более часа назад от текущего времени (UTC)
"""
try:
# Подключение к базе данных
connection = mysql.connector.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE
)
# Установка временной зоны соединения в UTC
cursor_temp = connection.cursor()
cursor_temp.execute("SET time_zone = '+00:00'")
cursor_temp.close()
cursor = connection.cursor()
# Текущее время в UTC
current_time_utc = datetime.now(timezone.utc).replace(tzinfo=None)
one_hour_ago = current_time_utc - timedelta(hours=1)
# SQL запрос для обновления записей
update_query = """
UPDATE events
SET marked_to_publication = 1
WHERE is_visible = 1
AND marked_to_publication = 0
AND modified < %s
UPDATE events
SET marked_to_publication = 1
WHERE is_visible = 1
AND marked_to_publication = 0
"""
# Выполнение запроса
cursor.execute(update_query, (one_hour_ago,))
cursor.execute(update_query)
updated_count = cursor.rowcount
# Фиксация изменений
connection.commit()
print(f"Обновлено записей: {updated_count}")
return updated_count
except Error as e:
print(f"Ошибка базы данных: {e}")
if 'connection' in locals() and connection.is_connected():
@@ -73,5 +70,6 @@ def prefetch_all_events():
cursor.close()
connection.close()
if __name__ == "__main__":
prefetch_all_events()
+106
View File
@@ -0,0 +1,106 @@
#!/bin/bash
# Использование (из корня проекта):
# bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
# Пример:
# bash tools/ops/copy_databases.sh old_db new_db appuser
set -e # Завершать выполнение при любой ошибке
if [ "$#" -ne 3 ]; then
echo "Использование: $0 <исходная_база> <целевая_база> <имя_пользователя>"
exit 1
fi
SOURCE_DB="$1"
TARGET_DB="$2"
DB_USER="$3"
BACKUP_DIR="."
# Функция для запроса пароля от MariaDB один раз
read_mysql_password() {
if [ -z "$MYSQL_PWD" ]; then
read -s -p "Введите пароль MariaDB для пользователя root: " MYSQL_PWD
echo
export MYSQL_PWD
fi
}
# Функция проверки существования базы данных
db_exists() {
local db_name="$1"
if mysql -u root -e "SHOW DATABASES LIKE '$db_name';" | grep -q "^$db_name$"; then
return 0
else
return 1
fi
}
# Функция резервного копирования базы
backup_db() {
local db_name="$1"
local backup_file="${BACKUP_DIR}/${db_name}.backup.sql"
echo "Создаётся резервная копия базы '$db_name' в файл '$backup_file'..."
mysqldump -u root \
--routines \
--triggers \
--events \
--single-transaction \
"$db_name" > "$backup_file"
echo "Резервная копия '$db_name' успешно создана."
}
# === НАЧАЛО ОСНОВНОГО СЦЕНАРИЯ ===
read_mysql_password
echo "Проверка существования исходной базы '$SOURCE_DB'..."
if ! db_exists "$SOURCE_DB"; then
echo "Ошибка: исходная база данных '$SOURCE_DB' не существует."
exit 1
fi
# Резервное копирование исходной базы (всегда)
backup_db "$SOURCE_DB"
# Проверка существования целевой базы
if db_exists "$TARGET_DB"; then
echo "Целевая база '$TARGET_DB' уже существует."
backup_db "$TARGET_DB"
echo "Удаление существующей базы '$TARGET_DB'..."
mysql -u root -e "DROP DATABASE \`$TARGET_DB\`;"
else
echo "Целевая база '$TARGET_DB' не существует — будет создана."
fi
# Создание новой целевой базы
echo "Создание новой базы '$TARGET_DB'..."
mysql -u root -e "CREATE DATABASE \`$TARGET_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# Создание временного дампа без указания базы (без CREATE DATABASE и USE)
TEMP_DUMP=$(mktemp)
echo "Создание временного дампа из '$SOURCE_DB'..."
mysqldump -u root \
--routines \
--triggers \
--events \
--single-transaction \
--no-create-db \
"$SOURCE_DB" > "$TEMP_DUMP"
# Восстановление в целевую базу
echo "Восстановление данных в '$TARGET_DB'..."
mysql -u root "$TARGET_DB" < "$TEMP_DUMP"
# Удаление временного файла
rm -f "$TEMP_DUMP"
# Настройка прав пользователя
echo "Настройка прав для пользователя '$DB_USER'@'%' на базу '$TARGET_DB'..."
mysql -u root -e "CREATE USER IF NOT EXISTS \`$DB_USER\`@'%';"
mysql -u root -e "GRANT ALL PRIVILEGES ON \`$TARGET_DB\`.* TO \`$DB_USER\`@'%';"
mysql -u root -e "FLUSH PRIVILEGES;"
echo "✅ Копирование завершено успешно!"
echo "Исходная база: $SOURCE_DB → Целевая база: $TARGET_DB"
echo "Пользователь: $DB_USER@% имеет полные права на целевую базу."
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""Генерация случайного hex-ключа для SECRET_KEY или WEBHOOK_SECRET.
Запуск из корня проекта:
python tools/ops/genkey.py
"""
import secrets
if __name__ == "__main__":
print(secrets.token_hex(32))
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Ручная регистрация Telegram webhook через internal_relay (с secret_token).
Запуск из корня проекта:
./venv/bin/python tools/ops/register_tg_webhook.py
Печатает полный ответ setWebhook — по нему видно, принял ли Telegram секрет.
"""
import json
import sys
import os
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
from dotenv import load_dotenv
import requests
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
from telegram_relay import bot_api_method_url, get_relay_url
TOKEN = os.getenv("RESPONDER_BOT_TOKEN")
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
WEBHOOK_SECRET = (os.getenv("WEBHOOK_SECRET") or "").strip().strip('"').strip("'")
def main() -> int:
if not TOKEN:
print("ERROR: RESPONDER_BOT_TOKEN не задан")
return 1
if not WEBHOOK_URL:
print("ERROR: WEBHOOK_URL не задан")
return 1
if not WEBHOOK_SECRET:
print("ERROR: WEBHOOK_SECRET не задан")
return 1
api_url = bot_api_method_url(TOKEN, "setWebhook")
form = {
"url": WEBHOOK_URL,
"secret_token": WEBHOOK_SECRET,
"max_connections": "40",
# Иначе Telegram может сохранить старый список без callback_query
"allowed_updates": json.dumps([
"message",
"edited_message",
"callback_query",
]),
}
print(f"RELAY_URL = {get_relay_url()}")
print(f"WEBHOOK_URL = {WEBHOOK_URL}")
print(f"secret_len = {len(WEBHOOK_SECRET)}")
print(f"POST = {api_url.replace(TOKEN, '<TOKEN>')}")
print(f"form keys = {list(form.keys())}")
resp = requests.post(api_url, data=form, timeout=60)
print(f"HTTP status = {resp.status_code}")
try:
data = resp.json()
print("JSON response:")
print(json.dumps(data, ensure_ascii=False, indent=2))
except Exception:
print("Raw response:")
print(resp.text[:1000])
return 1
if not data.get("ok"):
print("FAILED: Telegram/relay вернул ok=false")
return 1
print("OK: setWebhook принят. Нажмите «Подписка» / отправьте /start боту.")
print("В логе бота не должно быть «Секретный токен отсутствует».")
return 0
if __name__ == "__main__":
sys.exit(main())
+97 -12
View File
@@ -18,6 +18,15 @@ VK_ACCESS_TOKEN = os.getenv('VK_ACCESS_TOKEN', '')
VK_API_VERSION = os.getenv('VK_API_VERSION', '5.131')
IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on')
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации
# Минимум букв (isalpha), чтобы пост не считался пустым (видео без описания и т.п.)
try:
VK_POST_MIN_LETTERS = int((os.getenv('VK_POST_MIN_LETTERS') or '5').strip().strip('"').strip("'"))
except ValueError:
VK_POST_MIN_LETTERS = 5
if VK_POST_MIN_LETTERS < 0:
VK_POST_MIN_LETTERS = 0
EMPTY_POST_SHORTNAME = "- = Пустой пост = -"
LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
@@ -62,7 +71,7 @@ def get_vk_posts():
url = 'https://api.vk.ru/method/wall.get'
params = {
'owner_id': VK_GROUP_ID,
'count': 10,
'count': 20,
'access_token': VK_ACCESS_TOKEN,
'v': VK_API_VERSION,
'extended': 1
@@ -85,12 +94,37 @@ def get_vk_posts():
log_message(error_msg)
raise Exception(error_msg)
def count_letters(text):
"""Количество букв в тексте (Unicode isalpha), без цифр и знаков."""
return sum(1 for c in (text or "") if c.isalpha())
def process_post(post):
"""Извлекает необходимые данные из поста VK"""
"""Извлекает необходимые данные из поста VK, включая обработку репостов"""
post_id = post['id']
vk_post_url = f"https://vk.ru/wall{VK_GROUP_ID}_{post_id}"
published_at = datetime.utcfromtimestamp(post['date']).strftime('%Y-%m-%d %H:%M:%S')
# Проверка, является ли пост репостом
is_repost = 'copy_history' in post and len(post.get('copy_history', [])) > 0
# Если это репост, используем данные из оригинального поста
if is_repost:
original_post = post['copy_history'][0] # Берем первый элемент (обычно он один)
# Используем данные из оригинального поста
source_post = original_post
# Сохраняем информацию о том, что это репост, в тексте
repost_prefix = f"[Репост] "
original_owner_id = original_post.get('owner_id', 0)
original_post_id = original_post.get('id', 0)
original_url = f"https://vk.ru/wall{original_owner_id}_{original_post_id}"
repost_info = f"\n\n[Оригинальный пост: {original_url}]"
else:
source_post = post
repost_prefix = ""
repost_info = ""
# Инициализация полей опроса
is_poll = 0
poll_question = None
@@ -101,8 +135,8 @@ def process_post(post):
# Инициализация поля изображения
image_url = None
# Обработка вложений
for attachment in post.get('attachments', []):
# Обработка вложений из оригинального поста (если это репост) или текущего поста
for attachment in source_post.get('attachments', []):
if attachment['type'] == 'poll':
is_poll = 1
poll = attachment['poll']
@@ -122,9 +156,21 @@ def process_post(post):
max_size = max(sizes, key=lambda s: s.get('width', 0) * s.get('height', 0))
image_url = max_size['url']
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
post_text = source_post.get('text', '')
own_text = post.get('text', '')
letter_count = count_letters(post_text)
if is_repost:
letter_count += count_letters(own_text)
# Если у репоста есть свой текст, добавляем его перед текстом оригинала
if own_text.strip():
post_text = f"{own_text}\n\n---\n\n{post_text}"
post_text = repost_prefix + post_text + repost_info
is_empty_text = letter_count < VK_POST_MIN_LETTERS
return {
'vk_post_id': post_id,
'text': post['text'],
'text': post_text,
'image_url': image_url,
'vk_post_url': vk_post_url,
'published_at': published_at,
@@ -132,7 +178,10 @@ def process_post(post):
'poll_question': poll_question,
'poll_options': poll_options,
'poll_multiple': poll_multiple,
'poll_end_date': poll_end_date
'poll_end_date': poll_end_date,
'is_repost': is_repost,
'is_empty_text': is_empty_text,
'letter_count': letter_count,
}
def save_to_database(posts):
@@ -144,6 +193,19 @@ def save_to_database(posts):
new_posts_count = 0
for post in posts:
try:
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
post_text = post.get('text', '')
is_empty_text = post.get('is_empty_text', False)
should_auto_publish = AUTO_PUBLISH
shortname = None
if is_empty_text:
should_auto_publish = False
shortname = EMPTY_POST_SHORTNAME
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
# Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH
should_auto_publish = False
# Добавляем поля is_event, marked_for_publication, shortname и action_number
cursor.execute('''
INSERT INTO posts (
@@ -164,14 +226,33 @@ def save_to_database(posts):
int(post['poll_multiple']),
post['poll_end_date'],
IS_EVENT,
int(AUTO_PUBLISH), # Значение из .env
None, # shortname - пока не используется, устанавливаем NULL
None # action_number - пока не используется, устанавливаем NULL
int(should_auto_publish),
shortname,
None # action_number
))
if cursor.rowcount > 0:
new_posts_count += 1
# Логируем добавление нового поста
log_message(f"Добавлен новый пост: ID {post['vk_post_id']}, Дата: {post['published_at']}, Автопубликация: {'Да' if AUTO_PUBLISH else 'Нет'}")
# Логируем добавление нового поста с указанием причины отключения автопубликации
auto_publish_reason = 'Да'
if not should_auto_publish:
if is_empty_text:
auto_publish_reason = (
f'Нет (пустой текст: {post.get("letter_count", 0)} букв '
f'< {VK_POST_MIN_LETTERS})'
)
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')'
else:
auto_publish_reason = 'Нет'
# Добавляем информацию о репосте в лог
repost_info = ""
if post.get('is_repost', False):
repost_info = ", Репост: Да"
else:
repost_info = ", Репост: Нет"
log_message(f"Добавлен новый пост: ID {post['vk_post_id']}, Дата: {post['published_at']}, Автопубликация: {auto_publish_reason}{repost_info}")
except mysql.connector.Error as err:
error_msg = f"Ошибка при вставке поста {post['vk_post_id']}: {err}"
log_message(error_msg)
@@ -195,7 +276,11 @@ def vk_load_10():
Возвращает количество добавленных постов
"""
# Стартовая информация
start_msg = f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH}"
start_msg = (
f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | "
f"IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH} | "
f"VK_POST_MIN_LETTERS: {VK_POST_MIN_LETTERS}"
)
print(start_msg)
log_message(start_msg)
+473
View File
@@ -0,0 +1,473 @@
import os
import logging
import asyncio
import pymysql
import time
import html
import httpx
from datetime import datetime, timezone
from dotenv import load_dotenv
from formatter import get_event_text
from telegram_relay import bot_api_method_url
# Загрузка переменных окружения
load_dotenv()
# Настройки из переменных окружения
BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
CHANNEL_ID = os.getenv('CHANNEL_ID')
MDB_HOST = os.getenv('MDB_HOST')
MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE')
LOG_FILE = os.getenv('LOG_FILE', 'volk_cat_publisher.log')
# Параметры длины сообщений
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
# Режим публикации без звука
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
# Настройка логирования
logger = logging.getLogger('TG_CAT_post')
logger.setLevel(logging.INFO)
formatter = logging.Formatter('[%(asctime)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
class RetryFileHandler(logging.FileHandler):
def emit(self, record):
for _ in range(5):
try:
super().emit(record)
return
except (IOError, PermissionError):
time.sleep(0.5)
print(f"Failed to write to log file after 5 attempts: {record.msg}")
file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# Класс для обработки RetryAfter (FloodWait 429)
class RetryAfterException(Exception):
"""Исключение для обработки FloodWait (429) от Telegram API"""
def __init__(self, retry_after):
self.retry_after = retry_after
super().__init__(f"RetryAfter: {retry_after}")
def prepare_text(text):
"""Подготовка текстовых полей с экранированием HTML-сущностей"""
if not text:
return ""
# Экранируем специальные символы HTML
text = html.escape(str(text))
return text
async def tg_post_category(httpx_client, category_data):
"""Публикация одной категории"""
try:
# Форматирование текста
title = prepare_text(category_data['TITLE'] or "")
description_raw = category_data['description']
# Формируем базовый текст (название категории)
caption_parts = []
if title:
caption_parts.append(f"<b>{title}</b>")
base_text = "\n".join(caption_parts)
base_text_length = len(base_text)
# Определяем максимальную длину в зависимости от типа сообщения
image_url = category_data['image_url']
has_image = image_url and image_url.strip()
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
# Вычисляем доступную длину для описания категории
available_length = max_length - base_text_length - 3 # -3 для символов переноса строки
if available_length < 0:
available_length = 0
# Получаем обработанный текст с учетом доступной длины
description = get_event_text(description_raw, available_length) if description_raw else ""
# Формируем первоначальный текст
initial_text = f"{base_text}\n\n{description}" if description else base_text
# Логируем детальную информацию о данных для публикации
logger.info(f"=== Детали публикации категории ID {category_data['ID']} ===")
logger.info(f"Длина initial_text: {len(initial_text)} символов")
logger.info(f"Длина base_text: {base_text_length} символов")
logger.info(f"Длина description: {len(description)} символов")
logger.info(f"Максимальная длина (max_length): {max_length} символов")
logger.info(f"Доступная длина для description: {available_length} символов")
logger.info(f"Есть изображение: {has_image}")
if has_image:
logger.info(f"URL изображения: {image_url}")
logger.info(f"Длина URL изображения: {len(image_url)} символов")
logger.info(f"Текст для публикации: {initial_text[:200]}..." if len(initial_text) > 200 else f"Текст для публикации: {initial_text}")
# Публикация сообщения
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
if has_image:
try:
logger.info(f"Попытка отправки фото для категории {category_data['ID']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}")
photo_payload = {
'chat_id': CHANNEL_ID,
'photo': image_url,
'caption': initial_text,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY
}
photo_response = await httpx_client.post(send_photo_url, json=photo_payload)
if photo_response.status_code == 200:
photo_result = photo_response.json()
if photo_result.get('ok'):
message_data = photo_result.get('result', {})
message_id = message_data.get('message_id')
logger.info(f"Фото успешно отправлено для категории {category_data['ID']}, message_id={message_id}")
else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение
error_code = photo_result.get('error_code')
if error_code == 429:
# FloodWait - пробрасываем как RetryAfter
retry_after = photo_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {photo_result.get('description', 'Unknown error')}")
else:
if photo_response.status_code == 429:
# FloodWait
retry_after = 60
try:
error_data = photo_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {photo_response.status_code}: {photo_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при отправке изображения для категории ID {category_data['ID']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" image_url: {image_url}")
logger.error(f" caption_length: {len(initial_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
retry_after = int(error_msg.split(":")[1])
raise Exception(f"RetryAfter:{retry_after}")
logger.warning(f"Не удалось отправить изображение для категории ID {category_data['ID']}: {e}. Отправляем текстовое сообщение.")
logger.error(f"Полная информация об ошибке Telegram при отправке изображения для категории {category_data['ID']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
logger.info(f"Попытка отправки текстового сообщения вместо фото для категории {category_data['ID']}")
try:
text_payload = {
'chat_id': CHANNEL_ID,
'text': initial_text,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY,
'disable_web_page_preview': True
}
text_response = await httpx_client.post(send_message_url, json=text_payload)
if text_response.status_code == 200:
text_result = text_response.json()
if text_result.get('ok'):
message_data = text_result.get('result', {})
message_id = message_data.get('message_id')
logger.info(f"Текстовое сообщение успешно отправлено для категории {category_data['ID']}, message_id={message_id}")
else:
error_code = text_result.get('error_code')
if error_code == 429:
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
else:
if text_response.status_code == 429:
retry_after = 60
try:
error_data = text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для категории ID {category_data['ID']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" text_length: {len(initial_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
raise
else:
logger.info(f"Попытка отправки текстового сообщения для категории {category_data['ID']}: chat_id={CHANNEL_ID}, text_length={len(initial_text)}")
try:
text_payload = {
'chat_id': CHANNEL_ID,
'text': initial_text,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY,
'disable_web_page_preview': True
}
text_response = await httpx_client.post(send_message_url, json=text_payload)
if text_response.status_code == 200:
text_result = text_response.json()
if text_result.get('ok'):
message_data = text_result.get('result', {})
message_id = message_data.get('message_id')
logger.info(f"Текстовое сообщение успешно отправлено для категории {category_data['ID']}, message_id={message_id}")
else:
error_code = text_result.get('error_code')
if error_code == 429:
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
else:
if text_response.status_code == 429:
retry_after = 60
try:
error_data = text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для категории ID {category_data['ID']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" text_length: {len(initial_text)}")
logger.error(f" parse_mode: HTML")
raise
return message_id # Возвращаем только message_id
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при публикации категории ID {category_data['ID']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Контекст категории:")
logger.error(f" ID: {category_data.get('ID')}")
logger.error(f" has_image: {bool(category_data.get('image_url'))}")
logger.error(f" image_url_length: {len(category_data.get('image_url', '') or '')}")
logger.error(f" initial_text_length: {len(initial_text) if 'initial_text' in locals() else 'N/A'}")
return None
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
# Обработка ошибки FloodWait (429)
retry_after = int(error_msg.split(":")[1])
logger.error(f"Получена ошибка FloodWait (429) при публикации категории ID {category_data['ID']}")
logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
logger.error(f"Полная информация об ошибке RetryAfter для категории {category_data['ID']}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {error_msg}")
logger.error(f" retry_after: {retry_after}")
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации категории ID {category_data['ID']}: {e}")
logger.error(f"Полная информация об ошибке Telegram для категории {category_data['ID']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
return None
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации категории ID {category_data['ID']}: {e}")
return None
async def tg_post_category_by_id(id_category):
"""Публикация категории по ID"""
logger.info(f"Запуск публикации для категории ID {id_category}")
conn = None
try:
conn = pymysql.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cursor:
cursor.execute("""
SELECT AUTO_ID, ID, TITLE, description, image_url, tg_id
FROM categories
WHERE ID = %s
AND marked_for_publication = True
AND is_tg_published = False
""", (id_category,))
category = cursor.fetchone()
if not category:
logger.info(f"Категория ID {id_category} не найдена или уже опубликована")
return
old_tg_id = category.get('tg_id') # Сохраняем старый ID сообщения
# Создаем httpx клиент с отключенным HTTP/2
httpx_client = httpx.AsyncClient(
http2=False,
timeout=20.0,
follow_redirects=True
)
try:
tg_id = await tg_post_category(httpx_client, category) # Получаем новый message_id
finally:
await httpx_client.aclose()
if tg_id:
# Используем UTC время для записи в базу данных
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("""
UPDATE categories
SET is_tg_published = True,
tg_id = %s,
tg_published_date = %s
WHERE ID = %s
""", (tg_id, current_time_utc, id_category))
conn.commit()
logger.info(f"Категория ID {category['ID']} успешно опубликована")
else:
logger.error(f"Не удалось опубликовать категорию ID {id_category}")
except Exception as e:
# Проверяем, не RetryAfter ли это
error_msg = str(e)
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')):
retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_category_by_id для категории ID {id_category}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {error_msg}")
logger.error(f" retry_after: {retry_after}")
logger.error(f" Полное представление: {repr(e)}")
raise RetryAfterException(retry_after)
# Если это не RetryAfter, обрабатываем как обычную ошибку
if "RetryAfter" not in error_msg:
logger.error(f"Ошибка при публикации: {e}")
logger.error(f"Полная информация об ошибке в tg_post_category_by_id для категории ID {id_category}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
except Exception as e:
logger.error(f"Ошибка при публикации: {e}")
logger.error(f"Полная информация об ошибке в tg_post_category_by_id для категории ID {id_category}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
finally:
if conn:
conn.close()
async def tg_post_all_categories():
"""Публикация всех неопубликованных категорий (максимум 5 за один вызов)"""
logger.info("Запуск скрипта публикации всех категорий")
conn = None
try:
conn = pymysql.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
with conn.cursor() as cursor:
cursor.execute("""
SELECT ID
FROM categories
WHERE marked_for_publication = True
AND is_tg_published = False
LIMIT 5
""")
categories = cursor.fetchall()
category_count = 0
for category in categories:
try:
await tg_post_category_by_id(category['ID'])
category_count += 1
# Задержка между публикациями разных категорий
if category_count < len(categories):
await asyncio.sleep(2)
except RetryAfterException as e:
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_all_categories для категории ID {category['ID']}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" retry_after: {e.retry_after}")
logger.error(f" Полное представление: {repr(e)}")
break
except Exception as e:
logger.error(f"Ошибка при публикации категории ID {category['ID']}: {e}")
logger.error(f"Полная информация об ошибке в tg_post_all_categories для категории ID {category['ID']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
# Продолжаем публикацию следующих категорий, несмотря на ошибку
except Exception as e:
logger.error(f"Ошибка при публикации категории ID {category['ID']}: {e}")
logger.error(f"Полная информация об ошибке в tg_post_all_categories для категории ID {category['ID']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
# Продолжаем публикацию следующих категорий, несмотря на ошибку
logger.info(f"Опубликовано категорий в этом запуске: {category_count}")
except Exception as e:
logger.error(f"Ошибка при публикации: {e}")
finally:
if conn:
conn.close()
logger.info("Завершение работы скрипта")
if __name__ == "__main__":
asyncio.run(tg_post_all_categories())
+566
View File
@@ -0,0 +1,566 @@
import os
import json
import time
import traceback
import socket
import re
from datetime import datetime
from urllib.request import urlopen
from urllib.error import URLError, HTTPError
from dotenv import load_dotenv
import mysql.connector
from mysql.connector import Error
from ensure_db import ensure_database_structure
# Загрузка переменных окружения
load_dotenv()
VOLK_CATEGORY_LIST = os.getenv('VOLK_CATEGORY_LIST')
VOLK_CATEGORY_DESC_PREFIX = os.getenv('VOLK_CATEGORY_DESC_PREFIX')
VOLK_EVENT_LIST_PREFIX = os.getenv('VOLK_EVENT_LIST_PREFIX')
VOLK_API_VERSION = os.getenv('VOLK_API_VERSION')
MDB_HOST = os.getenv('MDB_HOST')
MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE')
LOG_FILE = os.getenv('LOG_FILE')
AUTO_PUB_EVT = os.getenv('AUTO_PUB_EVT', 'false').lower() in ('true', '1', 'yes', 'on')
def log_message(message, category="VOLK_loader"):
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [{category}] {message}\n"
# До 5 попыток записи с увеличением задержки
for attempt in range(5):
try:
with open(LOG_FILE, 'a', encoding='utf-8') as f:
f.write(log_entry)
return True
except (IOError, OSError) as e:
if attempt < 4: # Не последняя попытка
time.sleep(0.4) # Задержка 400ms между попытками
else:
# После 5 неудачных попыток просто возвращаем False
# Не используем print() чтобы избежать реентерабельных вызовов с gunicorn
return False
return False
def safe_log_direct(message, category="VOLK_loader"):
"""Безопасная функция для прямой записи в лог-файл без обработки исключений
Используется в критических ситуациях, когда обычный log_message может не сработать"""
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] [{category}] {message}\n"
# Прямая запись без контекстного менеджера для максимальной надежности
f = open(LOG_FILE, 'a', encoding='utf-8')
try:
f.write(log_entry)
f.flush() # Принудительно сбрасываем буфер
finally:
f.close()
except:
# Игнорируем все исключения - эта функция должна быть максимально безопасной
pass
def load_categories():
"""Экспортируемая функция для загрузки категорий из JSON в базу данных"""
inserted_count = 0
updated_count = 0
try:
log_message("Начало работы")
# Проверка наличия URL
if not VOLK_CATEGORY_LIST:
error_msg = "Переменная окружения VOLK_CATEGORY_LIST не установлена"
log_message(error_msg, "ERROR")
return
# Чтение JSON из URL
log_message(f"Чтение данных из {VOLK_CATEGORY_LIST}")
with urlopen(VOLK_CATEGORY_LIST) as response:
data = json.loads(response.read().decode())
# Проверка версии API
api_version = data.get('api_version')
if api_version != VOLK_API_VERSION:
error_msg = f"Несовпадение версии API: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Обработка остановлена."
log_message(error_msg, "ERROR")
return
log_message(f"Версия API соответствует: {api_version}")
# Проверка наличия блока data
if 'data' not in data:
error_msg = "В JSON отсутствует блок 'data'"
log_message(error_msg, "ERROR")
return
categories_data = data['data']
log_message(f"Получено {len(categories_data)} записей для обработки")
# Подключение к базе данных
connection = mysql.connector.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE
)
# Установка временной зоны соединения в UTC
cursor_temp = connection.cursor()
cursor_temp.execute("SET time_zone = '+00:00'")
cursor_temp.close()
cursor = connection.cursor(dictionary=True)
# Проверка структуры БД
log_message("Проверка структуры базы данных")
ensure_database_structure()
# Список категорий для последующей загрузки описаний
loaded_categories = []
for item in categories_data:
# Проверка обязательного поля ID
if 'id' not in item or not item['id']:
log_message(f"Пропущена запись без id или с пустым id: {item}", "WARNING")
continue
# Подготовка значений
category_id = item['id']
# TITLE может отсутствовать или быть пустым - в этом случае используем пустую строку
title = item.get('title', '') or ''
# Проверка существующей записи
cursor.execute(
"SELECT AUTO_ID, ID, TITLE FROM categories WHERE ID = %s",
(category_id,)
)
existing = cursor.fetchone()
if not existing:
# Вставка новой записи
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
marked_for_publication = 1 if AUTO_PUB_EVT else 0
cursor.execute(
"INSERT INTO categories (ID, TITLE, description, marked_for_publication) VALUES (%s, %s, %s, %s)",
(category_id, title, None, marked_for_publication)
)
inserted_count += 1
pub_status = "для публикации" if AUTO_PUB_EVT else "не для публикации"
log_message(f"Добавление: Категория {category_id}, Название: {title if title else '(пусто)'}, Статус: {pub_status}")
else:
# Обновление существующей записи (всегда обновляем TITLE, даже если оно пустое)
if existing['TITLE'] != title:
cursor.execute(
"UPDATE categories SET TITLE = %s WHERE ID = %s",
(title, category_id)
)
updated_count += 1
log_message(f"Обновление: Категория {category_id}, Новое название: {title if title else '(пусто)'}")
else:
log_message(f"Категория {category_id} без изменений")
connection.commit()
loaded_categories.append(category_id)
log_message(f"Загружено категорий: {len(loaded_categories)}. Добавлено записей: {inserted_count}. Обновлено записей: {updated_count}.")
# Загрузка описаний категорий
if VOLK_CATEGORY_DESC_PREFIX and loaded_categories:
log_message(f"Начало загрузки описаний для {len(loaded_categories)} категорий")
desc_updated_count = 0
for category_id in loaded_categories:
try:
desc_url = VOLK_CATEGORY_DESC_PREFIX + category_id
log_message(f"Запрос описания для категории {category_id} из {desc_url}")
desc_response = None
try:
# Добавляем таймаут для избежания зависаний (30 секунд)
from urllib.request import Request
req = Request(desc_url, headers={'User-Agent': 'Mozilla/5.0 (Telefaust)'})
log_message(f"request response = {req}", "DEBUG")
response = urlopen(req, timeout=30)
except (URLError, HTTPError, OSError, socket.gaierror, socket.timeout, ConnectionError) as e:
# Используем безопасное логирование для критических ошибок
safe_log_direct("ПЕРЕХВАЧЕНА СЕТЕВАЯ ОШИБКА urlopen", "ERROR")
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
try:
traceback_str = traceback.format_exc()
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
except:
pass
# Также пробуем обычное логирование
try:
error_msg = f"Ошибка сетевого подключения при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR")
except:
pass
# Пропускаем эту категорию и продолжаем обработку
continue
except Exception as e:
# Перехватываем любые другие исключения, которые могут возникнуть при urlopen
safe_log_direct("ПЕРЕХВАЧЕНА НЕОЖИДАННАЯ ОШИБКА urlopen", "ERROR")
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
try:
traceback_str = traceback.format_exc()
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
except:
pass
# Также пробуем обычное логирование
try:
error_msg = f"Неожиданная ошибка при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR")
except:
pass
# Пропускаем эту категорию и продолжаем обработку
continue
try:
with response:
# Явно указываем кодировку UTF-8 при декодировании
raw_data = response.read()
try:
decoded_data = raw_data.decode('utf-8')
except UnicodeDecodeError as e:
error_msg = f"Ошибка декодирования ответа для категории {category_id}: {e}. Попытка декодирования как latin-1."
log_message(error_msg, "WARNING")
decoded_data = raw_data.decode('latin-1')
try:
desc_response = json.loads(decoded_data)
except json.JSONDecodeError as e:
error_msg = f"Ошибка парсинга JSON для категории {category_id}: {e}. Первые 500 символов ответа: {decoded_data[:500]}"
log_message(error_msg, "ERROR")
# Пропускаем эту категорию и продолжаем обработку
continue
except Exception as e:
error_msg = f"Ошибка при чтении ответа для категории {category_id}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR")
traceback_str = traceback.format_exc()
log_message(f"Трассировка ошибки чтения для категории {category_id}:\n{traceback_str}", "ERROR")
# Пропускаем эту категорию и продолжаем обработку
continue
# Проверяем, что desc_response был успешно получен
if desc_response is None:
error_msg = f"Не удалось получить ответ для категории {category_id}"
log_message(error_msg, "ERROR")
continue
# Проверка версии API
api_version = desc_response.get('api_version')
if api_version != VOLK_API_VERSION:
error_msg = f"Несовпадение версии API при загрузке описания категории {category_id}: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Пропуск категории."
log_message(error_msg, "ERROR")
# Пропускаем эту категорию и продолжаем обработку
continue
# Проверка наличия блока data
if 'data' not in desc_response:
error_msg = f"В JSON ответе для категории {category_id} отсутствует блок 'data'. Пропуск категории."
log_message(error_msg, "ERROR")
# Пропускаем эту категорию и продолжаем обработку
continue
desc_data = desc_response['data']
# Проверка типа desc_data - должен быть словарём
if not isinstance(desc_data, dict):
error_msg = f"Блок 'data' для категории {category_id} не является словарём (тип: {type(desc_data)}). Пропуск категории."
log_message(error_msg, "ERROR")
# Пропускаем эту категорию и продолжаем обработку
continue
# Получаем description из блока data
description = desc_data.get('description')
# Если description пустое, None или отсутствует, обнуляем поле в БД
if not description:
description = None
# Обработка маркдаун-ссылок на изображения в конце описания
image_url = None
if description:
# Паттерн для поиска маркдаун-ссылки вида ![](url "title") или ![](url)
# Ищем в конце текста (с учетом пробелов и переносов строк)
# Паттерн: ![](url) или ![](url "title")
# URL может содержать различные символы, включая скобки в URL-кодированном виде
markdown_image_pattern = r'!\[\]\(([^)]+?)(?:\s+"[^"]*")?\)\s*$'
match = re.search(markdown_image_pattern, description, re.MULTILINE)
if match:
image_url = match.group(1).strip()
# Удаляем маркдаун-ссылку из описания (включая пробелы и переносы строк перед ней)
description = re.sub(r'\s*!\[\]\([^)]+?(?:\s+"[^"]*")?\)\s*$', '', description, flags=re.MULTILINE).rstrip()
# Если после удаления описание стало пустым, обнуляем его
if not description.strip():
description = None
log_message(f"Извлечен URL изображения для категории {category_id}: {image_url}")
# Обновляем описание и image_url в базе данных
cursor.execute(
"UPDATE categories SET description = %s, image_url = %s WHERE ID = %s",
(description, image_url, category_id)
)
connection.commit()
desc_updated_count += 1
if description:
log_message(f"Обновлено описание для категории {category_id}")
else:
log_message(f"Описание для категории {category_id} обнулено (пустое или отсутствует)")
except Exception as e:
error_msg = f"Ошибка при загрузке описания для категории {category_id}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR")
traceback_str = traceback.format_exc()
log_message(f"Трассировка ошибки для категории {category_id}:\n{traceback_str}", "ERROR")
# Пропускаем эту категорию и продолжаем обработку остальных
continue
log_message(f"Завершение загрузки описаний. Обновлено описаний: {desc_updated_count}.")
log_message(f"Завершение работы. Добавлено записей: {inserted_count}. Обновлено записей: {updated_count}.")
except Error as e:
error_msg = f"Ошибка базы данных: {e}"
log_message(error_msg, "ERROR")
if 'connection' in locals() and connection.is_connected():
connection.rollback()
except Exception as e:
error_msg = f"Общая ошибка: {e}"
log_message(error_msg, "ERROR")
finally:
if 'connection' in locals() and connection.is_connected():
cursor.close()
connection.close()
def load_events_by_category(category_id):
"""Экспортируемая функция для загрузки событий категории из JSON в базу данных"""
inserted_count = 0
updated_count = 0
try:
log_message(f"Начало загрузки событий для категории {category_id}")
# Проверка наличия URL префикса
if not VOLK_EVENT_LIST_PREFIX:
error_msg = "Переменная окружения VOLK_EVENT_LIST_PREFIX не установлена"
log_message(error_msg, "ERROR")
return
# Проверка наличия category_id
if not category_id:
error_msg = "ID категории не указан"
log_message(error_msg, "ERROR")
return
# Формирование URL для запроса
event_url = VOLK_EVENT_LIST_PREFIX + category_id
log_message(f"Чтение данных из {event_url}")
# Чтение JSON из URL
with urlopen(event_url) as response:
data = json.loads(response.read().decode())
# Проверка версии API
api_version = data.get('api_version')
if api_version != VOLK_API_VERSION:
error_msg = f"Несовпадение версии API: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Обработка остановлена."
log_message(error_msg, "ERROR")
return
log_message(f"Версия API соответствует: {api_version}")
# Проверка наличия блока data
if 'data' not in data:
error_msg = "В JSON отсутствует блок 'data'"
log_message(error_msg, "ERROR")
return
events_data = data['data']
log_message(f"Получено {len(events_data)} событий для обработки")
# Подключение к базе данных
connection = mysql.connector.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE
)
# Установка временной зоны соединения в UTC
cursor_temp = connection.cursor()
cursor_temp.execute("SET time_zone = '+00:00'")
cursor_temp.close()
cursor = connection.cursor(dictionary=True)
# Проверка структуры БД
log_message("Проверка структуры базы данных")
ensure_database_structure()
# Получаем максимальный number для всей таблицы, чтобы продолжить нумерацию
cursor.execute(
"SELECT MAX(number) as max_number FROM events"
)
result = cursor.fetchone()
max_number = result['max_number'] or 0
start_number = max_number + 1
# Обработка событий
current_number = start_number
for item in events_data:
# Проверка обязательного поля id
if 'id' not in item or item['id'] is None:
log_message(f"Пропущено событие без id: {item}", "WARNING")
continue
# Проверка обязательного поля title
if 'title' not in item or not item['title']:
log_message(f"Пропущено событие без title (id: {item.get('id', 'N/A')}): {item}", "WARNING")
continue
# Подготовка значений с маппингом полей
# Преобразуем id в число, если это необходимо
try:
id_event = int(item['id']) # Уникальный идентификатор события из JSON
except (ValueError, TypeError):
log_message(f"Пропущено событие с некорректным id (не число): {item.get('id', 'N/A')}", "WARNING")
continue
name = item.get('title', '')
about = item.get('announce', '') or ''
authors = item.get('authors', '') or ''
about_social_picture = item.get('image', '') or ''
unit_name = category_id
# Проверка существующей записи по id_event (уникальный идентификатор)
cursor.execute(
"SELECT id, id_event, name, authors, about, about_social_picture FROM events WHERE id_event = %s",
(id_event,)
)
existing = cursor.fetchone()
if not existing:
# Вставка новой записи
# Используем id из JSON для id_event, а для number используем автоинкремент
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
marked_to_publication = 1 if AUTO_PUB_EVT else 0
cursor.execute(
"""INSERT INTO events (id_event, number, unit_name, name, about, about_social_picture, authors, marked_to_publication)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(id_event, current_number, unit_name, name, about, about_social_picture, authors, marked_to_publication)
)
inserted_count += 1
pub_status = "для публикации" if AUTO_PUB_EVT else "не для публикации"
log_message(f"Добавление: Событие '{name}', id_event: {id_event}, номер: {current_number}, категория: {category_id}, Статус: {pub_status}")
current_number += 1
else:
# Обновление существующей записи
needs_update = False
update_fields = []
if existing['name'] != name:
needs_update = True
update_fields.append('name')
if existing['authors'] != authors:
needs_update = True
update_fields.append('authors')
if existing['about'] != about:
needs_update = True
update_fields.append('about')
if existing['about_social_picture'] != about_social_picture:
needs_update = True
update_fields.append('about_social_picture')
if needs_update:
cursor.execute(
"""UPDATE events SET name = %s, authors = %s, about = %s, about_social_picture = %s
WHERE id_event = %s""",
(name, authors, about, about_social_picture, id_event)
)
updated_count += 1
log_message(f"Обновление: Событие id_event={id_event} '{name}', обновлены поля: {', '.join(update_fields)}")
else:
log_message(f"Событие id_event={id_event} '{name}' без изменений")
connection.commit()
log_message(f"Завершение работы. Добавлено событий: {inserted_count}. Обновлено событий: {updated_count}.")
except Error as e:
error_msg = f"Ошибка базы данных: {e}"
log_message(error_msg, "ERROR")
if 'connection' in locals() and connection.is_connected():
connection.rollback()
except Exception as e:
error_msg = f"Общая ошибка: {e}"
log_message(error_msg, "ERROR")
finally:
if 'connection' in locals() and connection.is_connected():
cursor.close()
connection.close()
def load_all_events():
"""Экспортируемая функция для загрузки всех событий по всем категориям"""
try:
log_message("Начало загрузки всех событий по всем категориям")
# Подключение к базе данных
connection = mysql.connector.connect(
host=MDB_HOST,
user=MDB_USER,
password=MDB_PW,
database=MDBASE
)
cursor = connection.cursor(dictionary=True)
# Получаем список всех категорий
cursor.execute("SELECT ID FROM categories ORDER BY ID")
categories = cursor.fetchall()
if not categories:
log_message("В базе данных нет категорий для загрузки событий", "WARNING")
cursor.close()
connection.close()
return
log_message(f"Найдено {len(categories)} категорий для обработки")
# Закрываем соединение перед вызовами load_events_by_category
# (так как она создает свое соединение)
cursor.close()
connection.close()
# Загружаем события для каждой категории
processed_count = 0
for category in categories:
category_id = category['ID']
try:
log_message(f"Обработка категории {category_id} ({processed_count + 1}/{len(categories)})")
load_events_by_category(category_id)
processed_count += 1
except Exception as e:
error_msg = f"Ошибка при загрузке событий для категории {category_id}: {e}"
log_message(error_msg, "ERROR")
# Продолжаем обработку других категорий
continue
log_message(f"Завершение загрузки всех событий. Обработано категорий: {processed_count}/{len(categories)}")
except Error as e:
error_msg = f"Ошибка базы данных при загрузке всех событий: {e}"
log_message(error_msg, "ERROR")
except Exception as e:
error_msg = f"Общая ошибка при загрузке всех событий: {e}"
log_message(error_msg, "ERROR")
if __name__ == "__main__":
load_all_events()
+29 -1
View File
@@ -1,4 +1,32 @@
from tg_mainbot import app # Импортируйте ваше приложение
import fcntl
import hashlib
import os
from tg_mainbot import WEBHOOK_SECRET, WEBHOOK_URL, app, setup_webhook
def _ensure_webhook():
"""
Один раз на старте gunicorn регистрируем webhook с secret_token.
Блокирующий flock — без гонки 4 воркеров и без 429.
"""
digest = hashlib.sha256(
f"{WEBHOOK_URL}|{WEBHOOK_SECRET or ''}|allowed_v2".encode("utf-8")
).hexdigest()[:16]
marker_path = f"/tmp/zilant_webhook_ok_{digest}"
lock_path = "/tmp/zilant_webhook_setup.lock"
with open(lock_path, "w") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
if os.path.exists(marker_path):
return
setup_webhook()
with open(marker_path, "w") as marker:
marker.write("ok\n")
_ensure_webhook()
if __name__ == "__main__":
setup_webhook()
app.run()
-4
View File
@@ -1,4 +0,0 @@
from evtg_mainbot import app # Импортируйте ваше приложение
if __name__ == "__main__":
app.run()
+71 -7
View File
@@ -17,6 +17,8 @@ MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE')
LOG_FILE = os.getenv('LOG_FILE')
PROCESS_EVENT_UPDATES = os.getenv('PROCESS_EVENT_UPDATES', 'false').lower() in ('true', '1', 'yes', 'on')
AUTO_PUB_EVT = os.getenv('AUTO_PUB_EVT', 'false').lower() in ('true', '1', 'yes', 'on')
def log_message(message, category="JSON_loader"):
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
@@ -52,6 +54,41 @@ def parse_datetime(dt_str):
return dt
def create_event_post_links(cursor, event_id, unit_name):
"""Создание связей между событием и постами по auto_unit"""
if not unit_name:
return
try:
# Поиск постов с соответствующим auto_unit
cursor.execute(
"SELECT id FROM posts WHERE auto_unit = %s",
(unit_name,)
)
posts = cursor.fetchall()
for post in posts:
post_id = post['id']
# Проверяем, существует ли уже связь
cursor.execute(
"SELECT id FROM interlinks WHERE post_id = %s AND event_id = %s",
(post_id, event_id)
)
if not cursor.fetchone():
# Создаем новую связь с auto_added = 1
cursor.execute(
"INSERT INTO interlinks (post_id, event_id, created_at, auto_added) VALUES (%s, %s, %s, 1)",
(post_id, event_id, datetime.now())
)
log_message(f"Создана автоматическая связь: событие {event_id} <-> пост {post_id} (unit: {unit_name})")
else:
log_message(f"Связь уже существует: событие {event_id} <-> пост {post_id}")
except Error as e:
log_message(f"Ошибка при создании связей для события {event_id}: {e}", "ERROR")
def process_json_data():
inserted_count = 0
updated_count = 0
@@ -115,6 +152,9 @@ def process_json_data():
# Вычисление is_visible
is_visible = bool(name and about and not is_canceled and not denied)
# Определение marked_to_publication для новой записи
marked_to_publication = 1 if (is_visible and AUTO_PUB_EVT) else 0
# Проверка существующей записи
cursor.execute(
@@ -134,10 +174,16 @@ def process_json_data():
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(id_event, number, unit_name, name, about, about_social_picture,
modified, added, tags, is_canceled, accepted, denied,
is_visible, 0, 0, None, None, None)
is_visible, 0, marked_to_publication, None, None, None)
)
inserted_count += 1
log_message(f"Добавление: Запись {id_event}, Created: {added}")
# Создание связей с постами для новой записи
cursor.execute("SELECT id FROM events WHERE id_event = %s AND number = %s", (id_event, number))
event_record = cursor.fetchone()
if event_record:
create_event_post_links(cursor, event_record['id'], unit_name)
else:
# Преобразуем дату из БД к наивному datetime для сравнения
existing_modified = existing['modified']
@@ -146,30 +192,48 @@ def process_json_data():
existing_modified = existing_modified.replace(tzinfo=None)
# Проверка расхождения во времени
time_diff = abs(modified - existing_modified) if modified and existing_modified else timedelta(minutes=2)
time_diff = abs(modified - existing_modified) if modified and existing_modified else timedelta(seconds=2)
if time_diff > timedelta(minutes=1):
if time_diff > timedelta(seconds=1):
# Проверка изменения только по полю name
name_changed = existing['name'] != name
is_posted_tg = 0 if name_changed else existing['is_posted_tg']
# Сброс флага is_posted_tg только если PROCESS_EVENT_UPDATES = true
is_posted_tg = 0 if (name_changed and PROCESS_EVENT_UPDATES) else existing['is_posted_tg']
# Обновление marked_to_publication на основе новых данных
new_marked_to_publication = 1 if (is_visible and AUTO_PUB_EVT) else 0
# log_message(f"PROCESS_EVENT_UPDATES: {PROCESS_EVENT_UPDATES}")
# log_message(f"name_changed: {name_changed}")
# Обновление записи
cursor.execute(
"""UPDATE events SET
unit_name = %s, name = %s, about = %s, about_social_picture = %s,
modified = %s, added = %s, tags = %s, is_canceled = %s,
accepted = %s, denied = %s, is_visible = %s, is_posted_tg = %s
accepted = %s, denied = %s, is_visible = %s, is_posted_tg = %s,
marked_to_publication = %s
WHERE id_event = %s AND number = %s""",
(unit_name, name, about, about_social_picture, modified, added,
tags, is_canceled, accepted, denied, is_visible, is_posted_tg,
id_event, number)
new_marked_to_publication, id_event, number)
)
updated_count += 1
# Создание связей с постами для обновленной записи
cursor.execute("SELECT id FROM events WHERE id_event = %s AND number = %s", (id_event, number))
event_record = cursor.fetchone()
if event_record:
create_event_post_links(cursor, event_record['id'], unit_name)
# Формирование сообщения для лога
update_msg = f"Обновление: Запись {id_event}, Updated: {modified}"
if name_changed:
if name_changed and PROCESS_EVENT_UPDATES:
update_msg += ". Флаг публикации сброшен."
elif name_changed and not PROCESS_EVENT_UPDATES:
update_msg += ". Имя изменено, но флаг публикации сохранен (PROCESS_EVENT_UPDATES=false)."
if new_marked_to_publication:
update_msg += f" marked_to_publication установлен в 1 (is_visible={is_visible}, AUTO_PUB_EVT={AUTO_PUB_EVT})."
log_message(update_msg)
connection.commit()