Compare commits

..
113 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
47 changed files with 6807 additions and 852 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**.
+69 -27
View File
@@ -1,15 +1,30 @@
# Общие настройки # Общие настройки
SECRET_KEY=24af5f41dba9d8cc0dbc1841660da0b14d7df86ab5e0ed973cdd3bc2dbac23f6 SECRET_KEY=24af5f41dba9d8cc0dbc1841660da0b14d7df86ab5e0ed973cdd3bc2dbac23f6
LOG_FILE = /opt/testbot-git/logs/testzilantposter.log LOG_FILE = /opt/testbot-git/logs/testzilantposter.log
# LOG_FILE = /opt/volk/logs/volkposter.log
# WORKMODE = "ZILANT" WORKMODE = "ZILANT"
WORKMODE = "VOLK" # 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 Responder-bot
WEBHOOK_URL = "https://bot.aabpro.ru/testbot" # 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_PORT = 5005
WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c" WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
@@ -23,31 +38,53 @@ WEBCTRL_URL = "https://bot.aabpro.ru/testedit"
WEBCTRL_PORT = 5006 WEBCTRL_PORT = 5006
WEBCTRL_PREFIX = "/testedit" WEBCTRL_PREFIX = "/testedit"
# WEBCTRL_URL = "https://bot.aabpro.ru/volk"
# WEBCTRL_PORT = 5009
# WEBCTRL_PREFIX = "/volk"
# логин и пароль по умолчанию для первого запуска веб-контроллера # логин и пароль по умолчанию для первого запуска веб-контроллера
DEFNM=admin DEFNM=admin
DEFPW=Z1l@ntAdm1n23 DEFPW=Z1l@ntAdm1n23
# VK access # VK access
# Zilant group # Zilant group
# VK_GROUP_ID = -3342146 VK_GROUP_ID = -3342146
# VOLK group # VOLK group
VK_GROUP_ID = -58475527 # VK_GROUP_ID = -58475527
VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012e946b952a8' VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012e946b952a8'
VK_API_VERSION = '5.131' VK_API_VERSION = '5.131'
# MySQL database # MySQL database
# MDBASE = 'testposts' MDBASE = 'testposts'
# MDBASE = 'tempzilant' # debug Zilant database # MDBASE = 'tempzilant' # debug Zilant database
MDBASE = 'volk_test' # debug VOLK database # Zilant 2026 database
# MDBASE = 'zilant_2026'
# MDBASE = 'volk_test' # debug VOLK database
# MDBASE = 'volkdata' # VOLK production database
MDB_HOST = 'localhost' MDB_HOST = 'localhost'
# MDB_USER = 'zilantposter'
# MDB_PW = 'Z1l@ntp08t3r'
MDB_USER = 'testuser' MDB_USER = 'testuser'
MDB_PW = 'T38tU83r69' MDB_PW = 'T38tU83r69'
# MDB_USER = 'volkuser'
# MDB_PW = 'V0lK@dM1n23'
# Poster bot # Poster bot
# Debug bot # Debug bot @posto_1638_bot
# BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U" # BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
POSTER_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_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
RESPONDER_BOT_NAME = "posto_1638_bot"
# Zilant info bot # Zilant info bot
# BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU" # BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
# #
@@ -58,7 +95,7 @@ RESPONDER_BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
# POSTER_BOT_TOKEN = "8115998792:AAHamcNBlTgTXG0Birc1gNbZ5B7z4jMEdik" # POSTER_BOT_TOKEN = "8115998792:AAHamcNBlTgTXG0Birc1gNbZ5B7z4jMEdik"
# RESPONDER_BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU" # RESPONDER_BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
# RESPONDER_BOT_NAME = "Zilant_info_bot" # RESPONDER_BOT_NAME = "Zilant_info_bot"
RESPONDER_BOT_NAME = "posto_1638_bot" # RESPONDER_BOT_NAME = "posto_1638_bot"
# Parameters # Parameters
# #
@@ -67,6 +104,8 @@ RESPONDER_BOT_NAME = "posto_1638_bot"
ORG_MESSAGE_TAG = "#оргинфаЗиланткона" ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
# tag to detect posts to not repost from VK # tag to detect posts to not repost from VK
VK_NO_REPOST_TAG = "#окоЗиланта" VK_NO_REPOST_TAG = "#окоЗиланта"
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
VK_POST_MIN_LETTERS = 5
# Minutes delay before publishing an event # Minutes delay before publishing an event
@@ -83,6 +122,9 @@ EVENT_POST_DELAY = 1440
CHANNEL_ID = "@posto1638" CHANNEL_ID = "@posto1638"
# Zilant production channel # Zilant production channel
# CHANNEL_ID = "@zilantkon" # CHANNEL_ID = "@zilantkon"
# VOLK production channel
# CHANNEL_ID = "@VOLKinformer"
MAX_CAPTION_LENGTH = 1000 MAX_CAPTION_LENGTH = 1000
MAX_TEXT_LENGTH = 4000 MAX_TEXT_LENGTH = 4000
@@ -91,30 +133,30 @@ MAX_TEXT_LENGTH = 4000
# #
# add event number and trailing / # add event number and trailing /
# #
PZK_PREFIX = "https://reg.konvent.ru/zk2025/draft/plan/api/add/" PZK_PREFIX = "https://reg.konvent.ru/zk2026/draft/plan/api/add/"
EVT_PREFIX = "https://reg.konvent.ru/zk2025/events/actions/api/" EVT_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/api/"
DESC_PREFIX = "https://reg.konvent.ru/zk2025/events/actions/" DESC_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/"
# VOLK mode API links # VOLK mode API links
# add category ID (volk24_larp for ex.) # add category ID (volk24_larp for ex.)
VOLK_API_VERSION = "1.0" # VOLK_API_VERSION = "1.0"
VOLK_CATEGORY_LIST = "https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event" # 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_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=" # 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 # Event status
IS_EVENT = true IS_EVENT = true
AUTO_PUBLISH = false AUTO_PUBLISH = true
AUTO_PUB_EVT = false AUTO_PUB_EVT = true
PROCESS_EVENT_UPDATES = false PROCESS_EVENT_UPDATES = false
PUBLISH_SILENTLY = true PUBLISH_SILENTLY = true
USE_SUBSCRIPTION_BOT = false USE_SUBSCRIPTION_BOT = true
LOG_DEBUG_DATA = false LOG_DEBUG_DATA = false
# RouterAI (генерация shortname)
RA_KEY = "sk-7pJxRlbYWUGMNX9y2mHzi2soKDIsZPzp"
RA_MODEL = "moonshotai/kimi-k2-thinking"
# OpenRouter # OpenRouter (не используется, оставлен для справки)
OR_KEY = "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" # OR_KEY = "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"
OR_MODEL_NAME = "qwen/qwen3-vl-235b-a22b-instruct" # OR_MODEL_NAME = "anthropic/claude-sonnet-5"
# OR_MODEL_NAME = "deepseek/deepseek-chat-v3.1"
# OR_MODEL_NAME = "deepseek/deepseek-r1-0528:free"
# OR_MODEL_NAME = "qwen/qwen3-coder:free"
+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/
+67 -3
View File
@@ -1,4 +1,68 @@
# Zilant2025 # Zilant / VOLK — Telegram poster и подписки
Zilantkon bot testarea **Версия:** 1.1.4 ([`VERSION`](VERSION))
test update #8
Автоматическая публикация постов и событий в 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
```
+2 -203
View File
@@ -1,204 +1,3 @@
# Отправка сообщений через Telegram бота # Перенесено
## Описание Документация: [`docs/telegram-messaging.md`](docs/telegram-messaging.md)
В проект добавлена возможность отправки сообщений пользователям 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`:
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
- `WEBHOOK_URL` - полный URL бота (например, https://bot.aabpro.ru/testbot)
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
Веб-интерфейс автоматически читает URL и порт бота из переменных окружения и использует их для отправки запросов.
## Использование через веб-интерфейс
В заголовке страницы "Управление постами VK и событиями" добавлены два поля:
- **ID пользователя** - числовой ID пользователя в Telegram
- **Сообщение** - текст сообщения для отправки
- **Кнопка "Отправить"** - отправляет сообщение через бота
### Особенности интерфейса:
- Поля проверяются на заполненность перед отправкой
- ID пользователя проверяется на корректность (должен быть числом)
- Поддержка отправки по клавише Enter
- Индикатор загрузки во время отправки
- Автоматическая очистка полей после успешной отправки
## Использование через API
### Endpoint
```
POST /send_message
POST {WEBHOOK_PATH}/send_message
```
Где `WEBHOOK_PATH` - путь из переменной окружения `WEBHOOK_URL` (например, `/testbot`).
### Примеры URL:
- **Локально:** `http://localhost:5005/send_message`
- **Через reverse proxy:** `https://bot.aabpro.ru/testbot/send_message`
### Заголовки запроса
```
Content-Type: application/json
X-API-Secret-Token: <секретный_токен>
```
### Параметры запроса
```json
{
"user_id": 123456789,
"message": "Текст сообщения"
}
```
### Аутентификация
API защищен секретным токеном, который передается в заголовке `X-API-Secret-Token`. Токен берется из переменной окружения `WEBHOOK_SECRET`.
### Ответы
**Успешная отправка (200):**
```json
{
"success": true,
"message": "Сообщение отправлено успешно"
}
```
**Ошибка (400):**
```json
{
"success": false,
"error": "Описание ошибки"
}
```
### Пример использования из Python
```python
import requests
def send_telegram_message(user_id, message):
# Используем внешний URL через reverse proxy
url = "https://bot.aabpro.ru/testbot/send_message"
data = {
"user_id": user_id,
"message": message
}
headers = {
'Content-Type': 'application/json',
'X-API-Secret-Token': 'ваш_секретный_токен'
}
response = requests.post(url, json=data, headers=headers)
result = response.json()
if result["success"]:
print("Сообщение отправлено успешно!")
else:
print(f"Ошибка: {result['error']}")
```
### Пример использования из командной строки
```bash
# Используя curl (через reverse proxy)
curl -X POST https://bot.aabpro.ru/testbot/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
# Используя curl (локально)
curl -X POST http://localhost:5005/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
# Используя примерный скрипт
python send_telegram_example.py 123456789 "Привет! Это тестовое сообщение."
```
## Обработка ошибок
Система обрабатывает следующие типы ошибок:
### HTTP ошибки:
1. **401 Unauthorized** - Неверный или отсутствующий API токен
2. **400 Bad Request** - Неверный ID пользователя или отсутствующие данные
3. **500 Internal Server Error** - Внутренняя ошибка сервера
### Коды ошибок API:
- `INVALID_TOKEN` - Неверный токен аутентификации
- `NO_DATA` - Отсутствуют данные в запросе
- `NO_USER_ID` - Не указан ID пользователя
- `NO_MESSAGE` - Не указан текст сообщения
- `INVALID_USER_ID` - user_id не является числом
- `TELEGRAM_ERROR` - Ошибка Telegram API
- `INTERNAL_ERROR` - Внутренняя ошибка сервера
### Ошибки Telegram API:
1. **403 Forbidden** - Пользователь заблокировал бота или не начал с ним диалог
2. **400 Bad Request** - Неверный ID пользователя
3. **429 Too Many Requests** - Превышен лимит отправки сообщений
4. **404 Not Found** - Пользователь не найден
### Диагностика в веб-интерфейсе
Веб-интерфейс предоставляет подробную диагностику ошибок:
- **Время ответа** сервера
- **HTTP статус** код
- **Код ошибки** API
- **Конкретные предложения** по решению проблемы
- **Автоматическое скрытие** уведомлений
Все ошибки логируются в файл логов с указанием типа ошибки и ID пользователя.
## Логирование
Все операции отправки сообщений записываются в лог с указанием:
- Времени операции
- ID пользователя
- Статуса отправки (успех/ошибка)
- Краткого описания ошибки (при наличии)
Пример записи в логе:
```
[2024-01-15 10:30:45] [TG_bot] ИНФО: Сообщение отправлено: Привет! Это тестовое сообщение... (Пользователь user_123456789)
```
## Требования
- Telegram бот должен быть запущен и подключен через webhook
- Пользователь должен начать диалог с ботом (отправить команду /start)
- Пользователь не должен заблокировать бота
## Безопасность
- API endpoint доступен только для POST запросов
- Проверка корректности входных данных
- Логирование всех попыток отправки для аудита
- Обработка ошибок без раскрытия внутренней информации системы
+1
View File
@@ -0,0 +1 @@
1.1.4
+523 -2
View File
@@ -25,6 +25,8 @@ 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 evtg_publish import tg_post_event_by_id, tg_post_all_events
from zk_load import load_json_all from zk_load import load_json_all
from volk_load import load_categories, load_all_events 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 # Загружаем настройки из .env
load_dotenv() load_dotenv()
@@ -676,6 +678,28 @@ def api_toggle_event_publication(event_id):
finally: finally:
db.close() 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']) @bp.route('/api/events', methods=['POST'])
@login_required @login_required
def api_add_event(): def api_add_event():
@@ -803,6 +827,28 @@ def api_toggle_publication(post_id):
finally: finally:
db.close() 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']) @bp.route('/api/posts', methods=['POST'])
@login_required @login_required
def api_add_post(): def api_add_post():
@@ -940,6 +986,24 @@ def api_generate_all_shortnames():
log_event(error_msg) log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500 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']) @bp.route('/api/generate_shortname/<int:post_id>', methods=['POST'])
@login_required @login_required
def api_generate_shortname(post_id): def api_generate_shortname(post_id):
@@ -947,12 +1011,23 @@ def api_generate_shortname(post_id):
try: try:
post = db.get_post(post_id) post = db.get_post(post_id)
if not post: 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}") log_event(f"Генерация названия для поста ID: {post_id}")
shortname = generate_ai_shortname(post['text']) 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: with db.conn.cursor() as cursor:
cursor.execute( cursor.execute(
"UPDATE posts SET shortname = %s WHERE id = %s", "UPDATE posts SET shortname = %s WHERE id = %s",
@@ -967,7 +1042,10 @@ def api_generate_shortname(post_id):
log_event(error_msg) log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500 return jsonify({'success': False, 'error': error_msg}), 500
finally: finally:
try:
db.close() db.close()
except Exception:
pass
@bp.route('/api/publish_post/<int:post_id>', methods=['POST']) @bp.route('/api/publish_post/<int:post_id>', methods=['POST'])
@login_required @login_required
@@ -987,11 +1065,49 @@ def api_publish_post(post_id):
# Функция для запуска в отдельном потоке # Функция для запуска в отдельном потоке
def run_async_func(): def run_async_func():
try: 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() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
# Устанавливаем переменную окружения для anyio backend
import os
os.environ['ANYIO_BACKEND'] = 'asyncio'
try:
result = loop.run_until_complete(publish_to_tg(post['vk_post_id'])) result = loop.run_until_complete(publish_to_tg(post['vk_post_id']))
return result 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: except Exception as e:
import traceback
log_event(f"Ошибка в run_async_func: {str(e)}\n{traceback.format_exc()}")
return e return e
# Создаем очередь для передачи результата # Создаем очередь для передачи результата
@@ -1005,6 +1121,12 @@ def api_publish_post(post_id):
if isinstance(result, Exception): if isinstance(result, Exception):
raise result 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} выполнена успешно") log_event(f"Публикация поста ID: {post_id} выполнена успешно")
return jsonify({'success': True}) return jsonify({'success': True})
except Exception as e: except Exception as e:
@@ -1072,6 +1194,92 @@ def api_publish_all_events():
log_event(error_msg) log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500 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']) @bp.route('/api/load_categories', methods=['POST'])
@login_required @login_required
def api_load_categories(): def api_load_categories():
@@ -1092,6 +1300,296 @@ def api_load_categories():
log_event(error_msg) log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500 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']) @bp.route('/api/run_volk_rescan', methods=['POST'])
@login_required @login_required
def api_run_volk_rescan(): def api_run_volk_rescan():
@@ -1510,6 +2008,29 @@ def edit_event_page(event_id):
def add_event_page(): def add_event_page():
return render_template('edit_event.html', event_id=None) 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 в приложении # Регистрируем Blueprint в приложении
app.register_blueprint(bp) app.register_blueprint(bp)
+149 -22
View File
@@ -21,14 +21,88 @@ DB_CONFIG = {
'charset': 'utf8mb4' 'charset': 'utf8mb4'
} }
OPENROUTER_CONFIG = { ROUTERAI_CONFIG = {
'api_key': os.getenv('OR_KEY'), 'api_key': os.getenv('RA_KEY'),
'model': os.getenv('OR_MODEL_NAME') 'model': os.getenv('RA_MODEL'),
'base_url': 'https://routerai.ru/api/v1',
} }
# Настройки журналирования # Настройки журналирования
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта 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): 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 return False
def _generate_shortname_by_api(text): def _generate_shortname_by_api(text):
"""Внутренняя функция для генерации краткого названия через OpenRouter API""" """Внутренняя функция для генерации краткого названия через RouterAI API"""
url = "https://openrouter.ai/api/v1/chat/completions" 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 = { headers = {
"Authorization": f"Bearer {OPENROUTER_CONFIG['api_key']}", "Authorization": f"Bearer {ROUTERAI_CONFIG['api_key']}",
"Content-Type": "application/json" "Content-Type": "application/json",
} }
prompt = f""" prompt = f"""
@@ -78,14 +157,16 @@ def _generate_shortname_by_api(text):
Название:""" Название:"""
data = { data = {
"model": OPENROUTER_CONFIG['model'], "model": ROUTERAI_CONFIG['model'],
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"max_tokens": 5000, "max_tokens": 5000,
"temperature": 0.3 "temperature": 0.3,
} }
_log_routerai_request(text, prompt)
try: 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) # Обработка ошибки 429 (Too Many Requests)
if response.status_code == 429: if response.status_code == 429:
@@ -98,27 +179,55 @@ def _generate_shortname_by_api(text):
# Пытаемся распарсить JSON ответ # Пытаемся распарсить JSON ответ
try: try:
error_info["body"] = response.json() error_info["body"] = response.json()
except: except Exception:
pass pass
_log_routerai_response(response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}") log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.") print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
sys.exit(1) # Не sys.exit — эта функция вызывается и из веб-API Flask
raise RuntimeError("RouterAI 429: превышен лимит запросов")
if not response.ok:
_log_routerai_response(response, error_body=response.text)
response.raise_for_status() response.raise_for_status()
result = response.json()
# Извлекаем только текст ответа result = response.json()
shortname = result['choices'][0]['message']['content'].strip() _log_routerai_response(response, result=result)
# Извлекаем только текст ответа (у 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) shortname = re.sub(r'^["\']|["\']$', '', shortname)
# Если модель вернула многострочный ответ — берём первую непустую строку
for line in shortname.splitlines():
line = line.strip()
if line:
shortname = line
break
# Обрезаем до 80 символов # Обрезаем до 80 символов
return shortname[:80] return shortname[:80]
except requests.exceptions.HTTPError as e: 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 = { error_info = {
"status_code": e.response.status_code, "status_code": e.response.status_code,
"headers": dict(e.response.headers), "headers": dict(e.response.headers),
@@ -128,17 +237,35 @@ def _generate_shortname_by_api(text):
# Пытаемся распарсить JSON ответ # Пытаемся распарсить JSON ответ
try: try:
error_info["body"] = e.response.json() error_info["body"] = e.response.json()
except: except Exception:
pass pass
_log_routerai_response(e.response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}") log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.") print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
sys.exit(1) raise RuntimeError("RouterAI 429: превышен лимит запросов")
else: 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}" error_msg = f"HTTP ошибка при генерации названия: {e}"
log_message(error_msg) log_message(error_msg)
print(error_msg) print(error_msg)
return None 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: except Exception as e:
error_msg = f"Ошибка при генерации названия: {e}" error_msg = f"Ошибка при генерации названия: {e}"
log_message(error_msg) log_message(error_msg)
@@ -228,10 +355,10 @@ def ai_shortname_all():
log_message("Обработка всех записей завершена") log_message("Обработка всех записей завершена")
print("Обработка завершена") print("Обработка завершена")
except SystemExit: except RuntimeError as e:
# Перехватываем системный выход для корректного закрытия соединения # 429 и прочие явные ошибки RouterAI
log_message("Завершение работы из-за ошибки 429") log_message(f"Завершение работы: {e}")
print("Завершение работы из-за ошибки 429") print(f"Завершение работы: {e}")
except Exception as e: except Exception as e:
error_msg = f"Произошла ошибка при обработке записей: {e}" error_msg = f"Произошла ошибка при обработке записей: {e}"
log_message(error_msg) 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`).
+59 -5
View File
@@ -46,6 +46,10 @@ def ensure_database_structure():
conn = None conn = None
cursor = None cursor = None
# Проверка режима отладки
log_debug = os.getenv('LOG_DEBUG_DATA', 'false').lower() == 'true'
changes_made = False # Флаг для отслеживания изменений
try: try:
# Подключение без указания базы данных # Подключение без указания базы данных
conn = mysql.connector.connect( conn = mysql.connector.connect(
@@ -56,9 +60,24 @@ def ensure_database_structure():
cursor = conn.cursor(buffered=True) # Используем буферизованный курсор 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']}") cursor.execute(f"USE {DB_CONFIG['database']}")
log_message("Database checked/created successfully")
# Список таблиц и их структур # Список таблиц и их структур
tables = { tables = {
@@ -162,7 +181,12 @@ def ensure_database_structure():
AUTO_ID INT AUTO_INCREMENT PRIMARY KEY, AUTO_ID INT AUTO_INCREMENT PRIMARY KEY,
ID VARCHAR(255), ID VARCHAR(255),
TITLE VARCHAR(2048), TITLE VARCHAR(2048),
description TEXT 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
''' '''
} }
@@ -180,8 +204,10 @@ def ensure_database_structure():
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
# Таблица не существует, создаем её # Таблица не существует, создаем её
cursor.execute(table_sql) cursor.execute(table_sql)
changes_made = True
log_message(f"Table {table_name} created") log_message(f"Table {table_name} created")
else: else:
if log_debug:
log_message(f"Table {table_name} exists") log_message(f"Table {table_name} exists")
except mysql.connector.Error as err: except mysql.connector.Error as err:
@@ -201,8 +227,10 @@ def ensure_database_structure():
"INSERT INTO users (username, password) VALUES (%s, %s)", "INSERT INTO users (username, password) VALUES (%s, %s)",
(username, hashed_password) (username, hashed_password)
) )
changes_made = True
log_message(f"Created default admin user: {username}") log_message(f"Created default admin user: {username}")
else: else:
# Важное предупреждение - всегда логируем
log_message("Environment variables DEFNM/DEFPW not set. Admin not created.") log_message("Environment variables DEFNM/DEFPW not set. Admin not created.")
except mysql.connector.Error as err: except mysql.connector.Error as err:
log_message(f"Error creating default admin: {err}") log_message(f"Error creating default admin: {err}")
@@ -224,7 +252,12 @@ def ensure_database_structure():
{'column': 'auto_added', 'type': 'BOOLEAN DEFAULT 0'} {'column': 'auto_added', 'type': 'BOOLEAN DEFAULT 0'}
], ],
'categories': [ 'categories': [
{'column': 'description', 'type': 'TEXT'} {'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': [ 'events': [
{'column': 'authors', 'type': 'VARCHAR(255)'} {'column': 'authors', 'type': 'VARCHAR(255)'}
@@ -247,8 +280,10 @@ def ensure_database_structure():
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
# Столбец не существует, добавляем его # Столбец не существует, добавляем его
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column} {column_type}") 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}") log_message(f"Added column {column} to table {table_name}")
else: else:
if log_debug:
log_message(f"Column {column} already exists in table {table_name}") log_message(f"Column {column} already exists in table {table_name}")
except mysql.connector.Error as err: except mysql.connector.Error as err:
@@ -299,13 +334,32 @@ def ensure_database_structure():
for index_sql in all_indexes: for index_sql in all_indexes:
try: try:
cursor.execute(index_sql) # Извлекаем имя индекса и таблицы из SQL
index_name = index_sql.split('IF NOT EXISTS')[1].split(' ON ')[0].strip() index_name = index_sql.split('IF NOT EXISTS')[1].split(' ON ')[0].strip()
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}") 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: except mysql.connector.Error as err:
log_message(f"Error creating index: {err}") log_message(f"Error creating index: {err}")
conn.commit() conn.commit()
# Финальное сообщение выводится всегда
log_message("Database structure ensured successfully") log_message("Database structure ensured successfully")
except mysql.connector.Error as err: except mysql.connector.Error as err:
+587 -79
View File
@@ -4,11 +4,20 @@ import asyncio
import pymysql import pymysql
import time import time
import html import html
import httpx
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from telegram import Bot, InlineKeyboardMarkup, InlineKeyboardButton from urllib.parse import urlparse, unquote
from telegram.error import TelegramError, RetryAfter
from dotenv import load_dotenv from dotenv import load_dotenv
from formatter import get_event_text 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() load_dotenv()
@@ -25,6 +34,7 @@ DESC_PREFIX = os.getenv('DESC_PREFIX')
PZK_PREFIX = os.getenv('PZK_PREFIX') PZK_PREFIX = os.getenv('PZK_PREFIX')
LOG_FILE = os.getenv('LOG_FILE', 'evtg_publisher.log') LOG_FILE = os.getenv('LOG_FILE', 'evtg_publisher.log')
EVENT_POST_DELAY = int(os.getenv('EVENT_POST_DELAY', 0)) # Задержка в минутах 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_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
@@ -60,6 +70,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
logger.addHandler(file_handler) 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): def format_tags(tags_str):
"""Форматирование тегов с добавлением #""" """Форматирование тегов с добавлением #"""
if not tags_str: if not tags_str:
@@ -83,14 +100,188 @@ def prepare_text(text):
return 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: try:
# Форматирование текста # Форматирование текста
tags = format_tags(event_data['tags']) 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 "") 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'] about_raw = event_data['about']
@@ -109,12 +300,22 @@ async def tg_post_event(bot, event_data):
number = event_data['number'] number = event_data['number']
placeholder_message_id = '0' * 20 # Заполнитель для message_id placeholder_message_id = '0' * 20 # Заполнитель для message_id
# Формируем ссылки в зависимости от USE_SUBSCRIPTION_BOT # Формируем ссылки в зависимости от USE_SUBSCRIPTION_BOT и наличия DESC_PREFIX
links_parts = []
if DESC_PREFIX:
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
if USE_SUBSCRIPTION_BOT: if USE_SUBSCRIPTION_BOT:
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_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
else: links_parts.append(
links_line_placeholder = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>' 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) links_line_length = len(links_line_placeholder)
else:
links_line_placeholder = ""
links_line_length = 0
# Определяем максимальную длину в зависимости от типа сообщения # Определяем максимальную длину в зависимости от типа сообщения
image_url = event_data['about_social_picture'] image_url = event_data['about_social_picture']
@@ -132,85 +333,275 @@ async def tg_post_event(bot, event_data):
# Формируем первоначальный текст # Формируем первоначальный текст
initial_text = f"{base_text}\n\n{about}" if about else base_text 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: if has_image:
try: try:
message = await bot.send_photo( logger.info(
chat_id=CHANNEL_ID, f"Попытка отправки фото для события {event_data['number']}: "
photo=image_url, f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
caption=initial_text, f"image_url={image_url}"
parse_mode="HTML",
disable_notification=PUBLISH_SILENTLY
) )
except TelegramError as e: image_data, content_type, filename = await download_image(image_url, httpx_client)
logger.warning(f"Не удалось отправить изображение для события номер {event_data['number']}: {e}. Отправляем текстовое сообщение.") photo_data = {
message = await bot.send_message( 'chat_id': CHANNEL_ID,
chat_id=CHANNEL_ID, 'caption': initial_text,
text=initial_text, 'parse_mode': 'HTML',
parse_mode="HTML", 'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
disable_notification=PUBLISH_SILENTLY, }
disable_web_page_preview=True # Отключаем предпросмотр ссылок photo_files = {
) 'photo': (filename, image_data, content_type),
else: }
message = await bot.send_message( photo_response = await httpx_client.post(
chat_id=CHANNEL_ID, send_photo_url, data=photo_data, files=photo_files
text=initial_text,
parse_mode="HTML",
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True # Отключаем предпросмотр ссылок
) )
# Получаем ID сообщения для использования в ссылке подписки if photo_response.status_code == 200:
message_id = message.message_id 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}. Отправляем текстовое сообщение.")
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:
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_parts = []
if DESC_PREFIX:
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
if USE_SUBSCRIPTION_BOT: if USE_SUBSCRIPTION_BOT:
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>' links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
else: links_parts.append(
links_line = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>' 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}" 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: else:
await bot.edit_message_text( final_text = initial_text
chat_id=CHANNEL_ID,
message_id=message_id, expected_urls = []
text=final_text, if DESC_PREFIX:
parse_mode="HTML", expected_urls.append(f"{DESC_PREFIX}{number}/")
disable_web_page_preview=True # Отключаем предпросмотр ссылок 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 return message_id # Возвращаем только message_id
except RetryAfter 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) # Обработка ошибки FloodWait (429)
logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}: {e}") retry_after = int(error_msg.split(":")[1])
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой") logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}")
raise # Пробрасываем исключение выше для обработки на уровне цикла logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
except TelegramError as e: 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']}: {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 return None
except Exception as e: except Exception as e:
logger.error(f"Неожиданная ошибка при публикации события номер {event_data['number']}: {e}") logger.error(f"Неожиданная ошибка при публикации события номер {event_data['number']}: {e}")
@@ -227,13 +618,16 @@ async def tg_post_event_by_id(id_event):
password=MDB_PW, password=MDB_PW,
database=MDBASE, database=MDBASE,
charset='utf8mb4', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor cursorclass=pymysql.cursors.DictCursor,
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
) )
# Проверка времени изменения только в режиме ZILANT
with conn.cursor() as cursor:
if WORKMODE == "ZILANT":
# Вычисляем время, до которого должно быть изменено событие для публикации # Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY) modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
FROM events FROM events
@@ -247,15 +641,58 @@ async def tg_post_event_by_id(id_event):
if not event: if not event:
logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад") logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад")
return 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 сообщения old_message_id = event.get('tg_message_id') # Сохраняем старый ID сообщения
bot = Bot(token=BOT_TOKEN) # Создаем httpx клиент с отключенным HTTP/2
tg_message_id = await tg_post_event(bot, event) # Получаем новый message_id 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: if tg_message_id:
# Используем UTC время для записи в базу данных # Используем UTC время для записи в базу данных
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Обновляем базу данных в отдельном блоке try-except для гарантии сохранения
try:
# Обновляем подписки если был предыдущий message_id # Обновляем подписки если был предыдущий message_id
if old_message_id: if old_message_id:
cursor.execute(""" cursor.execute("""
@@ -265,6 +702,7 @@ async def tg_post_event_by_id(id_event):
""", (tg_message_id, old_message_id)) """, (tg_message_id, old_message_id))
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей") logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
# Обновляем данные о публикации события
cursor.execute(""" cursor.execute("""
UPDATE events UPDATE events
SET is_posted_tg = True, SET is_posted_tg = True,
@@ -273,14 +711,54 @@ async def tg_post_event_by_id(id_event):
WHERE id_event = %s WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event)) """, (tg_message_id, current_time_utc, id_event))
conn.commit() conn.commit()
logger.info(f"Событие номер {event['number']} успешно опубликовано") 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: else:
logger.error(f"Не удалось опубликовать событие ID {id_event}") logger.error(f"Не удалось опубликовать событие ID {id_event}")
except RetryAfter as e: except RetryAfterException as e:
raise 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: 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"Ошибка при публикации: {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: finally:
if conn: if conn:
conn.close() conn.close()
@@ -296,13 +774,16 @@ async def tg_post_all_events():
password=MDB_PW, password=MDB_PW,
database=MDBASE, database=MDBASE,
charset='utf8mb4', charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor cursorclass=pymysql.cursors.DictCursor,
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
) )
with conn.cursor() as cursor:
# Проверка времени изменения только в режиме ZILANT
if WORKMODE == "ZILANT":
# Вычисляем время, до которого должно быть изменено событие для публикации # Вычисляем время, до которого должно быть изменено событие для публикации
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY) modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
with conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
SELECT id_event SELECT id_event
FROM events FROM events
@@ -311,6 +792,15 @@ async def tg_post_all_events():
AND modified <= %s AND modified <= %s
LIMIT 5 LIMIT 5
""", (modified_before,)) """, (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() events = cursor.fetchall()
event_count = 0 event_count = 0
@@ -323,11 +813,29 @@ async def tg_post_all_events():
if event_count < len(events): if event_count < len(events):
await asyncio.sleep(2) await asyncio.sleep(2)
except RetryAfter as e: except RetryAfterException as e:
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд") 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 break
except Exception as e: except Exception as e:
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {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}") logger.info(f"Опубликовано событий в этом запуске: {event_count}")
+22
View File
@@ -37,6 +37,7 @@ def replace_vk_links(text):
Заменяет ссылки на сообщества ВК в форматах: Заменяет ссылки на сообщества ВК в форматах:
[club123|Название сообщества] -> Название сообщества [club123|Название сообщества] -> Название сообщества
[id456|Имя пользователя] -> Имя пользователя [id456|Имя пользователя] -> Имя пользователя
[текст](url) -> <a href="url">текст</a> (для Telegram)
[url|текст] -> <a href="url">текст</a> (для Telegram) [url|текст] -> <a href="url">текст</a> (для Telegram)
""" """
if not text: if not text:
@@ -47,6 +48,27 @@ def replace_vk_links(text):
vk_pattern = r'\[(?:club|id)\d+\|([^\]]+)\]' vk_pattern = r'\[(?:club|id)\d+\|([^\]]+)\]'
text = re.sub(vk_pattern, r'\1', text) text = re.sub(vk_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-ссылки # Затем обрабатываем ссылки формата [url|текст] и преобразуем их в HTML-ссылки
def replace_url_link(match): def replace_url_link(match):
url = match.group(1).strip() url = match.group(1).strip()
-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.
"""
+19 -10
View File
@@ -6,15 +6,18 @@ SCRIPTS_DIR="/opt/testbot-git"
VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate" 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" "tg_publish.py" "evtg_publish.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 30) DELAYS=(2 2 2)
# Лог-файл # Лог-файл
LOG_FILE="$SCRIPTS_DIR/runner.log" LOG_DIR="$SCRIPTS_DIR/logs"
LOG_FILE="$LOG_DIR/runner.log"
# Функция для логирования # Функция для логирования
log_message() { log_message() {
@@ -24,11 +27,20 @@ log_message() {
# Проверка и настройка лог-файла # Проверка и настройка лог-файла
setup_logfile() { 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 [ ! -f "$LOG_FILE" ]; then
# Пытаемся создать файл
if ! touch "$LOG_FILE" 2>/dev/null; then if ! touch "$LOG_FILE" 2>/dev/null; then
# Если не получается, пробуем с sudo
if sudo touch "$LOG_FILE" 2>/dev/null; then if sudo touch "$LOG_FILE" 2>/dev/null; then
sudo chown $(whoami) "$LOG_FILE" sudo chown $(whoami) "$LOG_FILE"
sudo chmod 644 "$LOG_FILE" sudo chmod 644 "$LOG_FILE"
@@ -40,9 +52,7 @@ setup_logfile() {
fi fi
fi fi
# Проверяем права на запись
if [ ! -w "$LOG_FILE" ]; then if [ ! -w "$LOG_FILE" ]; then
# Пытаемся изменить права
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
log_message "Изменены права на лог-файл: $LOG_FILE" log_message "Изменены права на лог-файл: $LOG_FILE"
else else
@@ -112,4 +122,3 @@ done
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!" log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
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"
+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
+1 -1
View File
@@ -6,7 +6,7 @@ After=network.target
[Service] [Service]
User=root User=root
WorkingDirectory=/opt/testbot-git WorkingDirectory=/opt/testbot-git
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5005 --workers 4 wsgi_bot:app 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 Restart=always
[Install] [Install]
+1 -1
View File
@@ -6,7 +6,7 @@ After=network.target
[Service] [Service]
User=root User=root
WorkingDirectory=/opt/testbot-git WorkingDirectory=/opt/testbot-git
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5006 --workers 4 wsgi_edit:app ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5006 --workers 4 --timeout=240 wsgi_edit:app
Restart=always Restart=always
[Install] [Install]
+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>
+897 -30
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

+477 -112
View File
File diff suppressed because it is too large Load Diff
+421 -55
View File
@@ -6,11 +6,37 @@ import time
import html import html
import json import json
import re import re
import httpx
import traceback
import io
import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from telegram import Bot, PollOption
from telegram.error import TelegramError, RetryAfter
from dotenv import load_dotenv from dotenv import load_dotenv
from formatter import get_event_text 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() load_dotenv()
@@ -63,6 +89,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
logger.addHandler(file_handler) 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): def prepare_text(text):
"""Подготовка текстовых полей с экранированием HTML-сущностей""" """Подготовка текстовых полей с экранированием HTML-сущностей"""
if not text: if not text:
@@ -82,6 +115,133 @@ def has_org_tag(text):
pattern = r'#?' + re.escape(ORG_MESSAGE_TAG) + r'\b' pattern = r'#?' + re.escape(ORG_MESSAGE_TAG) + r'\b'
return bool(re.search(pattern, text, re.IGNORECASE)) 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): async def publish_to_tg(vk_post_id):
"""Публикация одной записи по VK post ID""" """Публикация одной записи по VK post ID"""
logger.info(f"Запуск публикации для записи VK ID {vk_post_id}") logger.info(f"Запуск публикации для записи VK ID {vk_post_id}")
@@ -111,8 +271,49 @@ async def publish_to_tg(vk_post_id):
logger.info(f"Запись VK ID {vk_post_id} не найдена или уже опубликована") logger.info(f"Запись VK ID {vk_post_id} не найдена или уже опубликована")
return False 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 # Таймаут для медиа
)
# 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()}")
# Создаем 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
)
# Параметры для повторных попыток при таймаутах
max_retries = 10
retry_interval = 5
# Переменная для отслеживания успешности публикации
message_id = None
publication_successful = False
# Флаг, указывающий, было ли отправлено изображение (True) или текстовое сообщение (False)
message_has_image = False
try:
# Проверяем наличие организационного хештега # Проверяем наличие организационного хештега
is_org_message = has_org_tag(post['text']) is_org_message = has_org_tag(post['text'])
@@ -128,7 +329,9 @@ async def publish_to_tg(vk_post_id):
# Ссылка на подписку (только для событий и если включен бот подписки) # Ссылка на подписку (только для событий и если включен бот подписки)
if post['is_event'] and USE_SUBSCRIPTION_BOT: if post['is_event'] and USE_SUBSCRIPTION_BOT:
links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{placeholder_message_id}">🔜 Подписка</a>') links.append(
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", placeholder_message_id)}">🔜 Подписка</a>'
)
# Формируем строку ссылок # Формируем строку ссылок
links_line = " | ".join(links) if links else "" links_line = " | ".join(links) if links else ""
@@ -163,34 +366,149 @@ async def publish_to_tg(vk_post_id):
# Публикация первоначального сообщения без ссылок # Публикация первоначального сообщения без ссылок
if has_image and not is_org_message: if has_image and not is_org_message:
try: 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( message = await bot.send_photo(
chat_id=CHANNEL_ID, chat_id=CHANNEL_ID,
photo=image_url, photo=image_file,
caption=text, caption=text,
parse_mode="HTML", parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY 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: 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}. Отправляем текстовое сообщение.") 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( message = await bot.send_message(
chat_id=CHANNEL_ID, chat_id=CHANNEL_ID,
text=text, text=text,
parse_mode="HTML", parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY, disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True 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: else:
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение # Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
message_has_image = False # Отправляем текстовое сообщение
# Повторные попытки отправки текста при таймауте
message = None
for attempt in range(1, max_retries + 1):
try:
message = await bot.send_message( message = await bot.send_message(
chat_id=CHANNEL_ID, chat_id=CHANNEL_ID,
text=text, text=text,
parse_mode="HTML", parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY, disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True disable_web_page_preview=True
) )
# Получаем ID сообщения для использования в ссылке подписки
message_id = message.message_id 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: if links:
@@ -200,39 +518,28 @@ async def publish_to_tg(vk_post_id):
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>') updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
if post['is_event'] and USE_SUBSCRIPTION_BOT: if post['is_event'] and USE_SUBSCRIPTION_BOT:
updated_links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{message_id}">🔜 Подписка</a>') updated_links.append(
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)}">🔜 Подписка</a>'
)
links_line = " | ".join(updated_links) links_line = " | ".join(updated_links)
final_text = f"{text}\n\n{links_line}" final_text = f"{text}\n\n{links_line}"
expected_urls = []
await asyncio.sleep(2) # задержка между обращениями к телеграм 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:
if has_image and not is_org_message: expected_urls.append(
try: subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode="HTML"
) )
except TelegramError as e: if not await _edit_post_add_links(
logger.warning(f"Не удалось отредактировать подпись для записи VK ID {vk_post_id}: {e}. Пробуем отредактировать текстовое сообщение.") bot, message_id, message_has_image, final_text, vk_post_id,
await bot.edit_message_text( expected_urls,
chat_id=CHANNEL_ID, ):
message_id=message_id, logger.error(
text=final_text, f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
parse_mode="HTML", f"но ссылки не добавлены — published_in_tg не обновляем"
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
) )
return False
await asyncio.sleep(2) # задержка между обращениями к телеграм await asyncio.sleep(2) # задержка между обращениями к телеграм
@@ -242,26 +549,70 @@ async def publish_to_tg(vk_post_id):
try: try:
# Парсим варианты ответа # Парсим варианты ответа
options = json.loads(post['poll_options']) options = json.loads(post['poll_options'])
poll_options = [PollOption(option, 0) for option in options]
# Отправляем опрос (в каналах можно отправлять только анонимные опросы) # Подготавливаем параметры для опроса
poll_message = await bot.send_poll( poll_kwargs = {
chat_id=CHANNEL_ID, 'chat_id': CHANNEL_ID,
question=post['poll_question'], 'question': post['poll_question'],
options=poll_options, 'options': options,
is_anonymous=True, # В каналах можно отправлять только анонимные опросы 'is_anonymous': True, # В каналах можно отправлять только анонимные опросы
allows_multiple_answers=post['poll_multiple'], 'allows_multiple_answers': post['poll_multiple'],
close_date=post['poll_end_date'] if post['poll_end_date'] else None, 'disable_notification': PUBLISH_SILENTLY
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 poll_message_id = poll_message.message_id
logger.info(f"Опубликован опрос для записи VK ID {vk_post_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: except Exception as e:
logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}") logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}")
# Обновляем запись в базе данных # Проверяем, что 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") current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(""" cursor.execute("""
UPDATE posts UPDATE posts
@@ -275,16 +626,31 @@ async def publish_to_tg(vk_post_id):
logger.info(f"Запись VK ID {vk_post_id} успешно опубликована") logger.info(f"Запись VK ID {vk_post_id} успешно опубликована")
return True return True
finally:
await httpx_client.aclose()
except RetryAfter as e: except RetryAfterException as e:
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}: {e}") logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}: {e}")
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой") logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
raise raise
except TelegramError as e: except TimedOut as e:
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {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 return False
except Exception as e: 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 return False
finally: finally:
if conn: if conn:
@@ -325,7 +691,7 @@ async def publish_to_tg_all():
if post_count < len(posts): if post_count < len(posts):
await asyncio.sleep(2) await asyncio.sleep(2)
except RetryAfter as e: except RetryAfterException as e:
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд") logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
break break
except Exception as e: 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
```
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Пример скрипта для отправки сообщений через Telegram бота Пример скрипта для отправки сообщений через Telegram бота.
Использование: python send_telegram_example.py <user_id> <message>
Использование (из корня проекта):
python tools/dev/send_telegram_example.py <user_id> <message>
""" """
import sys import sys
@@ -9,6 +11,12 @@ import requests
import json import json
import os 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): def send_message(user_id, message_text, bot_url=None, api_token=None):
""" """
Отправляет сообщение пользователю через Telegram бота Отправляет сообщение пользователю через Telegram бота
@@ -64,8 +72,8 @@ def send_message(user_id, message_text, bot_url=None, api_token=None):
def main(): def main():
if len(sys.argv) != 3: if len(sys.argv) != 3:
print("Использование: python send_telegram_example.py <user_id> <message>") print("Использование: python tools/dev/send_telegram_example.py <user_id> <message>")
print("Пример: python send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'") print("Пример: python tools/dev/send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
sys.exit(1) sys.exit(1)
try: try:
+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 os
import mysql.connector import mysql.connector
from mysql.connector import Error from mysql.connector import Error
from datetime import datetime, timezone
from dotenv import load_dotenv 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() load_dotenv()
MDB_HOST = os.getenv('MDB_HOST') MDB_HOST = os.getenv('MDB_HOST')
@@ -12,6 +21,7 @@ MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW') MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE') MDBASE = os.getenv('MDBASE')
def prefetch_all_events(): def prefetch_all_events():
""" """
Проверяет записи в таблице events и устанавливает флаг marked_to_publication Проверяет записи в таблице events и устанавливает флаг marked_to_publication
@@ -20,7 +30,6 @@ def prefetch_all_events():
- не имеют флага marked_to_publication = 1 - не имеют флага marked_to_publication = 1
""" """
try: try:
# Подключение к базе данных
connection = mysql.connector.connect( connection = mysql.connector.connect(
host=MDB_HOST, host=MDB_HOST,
user=MDB_USER, user=MDB_USER,
@@ -28,14 +37,12 @@ def prefetch_all_events():
database=MDBASE database=MDBASE
) )
# Установка временной зоны соединения в UTC
cursor_temp = connection.cursor() cursor_temp = connection.cursor()
cursor_temp.execute("SET time_zone = '+00:00'") cursor_temp.execute("SET time_zone = '+00:00'")
cursor_temp.close() cursor_temp.close()
cursor = connection.cursor() cursor = connection.cursor()
# SQL запрос для обновления записей
update_query = """ update_query = """
UPDATE events UPDATE events
SET marked_to_publication = 1 SET marked_to_publication = 1
@@ -43,15 +50,11 @@ def prefetch_all_events():
AND marked_to_publication = 0 AND marked_to_publication = 0
""" """
# Выполнение запроса
cursor.execute(update_query) cursor.execute(update_query)
updated_count = cursor.rowcount updated_count = cursor.rowcount
# Фиксация изменений
connection.commit() connection.commit()
print(f"Обновлено записей: {updated_count}") print(f"Обновлено записей: {updated_count}")
return updated_count return updated_count
except Error as e: except Error as e:
@@ -67,5 +70,6 @@ def prefetch_all_events():
cursor.close() cursor.close()
connection.close() connection.close()
if __name__ == "__main__": if __name__ == "__main__":
prefetch_all_events() prefetch_all_events()
@@ -1,7 +1,9 @@
#!/bin/bash #!/bin/bash
# Использование: ./copy_db.sh <source_db> <target_db> <username> # Использование (из корня проекта):
# Пример: ./copy_db.sh old_db new_db appuser # bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
# Пример:
# bash tools/ops/copy_databases.sh old_db new_db appuser
set -e # Завершать выполнение при любой ошибке set -e # Завершать выполнение при любой ошибке
+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())
+44 -10
View File
@@ -19,6 +19,14 @@ VK_API_VERSION = os.getenv('VK_API_VERSION', '5.131')
IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on') 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') AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации 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_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
@@ -63,7 +71,7 @@ def get_vk_posts():
url = 'https://api.vk.ru/method/wall.get' url = 'https://api.vk.ru/method/wall.get'
params = { params = {
'owner_id': VK_GROUP_ID, 'owner_id': VK_GROUP_ID,
'count': 10, 'count': 20,
'access_token': VK_ACCESS_TOKEN, 'access_token': VK_ACCESS_TOKEN,
'v': VK_API_VERSION, 'v': VK_API_VERSION,
'extended': 1 'extended': 1
@@ -86,6 +94,12 @@ def get_vk_posts():
log_message(error_msg) log_message(error_msg)
raise Exception(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): def process_post(post):
"""Извлекает необходимые данные из поста VK, включая обработку репостов""" """Извлекает необходимые данные из поста VK, включая обработку репостов"""
post_id = post['id'] post_id = post['id']
@@ -144,11 +158,15 @@ def process_post(post):
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале # Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
post_text = source_post.get('text', '') post_text = source_post.get('text', '')
own_text = post.get('text', '')
letter_count = count_letters(post_text)
if is_repost: if is_repost:
letter_count += count_letters(own_text)
# Если у репоста есть свой текст, добавляем его перед текстом оригинала # Если у репоста есть свой текст, добавляем его перед текстом оригинала
if post.get('text', '').strip(): if own_text.strip():
post_text = f"{post['text']}\n\n---\n\n{post_text}" post_text = f"{own_text}\n\n---\n\n{post_text}"
post_text = repost_prefix + post_text + repost_info post_text = repost_prefix + post_text + repost_info
is_empty_text = letter_count < VK_POST_MIN_LETTERS
return { return {
'vk_post_id': post_id, 'vk_post_id': post_id,
@@ -161,7 +179,9 @@ def process_post(post):
'poll_options': poll_options, 'poll_options': poll_options,
'poll_multiple': poll_multiple, 'poll_multiple': poll_multiple,
'poll_end_date': poll_end_date, 'poll_end_date': poll_end_date,
'is_repost': is_repost 'is_repost': is_repost,
'is_empty_text': is_empty_text,
'letter_count': letter_count,
} }
def save_to_database(posts): def save_to_database(posts):
@@ -175,9 +195,14 @@ def save_to_database(posts):
try: try:
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста # Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
post_text = post.get('text', '') post_text = post.get('text', '')
is_empty_text = post.get('is_empty_text', False)
should_auto_publish = AUTO_PUBLISH should_auto_publish = AUTO_PUBLISH
shortname = None
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text: 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 # Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH
should_auto_publish = False should_auto_publish = False
@@ -201,16 +226,21 @@ def save_to_database(posts):
int(post['poll_multiple']), int(post['poll_multiple']),
post['poll_end_date'], post['poll_end_date'],
IS_EVENT, IS_EVENT,
int(should_auto_publish), # Значение с учетом проверки тега int(should_auto_publish),
None, # shortname - пока не используется, устанавливаем NULL shortname,
None # action_number - пока не используется, устанавливаем NULL None # action_number
)) ))
if cursor.rowcount > 0: if cursor.rowcount > 0:
new_posts_count += 1 new_posts_count += 1
# Логируем добавление нового поста с указанием причины отключения автопубликации # Логируем добавление нового поста с указанием причины отключения автопубликации
auto_publish_reason = 'Да' auto_publish_reason = 'Да'
if not should_auto_publish: if not should_auto_publish:
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text: 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 + ')' auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')'
else: else:
auto_publish_reason = 'Нет' auto_publish_reason = 'Нет'
@@ -246,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) print(start_msg)
log_message(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())
+84 -23
View File
@@ -3,6 +3,7 @@ import json
import time import time
import traceback import traceback
import socket import socket
import re
from datetime import datetime from datetime import datetime
from urllib.request import urlopen from urllib.request import urlopen
from urllib.error import URLError, HTTPError from urllib.error import URLError, HTTPError
@@ -23,6 +24,7 @@ MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW') MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE') MDBASE = os.getenv('MDBASE')
LOG_FILE = os.getenv('LOG_FILE') 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"): def log_message(message, category="VOLK_loader"):
"""Функция для записи в лог-файл с повторными попытками при блокировке""" """Функция для записи в лог-файл с повторными попытками при блокировке"""
@@ -44,6 +46,23 @@ def log_message(message, category="VOLK_loader"):
return False return False
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(): def load_categories():
"""Экспортируемая функция для загрузки категорий из JSON в базу данных""" """Экспортируемая функция для загрузки категорий из JSON в базу данных"""
inserted_count = 0 inserted_count = 0
@@ -123,12 +142,15 @@ def load_categories():
if not existing: if not existing:
# Вставка новой записи # Вставка новой записи
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
marked_for_publication = 1 if AUTO_PUB_EVT else 0
cursor.execute( cursor.execute(
"INSERT INTO categories (ID, TITLE, description) VALUES (%s, %s, %s)", "INSERT INTO categories (ID, TITLE, description, marked_for_publication) VALUES (%s, %s, %s, %s)",
(category_id, title, None) (category_id, title, None, marked_for_publication)
) )
inserted_count += 1 inserted_count += 1
log_message(f"Добавление: Категория {category_id}, Название: {title if title else '(пусто)'}") pub_status = "для публикации" if AUTO_PUB_EVT else "не для публикации"
log_message(f"Добавление: Категория {category_id}, Название: {title if title else '(пусто)'}, Статус: {pub_status}")
else: else:
# Обновление существующей записи (всегда обновляем TITLE, даже если оно пустое) # Обновление существующей записи (всегда обновляем TITLE, даже если оно пустое)
if existing['TITLE'] != title: if existing['TITLE'] != title:
@@ -155,27 +177,48 @@ def load_categories():
try: try:
desc_url = VOLK_CATEGORY_DESC_PREFIX + category_id desc_url = VOLK_CATEGORY_DESC_PREFIX + category_id
log_message(f"Запрос описания для категории {category_id} из {desc_url}") log_message(f"Запрос описания для категории {category_id} из {desc_url}")
log_message("отладочное сообщение для проверки вывода в лог-файл")
desc_response = None desc_response = None
try: try:
response = urlopen(desc_url) # Добавляем таймаут для избежания зависаний (30 секунд)
log_message("urlopen успешно выполнен") 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: except (URLError, HTTPError, OSError, socket.gaierror, socket.timeout, ConnectionError) as e:
log_message("ошибка 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}" error_msg = f"Ошибка сетевого подключения при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR") log_message(error_msg, "ERROR")
traceback_str = traceback.format_exc() except:
log_message(f"Трассировка сетевой ошибки для категории {category_id}:\n{traceback_str}", "ERROR") pass
# Пропускаем эту категорию и продолжаем обработку # Пропускаем эту категорию и продолжаем обработку
continue continue
except Exception as e: except Exception as e:
# Перехватываем любые другие исключения, которые могут возникнуть при urlopen # Перехватываем любые другие исключения, которые могут возникнуть при urlopen
log_message("ошибка urlopen, шаг 2") 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}" error_msg = f"Неожиданная ошибка при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
log_message(error_msg, "ERROR") log_message(error_msg, "ERROR")
traceback_str = traceback.format_exc() except:
log_message(f"Трассировка неожиданной ошибки для категории {category_id}:\n{traceback_str}", "ERROR") pass
# Пропускаем эту категорию и продолжаем обработку # Пропускаем эту категорию и продолжаем обработку
continue continue
@@ -183,10 +226,8 @@ def load_categories():
with response: with response:
# Явно указываем кодировку UTF-8 при декодировании # Явно указываем кодировку UTF-8 при декодировании
raw_data = response.read() raw_data = response.read()
log_message("raw data read")
try: try:
decoded_data = raw_data.decode('utf-8') decoded_data = raw_data.decode('utf-8')
log_message("data decoded")
except UnicodeDecodeError as e: except UnicodeDecodeError as e:
error_msg = f"Ошибка декодирования ответа для категории {category_id}: {e}. Попытка декодирования как latin-1." error_msg = f"Ошибка декодирования ответа для категории {category_id}: {e}. Попытка декодирования как latin-1."
log_message(error_msg, "WARNING") log_message(error_msg, "WARNING")
@@ -194,7 +235,6 @@ def load_categories():
try: try:
desc_response = json.loads(decoded_data) desc_response = json.loads(decoded_data)
log_message("json loaded")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
error_msg = f"Ошибка парсинга JSON для категории {category_id}: {e}. Первые 500 символов ответа: {decoded_data[:500]}" error_msg = f"Ошибка парсинга JSON для категории {category_id}: {e}. Первые 500 символов ответа: {decoded_data[:500]}"
log_message(error_msg, "ERROR") log_message(error_msg, "ERROR")
@@ -244,10 +284,28 @@ def load_categories():
if not description: if not description:
description = None 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( cursor.execute(
"UPDATE categories SET description = %s WHERE ID = %s", "UPDATE categories SET description = %s, image_url = %s WHERE ID = %s",
(description, category_id) (description, image_url, category_id)
) )
connection.commit() connection.commit()
desc_updated_count += 1 desc_updated_count += 1
@@ -391,13 +449,16 @@ def load_events_by_category(category_id):
if not existing: if not existing:
# Вставка новой записи # Вставка новой записи
# Используем id из JSON для id_event, а для number используем автоинкремент # Используем id из JSON для id_event, а для number используем автоинкремент
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
marked_to_publication = 1 if AUTO_PUB_EVT else 0
cursor.execute( cursor.execute(
"""INSERT INTO events (id_event, number, unit_name, name, about, about_social_picture, authors) """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)""", VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
(id_event, current_number, unit_name, name, about, about_social_picture, authors) (id_event, current_number, unit_name, name, about, about_social_picture, authors, marked_to_publication)
) )
inserted_count += 1 inserted_count += 1
log_message(f"Добавление: Событие '{name}', id_event: {id_event}, номер: {current_number}, категория: {category_id}") pub_status = "для публикации" if AUTO_PUB_EVT else "не для публикации"
log_message(f"Добавление: Событие '{name}', id_event: {id_event}, номер: {current_number}, категория: {category_id}, Статус: {pub_status}")
current_number += 1 current_number += 1
else: else:
# Обновление существующей записи # Обновление существующей записи
@@ -501,5 +562,5 @@ def load_all_events():
log_message(error_msg, "ERROR") log_message(error_msg, "ERROR")
if __name__ == "__main__": if __name__ == "__main__":
load_categories() 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__": if __name__ == "__main__":
setup_webhook()
app.run() app.run()