Compare commits

..
82 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
46 changed files with 4125 additions and 948 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**.
+66 -26
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,33 +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"
# @info_1638_bot # @info_1638_bot
POSTER_BOT_TOKEN = "8215624618:AAF6VtqRtFr4twFRTiTjedSDE6txkbIy6rc" 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 # @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"
# #
@@ -60,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
# #
@@ -69,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
@@ -85,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
@@ -93,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
+267 -9
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():
@@ -1175,15 +1383,17 @@ def api_create_category():
data = request.json data = request.json
with db.conn.cursor() as cursor: with db.conn.cursor() as cursor:
cursor.execute(""" cursor.execute("""
INSERT INTO categories (ID, TITLE, description, image_url, tg_id, marked_for_publication) 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) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", ( """, (
data.get('id'), data.get('id'),
data.get('title', ''), data.get('title', ''),
data.get('description', ''), data.get('description', ''),
data.get('image_url', ''), data.get('image_url', ''),
data.get('tg_id'), data.get('tg_id'),
bool(data.get('marked_for_publication', False)) bool(data.get('marked_for_publication', False)),
bool(data.get('is_tg_published', False)),
data.get('tg_published_date') or None
)) ))
db.conn.commit() db.conn.commit()
client_ip = get_client_ip() client_ip = get_client_ip()
@@ -1201,23 +1411,49 @@ def api_update_category(category_id):
db = get_db() db = get_db()
try: try:
data = request.json 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: 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(""" cursor.execute("""
UPDATE categories UPDATE categories
SET TITLE = %s, description = %s, image_url = %s, tg_id = %s, marked_for_publication = %s SET ID = %s, TITLE = %s, description = %s, image_url = %s, tg_id = %s,
WHERE 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('title', ''),
data.get('description', ''), data.get('description', ''),
data.get('image_url', ''), data.get('image_url', ''),
data.get('tg_id'), data.get('tg_id'),
bool(data.get('marked_for_publication', False)), bool(data.get('marked_for_publication', False)),
category_id bool(data.get('is_tg_published', False)),
data.get('tg_published_date') or None,
auto_id
)) ))
db.conn.commit() db.conn.commit()
if cursor.rowcount > 0: if cursor.rowcount > 0:
client_ip = get_client_ip() client_ip = get_client_ip()
log_event(f"Обновлена площадка: {category_id}, IP {client_ip}") log_event(f"Обновлена площадка: {category_id} -> {new_id}, IP {client_ip}")
return jsonify({'success': True}) return jsonify({'success': True})
else: else:
return jsonify({'success': False, 'error': 'Category not found'}), 404 return jsonify({'success': False, 'error': 'Category not found'}), 404
@@ -1298,6 +1534,28 @@ def api_toggle_category_publication(category_id):
finally: finally:
db.close() 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') @bp.route('/api/categories/<category_id>/events_count')
@login_required @login_required
def api_category_events_count(category_id): def api_category_events_count(category_id):
+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`).
+47 -3
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 = {
@@ -185,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:
@@ -206,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}")
@@ -257,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:
@@ -309,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:
+310 -179
View File
@@ -6,8 +6,18 @@ import time
import html import html
import httpx import httpx
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from urllib.parse import urlparse, unquote
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()
@@ -90,14 +100,188 @@ def prepare_text(text):
return text return text
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): 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']
@@ -116,12 +300,22 @@ async def tg_post_event(httpx_client, 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']
@@ -154,26 +348,37 @@ async def tg_post_event(httpx_client, event_data):
logger.info(f"Текст для публикации: {initial_text[:200]}..." if len(initial_text) > 200 else f"Текст для публикации: {initial_text}") logger.info(f"Текст для публикации: {initial_text[:200]}..." if len(initial_text) > 200 else f"Текст для публикации: {initial_text}")
# Публикация первоначального сообщения без ссылок # Публикация первоначального сообщения без ссылок
send_photo_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendPhoto" send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
send_message_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
message_is_photo = False
if has_image: if has_image:
try: try:
logger.info(f"Попытка отправки фото для события {event_data['number']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}") logger.info(
photo_payload = { f"Попытка отправки фото для события {event_data['number']}: "
f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
f"image_url={image_url}"
)
image_data, content_type, filename = await download_image(image_url, httpx_client)
photo_data = {
'chat_id': CHANNEL_ID, 'chat_id': CHANNEL_ID,
'photo': image_url,
'caption': initial_text, 'caption': initial_text,
'parse_mode': 'HTML', 'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY 'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
} }
photo_response = await httpx_client.post(send_photo_url, json=photo_payload) photo_files = {
'photo': (filename, image_data, content_type),
}
photo_response = await httpx_client.post(
send_photo_url, data=photo_data, files=photo_files
)
if photo_response.status_code == 200: if photo_response.status_code == 200:
photo_result = photo_response.json() photo_result = photo_response.json()
if photo_result.get('ok'): if photo_result.get('ok'):
message_data = photo_result.get('result', {}) message_data = photo_result.get('result', {})
message_id = message_data.get('message_id') message_id = message_data.get('message_id')
message_is_photo = True
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}") logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
else: else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение # Ошибка в ответе API - пробуем отправить текстовое сообщение
@@ -317,167 +522,51 @@ async def tg_post_event(httpx_client, event_data):
raise 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)
# Редактируем сообщение, добавляя ссылки
logger.info(f"Попытка редактирования сообщения для события {event_data['number']}: message_id={message_id}, final_text_length={len(final_text)}")
edit_caption_url = f"https://api.telegram.org/bot{BOT_TOKEN}/editMessageCaption"
edit_text_url = f"https://api.telegram.org/bot{BOT_TOKEN}/editMessageText"
if has_image:
try:
edit_caption_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'caption': final_text,
'parse_mode': 'HTML'
}
edit_caption_response = await httpx_client.post(edit_caption_url, json=edit_caption_payload)
if edit_caption_response.status_code == 200:
edit_result = edit_caption_response.json()
if edit_result.get('ok'):
logger.info(f"Подпись успешно отредактирована для события {event_data['number']}")
else: else:
# Ошибка при редактировании подписи - пробуем редактировать текст final_text = initial_text
error_code = edit_result.get('error_code')
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {edit_result.get('description', 'Unknown error')}")
else:
if edit_caption_response.status_code == 429:
retry_after = 60
try:
error_data = edit_caption_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_caption_response.status_code}: {edit_caption_response.text}")
except httpx.TimeoutException as e: expected_urls = []
logger.error(f"ТАЙМАУТ при редактировании подписи для события номер {event_data['number']}") if DESC_PREFIX:
logger.error(f"Полная диагностическая информация об ошибке TimedOut:") expected_urls.append(f"{DESC_PREFIX}{number}/")
logger.error(f" Тип ошибки: {type(e).__name__}") if USE_SUBSCRIPTION_BOT:
logger.error(f" Сообщение: {str(e)}") expected_urls.append(f"{PZK_PREFIX}{number}/")
logger.error(f" Все атрибуты ошибки: {vars(e)}") expected_urls.append(
logger.error(f" Полное представление: {repr(e)}") subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)
logger.error(f" Параметры запроса:") )
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" caption_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
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:
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200: # Проверяем, изменился ли текст. Если нет - не редактируем сообщение
edit_result = edit_text_response.json() if final_text == initial_text:
if edit_result.get('ok'): logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
logger.info(f"Текст успешно отредактирован для события {event_data['number']}") return message_id
else:
error_code = edit_result.get('error_code')
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {edit_result.get('description', 'Unknown error')}")
else:
if edit_text_response.status_code == 429:
retry_after = 60
try:
error_data = edit_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 {edit_text_response.status_code}: {edit_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" message_id: {message_id}")
logger.error(f" text_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
raise
else:
try:
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200: entity_label = f"события {event_data['number']}"
edit_result = edit_text_response.json() links_ok = await _edit_event_add_links(
if edit_result.get('ok'): httpx_client, message_is_photo, message_id, final_text, entity_label,
logger.info(f"Текст успешно отредактирован для события {event_data['number']}") expected_urls,
else: )
error_code = edit_result.get('error_code') if not links_ok and message_is_photo:
if error_code == 429: links_ok = await _edit_event_add_links(
retry_after = edit_result.get('parameters', {}).get('retry_after', 60) httpx_client, False, message_id, final_text, entity_label,
raise Exception(f"RetryAfter:{retry_after}") expected_urls,
raise Exception(f"API error: {edit_result.get('description', 'Unknown error')}") )
else: if not links_ok:
if edit_text_response.status_code == 429: logger.error(
retry_after = 60 f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
try: f"но ссылки не добавлены"
error_data = edit_text_response.json() )
retry_after = error_data.get('parameters', {}).get('retry_after', 60) return None
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_text_response.status_code}: {edit_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" message_id: {message_id}")
logger.error(f" text_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
return message_id # Возвращаем только message_id return message_id # Возвращаем только message_id
@@ -529,7 +618,8 @@ 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 # Проверка времени изменения только в режиме ZILANT
@@ -566,6 +656,24 @@ async def tg_post_event_by_id(id_event):
logger.info(f"Событие ID {id_event} не найдено или уже опубликовано") logger.info(f"Событие ID {id_event} не найдено или уже опубликовано")
return 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 сообщения
# Создаем httpx клиент с отключенным HTTP/2 # Создаем httpx клиент с отключенным HTTP/2
httpx_client = httpx.AsyncClient( httpx_client = httpx.AsyncClient(
@@ -573,6 +681,7 @@ async def tg_post_event_by_id(id_event):
timeout=20.0, timeout=20.0,
follow_redirects=True follow_redirects=True
) )
tg_message_id = None
try: try:
tg_message_id = await tg_post_event(httpx_client, event) # Получаем новый message_id tg_message_id = await tg_post_event(httpx_client, event) # Получаем новый message_id
finally: finally:
@@ -582,6 +691,8 @@ async def tg_post_event_by_id(id_event):
# Используем 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("""
@@ -591,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,
@@ -599,12 +711,38 @@ 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 RetryAfterException as e:
# Обработка RetryAfter (FloodWait)
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" retry_after: {e.retry_after}")
logger.error(f" Полное представление: {repr(e)}")
raise
except Exception as e: except Exception as e:
# Проверяем, не RetryAfter ли это # Проверяем, не RetryAfter ли это в строковом виде
error_msg = str(e) error_msg = str(e)
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')): 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) retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
@@ -615,20 +753,12 @@ async def tg_post_event_by_id(id_event):
logger.error(f" Полное представление: {repr(e)}") logger.error(f" Полное представление: {repr(e)}")
raise RetryAfterException(retry_after) raise RetryAfterException(retry_after)
# Если это не RetryAfter, обрабатываем как обычную ошибку # Если это не RetryAfter, обрабатываем как обычную ошибку
if "RetryAfter" not in error_msg:
logger.error(f"Ошибка при публикации: {e}") logger.error(f"Ошибка при публикации: {e}")
logger.error(f"Полная информация об ошибке в tg_post_event_by_id для события ID {id_event}:") logger.error(f"Полная информация об ошибке в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: {type(e).__name__}") logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}") logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}") logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}") logger.error(f" Полное представление: {repr(e)}")
except Exception as 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)}")
logger.error(f" Полное представление: {repr(e)}")
finally: finally:
if conn: if conn:
conn.close() conn.close()
@@ -644,7 +774,8 @@ 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: with conn.cursor() as cursor:
-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 --timeout=120 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)
+38 -1
View File
@@ -122,6 +122,22 @@
<label class="form-check-label" for="marked_for_publication">Отметить для публикации</label> <label class="form-check-label" for="marked_for_publication">Отметить для публикации</label>
</div> </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"> <div class="d-flex justify-content-end mt-4">
<button type="button" class="btn btn-secondary me-2" id="cancel-btn">Отмена</button> <button type="button" class="btn btn-secondary me-2" id="cancel-btn">Отмена</button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
@@ -210,6 +226,17 @@
document.getElementById('image_url').value = category.image_url || ''; document.getElementById('image_url').value = category.image_url || '';
document.getElementById('tg_id').value = category.tg_id || ''; document.getElementById('tg_id').value = category.tg_id || '';
document.getElementById('marked_for_publication').checked = category.marked_for_publication || false; 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) { if (category.image_url) {
@@ -226,13 +253,23 @@
// Сохранение категории // Сохранение категории
async function saveCategory(categoryId) { async function saveCategory(categoryId) {
const form = document.getElementById('category-form'); 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 = { const formData = {
id: document.getElementById('id').value.trim(), id: document.getElementById('id').value.trim(),
title: document.getElementById('title').value.trim(), title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(), description: document.getElementById('description').value.trim(),
image_url: document.getElementById('image_url').value.trim(), image_url: document.getElementById('image_url').value.trim(),
tg_id: document.getElementById('tg_id').value.trim() || null, tg_id: document.getElementById('tg_id').value.trim() || null,
marked_for_publication: document.getElementById('marked_for_publication').checked 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) { if (!formData.id) {
+245 -12
View File
@@ -469,12 +469,18 @@
<button class="btn btn-primary" id="rescan-btn"> <button class="btn btn-primary" id="rescan-btn">
<i class="fas fa-search me-1"></i> Рескан ВК <i class="fas fa-search me-1"></i> Рескан ВК
</button> </button>
<button class="btn btn-warning me-2" id="mark-all-posts-pub-btn">
<i class="fas fa-check-double me-1"></i> Все - для публикации
</button>
<button class="btn btn-success" id="publish-btn"> <button class="btn btn-success" id="publish-btn">
<i class="fas fa-paper-plane me-1"></i> Опубликовать <i class="fas fa-paper-plane me-1"></i> Опубликовать все
</button> </button>
<button class="btn btn-info" id="generate-names-btn"> <button class="btn btn-info" id="generate-names-btn">
<i class="fas fa-font me-1"></i> Названия <i class="fas fa-font me-1"></i> Названия
</button> </button>
<button class="btn btn-secondary ms-2" id="test-post-btn">
<i class="fas fa-vial me-1"></i> Тест
</button>
<div id="post-selected-event-info" class="cross-tab-info" style="display: none;"> <div id="post-selected-event-info" class="cross-tab-info" style="display: none;">
<span>Выбранное событие: </span> <span>Выбранное событие: </span>
<span id="post-selected-event-name"></span> <span id="post-selected-event-name"></span>
@@ -578,6 +584,9 @@
<i class="fas fa-search me-1"></i> Рескан Зилант-сервера <i class="fas fa-search me-1"></i> Рескан Зилант-сервера
</button> </button>
{% endif %} {% endif %}
<button class="btn btn-warning me-2" id="mark-all-events-pub-btn">
<i class="fas fa-check-double me-1"></i> Все - для публикации
</button>
<button class="btn btn-success" id="publish-all-events-btn"> <button class="btn btn-success" id="publish-all-events-btn">
<i class="fas fa-paper-plane me-1"></i> Опубликовать все <i class="fas fa-paper-plane me-1"></i> Опубликовать все
</button> </button>
@@ -668,6 +677,12 @@
<!-- Вкладка площадок --> <!-- Вкладка площадок -->
<div class="tab-pane fade" id="categories-tab-pane" role="tabpanel" aria-labelledby="categories-tab" tabindex="0"> <div class="tab-pane fade" id="categories-tab-pane" role="tabpanel" aria-labelledby="categories-tab" tabindex="0">
<div class="top-action-buttons d-flex align-items-center"> <div class="top-action-buttons d-flex align-items-center">
<button class="btn btn-warning me-2" id="mark-all-categories-pub-btn">
<i class="fas fa-check-double me-1"></i> Все - для публикации
</button>
<button class="btn btn-success me-2" id="publish-all-categories-btn">
<i class="fas fa-paper-plane me-1"></i> Опубликовать все
</button>
<button class="btn btn-info" id="load-categories-btn"> <button class="btn btn-info" id="load-categories-btn">
<i class="fas fa-sync-alt me-1"></i> Площадки <i class="fas fa-sync-alt me-1"></i> Площадки
</button> </button>
@@ -924,8 +939,10 @@
loadPosts(); loadPosts();
// Добавляем обработчики для кнопок запуска скриптов // Добавляем обработчики для кнопок запуска скриптов
document.getElementById('rescan-btn').addEventListener('click', runRescanScript); document.getElementById('rescan-btn').addEventListener('click', runRescanScript);
document.getElementById('mark-all-posts-pub-btn').addEventListener('click', markAllPostsForPublication);
document.getElementById('publish-btn').addEventListener('click', runPublishScript); document.getElementById('publish-btn').addEventListener('click', runPublishScript);
document.getElementById('generate-names-btn').addEventListener('click', generateAllShortnames); document.getElementById('generate-names-btn').addEventListener('click', generateAllShortnames);
document.getElementById('test-post-btn').addEventListener('click', runTestPost);
document.getElementById('refresh-events-btn').addEventListener('click', loadEvents); document.getElementById('refresh-events-btn').addEventListener('click', loadEvents);
// Обработчики для кнопок добавления события // Обработчики для кнопок добавления события
document.getElementById('add-event-btn').addEventListener('click', () => { document.getElementById('add-event-btn').addEventListener('click', () => {
@@ -1005,8 +1022,17 @@
zkRescanBtn.addEventListener('click', runZkRescan); zkRescanBtn.addEventListener('click', runZkRescan);
} }
{% endif %} {% endif %}
document.getElementById('mark-all-events-pub-btn').addEventListener('click', markAllEventsForPublication);
document.getElementById('publish-all-events-btn').addEventListener('click', publishAllEvents); document.getElementById('publish-all-events-btn').addEventListener('click', publishAllEvents);
{% if g.workmode == 'VOLK' %} {% if g.workmode == 'VOLK' %}
const markAllCategoriesPubBtn = document.getElementById('mark-all-categories-pub-btn');
if (markAllCategoriesPubBtn) {
markAllCategoriesPubBtn.addEventListener('click', markAllCategoriesForPublication);
}
const publishAllCategoriesBtn = document.getElementById('publish-all-categories-btn');
if (publishAllCategoriesBtn) {
publishAllCategoriesBtn.addEventListener('click', publishAllCategories);
}
const loadCategoriesBtn = document.getElementById('load-categories-btn'); const loadCategoriesBtn = document.getElementById('load-categories-btn');
if (loadCategoriesBtn) { if (loadCategoriesBtn) {
loadCategoriesBtn.addEventListener('click', loadCategories); loadCategoriesBtn.addEventListener('click', loadCategories);
@@ -1157,6 +1183,64 @@
alert('Произошла ошибка при рескане ВОЛК'); alert('Произошла ошибка при рескане ВОЛК');
}); });
} }
// Отметить все посты для публикации
function markAllPostsForPublication() {
if (!confirm('Вы уверены, что хотите отметить все посты для публикации?')) {
return;
}
showProcessingMessage("Отметка всех постов для публикации...");
fetch(`${PREFIX}/api/posts/mark_all_for_publication`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
hideProcessingMessage();
if (data.success) {
alert(`Отмечено для публикации: ${data.count || 0} постов`);
loadPosts();
} else {
alert('Ошибка: ' + (data.error || 'неизвестная ошибка'));
}
})
.catch(error => {
hideProcessingMessage();
console.error('Ошибка:', error);
alert('Произошла ошибка при отметке постов');
});
}
// Отметить все события для публикации
function markAllEventsForPublication() {
if (!confirm('Вы уверены, что хотите отметить все события для публикации?')) {
return;
}
showProcessingMessage("Отметка всех событий для публикации...");
fetch(`${PREFIX}/api/events/mark_all_for_publication`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
hideProcessingMessage();
if (data.success) {
alert(`Отмечено для публикации: ${data.count || 0} событий`);
loadEvents();
} else {
alert('Ошибка: ' + (data.error || 'неизвестная ошибка'));
}
})
.catch(error => {
hideProcessingMessage();
console.error('Ошибка:', error);
alert('Произошла ошибка при отметке событий');
});
}
// Функция для публикации всех событий // Функция для публикации всех событий
function publishAllEvents() { function publishAllEvents() {
const currentEventId = selectedEventId; const currentEventId = selectedEventId;
@@ -1460,10 +1544,10 @@
alert('Произошла ошибка при генерации названий'); alert('Произошла ошибка при генерации названий');
}); });
} }
// Функция для генерации названия для конкретного поста // Функция для запуска тестового поста
function generateShortname(postId) { function runTestPost() {
showProcessingMessage("Генерация названия для поста..."); showProcessingMessage("Публикация тестового сообщения...");
fetch(`${PREFIX}/api/generate_shortname/${postId}`, { fetch(`${PREFIX}/api/test_post`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -1474,19 +1558,67 @@
.then(data => { .then(data => {
hideProcessingMessage(); hideProcessingMessage();
if (data.success) { if (data.success) {
alert('Название успешно сгенерировано: ' + data.shortname); alert('Тестовое сообщение успешно опубликовано!');
loadPosts(); // Перезагружаем список постов
// Восстанавливаем выбранный пост
highlightSelectedPost(postId);
showPostDetails(postId);
} else { } else {
alert('Ошибка генерации названия: ' + (data.error || 'неизвестная ошибка')); alert('Ошибка публикации тестового сообщения: ' + (data.error || 'неизвестная ошибка'));
} }
}) })
.catch(error => { .catch(error => {
hideProcessingMessage();
console.error('Ошибка публикации тестового сообщения:', error);
alert('Произошла ошибка при публикации тестового сообщения');
});
}
// Функция для генерации названия для конкретного поста
function generateShortname(postId) {
showProcessingMessage("Генерация названия для поста...");
fetch(`${PREFIX}/api/generate_shortname/${postId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(async response => {
let data = null;
try {
data = await response.json();
} catch (e) {
// Прокси мог оборвать долгий ответ (502/504 HTML) — название часто уже в БД
throw new Error(
`Сервер вернул не-JSON (HTTP ${response.status}). ` +
`Если запрос был долгим, название могло уже сохраниться — обновите список.`
);
}
if (!response.ok) {
throw new Error((data && data.error) || `HTTP ${response.status}`);
}
return data;
})
.then(async data => {
hideProcessingMessage();
if (data && data.success) {
alert('Название успешно сгенерировано: ' + data.shortname);
await loadPosts();
highlightSelectedPost(postId);
await showPostDetails(postId);
} else {
alert('Ошибка генерации названия: ' + ((data && data.error) || 'неизвестная ошибка'));
await loadPosts();
}
})
.catch(async error => {
hideProcessingMessage(); hideProcessingMessage();
console.error('Ошибка генерации названия:', error); console.error('Ошибка генерации названия:', error);
alert('Произошла ошибка при генерации названия'); // После таймаута прокси название часто уже записано — подтянем список
try {
await loadPosts();
highlightSelectedPost(postId);
await showPostDetails(postId);
} catch (e) {
console.error('Не удалось обновить список после ошибки:', e);
}
alert('Ошибка при генерации названия: ' + (error.message || error));
}); });
} }
// Функция для публикации конкретного поста // Функция для публикации конкретного поста
@@ -2050,6 +2182,9 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div></div> <div></div>
<div> <div>
<button class="btn btn-sm btn-success me-2" id="publish-category-btn" ${!category.marked_for_publication || category.is_tg_published ? 'style="display: none;"' : ''}>
<i class="fas fa-paper-plane me-1"></i>Опубликовать
</button>
<button class="btn btn-sm btn-info me-2" id="toggle-category-pub-btn"> <button class="btn btn-sm btn-info me-2" id="toggle-category-pub-btn">
${category.marked_for_publication ? ${category.marked_for_publication ?
'<i class="fas fa-times-circle me-1"></i>Снять с публикации' : '<i class="fas fa-times-circle me-1"></i>Снять с публикации' :
@@ -2103,6 +2238,14 @@
detailsHtml += `</div>`; detailsHtml += `</div>`;
detailsPanel.innerHTML = detailsHtml; detailsPanel.innerHTML = detailsHtml;
// Обработчик для кнопки публикации категории
const publishBtn = document.getElementById('publish-category-btn');
if (publishBtn) {
publishBtn.addEventListener('click', () => {
publishCategory(category.id);
});
}
// Обработчик для кнопки переключения публикации категории // Обработчик для кнопки переключения публикации категории
const togglePubBtn = document.getElementById('toggle-category-pub-btn'); const togglePubBtn = document.getElementById('toggle-category-pub-btn');
if (togglePubBtn) { if (togglePubBtn) {
@@ -2226,6 +2369,95 @@
alert('Произошла ошибка при удалении площадки'); alert('Произошла ошибка при удалении площадки');
} }
} }
// Публикация категории
async function publishCategory(categoryId) {
showProcessingMessage("Публикация площадки...");
try {
const response = await fetch(`${PREFIX}/api/publish_category/${encodeURIComponent(categoryId)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
hideProcessingMessage();
if (data.success) {
alert('Площадка успешно опубликована!');
// Обновляем детали категории
showCategoryDetails(categoryId);
// Обновляем список категорий
loadCategoriesList();
} else {
alert('Ошибка публикации площадки: ' + (data.error || 'неизвестная ошибка'));
}
} catch (error) {
hideProcessingMessage();
console.error('Ошибка публикации площадки:', error);
alert('Произошла ошибка при публикации площадки');
}
}
// Отметить все категории для публикации
function markAllCategoriesForPublication() {
if (!confirm('Вы уверены, что хотите отметить все площадки для публикации?')) {
return;
}
showProcessingMessage("Отметка всех площадок для публикации...");
fetch(`${PREFIX}/api/categories/mark_all_for_publication`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
hideProcessingMessage();
if (data.success) {
alert(`Отмечено для публикации: ${data.count || 0} площадок`);
loadCategoriesList();
} else {
alert('Ошибка: ' + (data.error || 'неизвестная ошибка'));
}
})
.catch(error => {
hideProcessingMessage();
console.error('Ошибка:', error);
alert('Произошла ошибка при отметке площадок');
});
}
// Публикация всех категорий
function publishAllCategories() {
const currentCategoryId = selectedCategoryId;
showProcessingMessage("Публикация всех площадок...");
fetch(`${PREFIX}/api/publish_all_categories`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
hideProcessingMessage();
if (data.success) {
alert('Публикация всех площадок выполнена успешно!');
loadCategoriesList().then(() => {
if (currentCategoryId) {
highlightSelectedCategory(currentCategoryId);
showCategoryDetails(currentCategoryId);
}
});
} else {
alert('Ошибка публикации всех площадок: ' + (data.error || 'неизвестная ошибка'));
}
})
.catch(error => {
hideProcessingMessage();
console.error('Ошибка публикации всех площадок:', error);
alert('Произошла ошибка при публикации всех площадок');
});
}
// Функция загрузки постов // Функция загрузки постов
async function loadPosts() { async function loadPosts() {
const tableBody = document.getElementById('posts-table'); const tableBody = document.getElementById('posts-table');
@@ -2759,6 +2991,7 @@
window.deletePost = deletePost; window.deletePost = deletePost;
window.deleteEvent = deleteEvent; window.deleteEvent = deleteEvent;
window.generateAllShortnames = generateAllShortnames; window.generateAllShortnames = generateAllShortnames;
window.runTestPost = runTestPost;
window.generateShortname = generateShortname; window.generateShortname = generateShortname;
window.publishPost = publishPost; window.publishPost = publishPost;
window.publishCurrentEvent = publishCurrentEvent; window.publishCurrentEvent = publishCurrentEvent;
+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
+392 -217
View File
@@ -7,9 +7,36 @@ import html
import json import json
import re import re
import httpx import httpx
import traceback
import io
import sys
from datetime import datetime, timezone from datetime import datetime, timezone
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()
@@ -88,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}")
@@ -117,13 +271,48 @@ 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
# Создаем httpx клиент с отключенным HTTP/2 # Настройка таймаутов для 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( httpx_client = httpx.AsyncClient(
http2=False, http2=False,
timeout=20.0, timeout=timeout_config,
follow_redirects=True follow_redirects=True
) )
# Параметры для повторных попыток при таймаутах
max_retries = 10
retry_interval = 5
# Переменная для отслеживания успешности публикации
message_id = None
publication_successful = False
# Флаг, указывающий, было ли отправлено изображение (True) или текстовое сообщение (False)
message_has_image = False
try: try:
# Проверяем наличие организационного хештега # Проверяем наличие организационного хештега
is_org_message = has_org_tag(post['text']) is_org_message = has_org_tag(post['text'])
@@ -140,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 ""
@@ -173,108 +364,151 @@ async def publish_to_tg(vk_post_id):
logger.info(f"=== Конец отформатированного текста ===") logger.info(f"=== Конец отформатированного текста ===")
# Публикация первоначального сообщения без ссылок # Публикация первоначального сообщения без ссылок
send_photo_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendPhoto"
send_message_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
if has_image and not is_org_message: if has_image and not is_org_message:
try: try:
photo_payload = { # Логируем информацию об изображении перед отправкой
'chat_id': CHANNEL_ID, logger.info(f"Отправка изображения для записи VK ID {vk_post_id}")
'photo': image_url, logger.info(f"URL изображения: {image_url}")
'caption': text, logger.info(f"Длина URL изображения: {len(image_url)} символов")
'parse_mode': 'HTML', logger.info(f"Длина подписи: {len(text)} символов")
'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() image_data = None
if photo_result.get('ok'):
message_data = photo_result.get('result', {})
message_id = message_data.get('message_id')
else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение
error_code = photo_result.get('error_code')
if error_code == 429:
retry_after = photo_result.get('parameters', {}).get('retry_after', 60)
raise RetryAfterException(retry_after)
raise Exception(f"API error: {photo_result.get('description', 'Unknown error')}")
else:
if photo_response.status_code == 429:
retry_after = 60
try: try:
error_data = photo_response.json() image_data = await download_image(image_url, httpx_client)
retry_after = error_data.get('parameters', {}).get('retry_after', 60) except Exception as download_error:
except: logger.error(f"Ошибка при скачивании изображения для записи VK ID {vk_post_id}: {download_error}")
pass raise # Пробрасываем ошибку, чтобы перейти к fallback
file_size_mb = len(image_data) / (1024 * 1024)
logger.info(f"Размер файла: {file_size_mb:.2f} МБ")
# Повторные попытки отправки фото при таймауте (до 10 раз)
message = None
for attempt in range(1, max_retries + 1):
try:
image_file = io.BytesIO(image_data)
message = await bot.send_photo(
chat_id=CHANNEL_ID,
photo=image_file,
caption=text,
parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY
)
message_id = message.message_id
message_has_image = True
logger.info(f"Сообщение с изображением успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при публикации изображения (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при публикации изображения после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise # Пробрасываем таймаут наверх
except TelegramError as e:
# Проверяем, является ли это ошибкой FloodWait (429)
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after) raise RetryAfterException(retry_after)
raise Exception(f"HTTP {photo_response.status_code}: {photo_response.text}") 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: except RetryAfterException:
raise raise
except Exception as e: except Exception as e:
# Если не удалось отправить изображение, пробуем отправить текстовое сообщение
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.") logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
text_payload = { message_has_image = False # Отправляем текстовое сообщение вместо изображения
'chat_id': CHANNEL_ID,
'text': 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: # Повторные попытки отправки текста (fallback) при таймауте
text_result = text_response.json() for attempt in range(1, max_retries + 1):
if text_result.get('ok'):
message_data = text_result.get('result', {})
message_id = message_data.get('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 RetryAfterException(retry_after)
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
else:
if text_response.status_code == 429:
retry_after = 60
try: try:
error_data = text_response.json() message = await bot.send_message(
retry_after = error_data.get('parameters', {}).get('retry_after', 60) chat_id=CHANNEL_ID,
except: text=text,
pass parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True
)
message_id = message.message_id
logger.info(f"Текстовое сообщение (fallback) успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при отправке текста (fallback) (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при отправке текста (fallback) после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after) raise RetryAfterException(retry_after)
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}") 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:
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение # Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
text_payload = { message_has_image = False # Отправляем текстовое сообщение
'chat_id': CHANNEL_ID,
'text': 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() message = None
if text_result.get('ok'): for attempt in range(1, max_retries + 1):
message_data = text_result.get('result', {})
message_id = message_data.get('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 RetryAfterException(retry_after)
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
else:
if text_response.status_code == 429:
retry_after = 60
try: try:
error_data = text_response.json() message = await bot.send_message(
retry_after = error_data.get('parameters', {}).get('retry_after', 60) chat_id=CHANNEL_ID,
except: text=text,
pass parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True
)
message_id = message.message_id
logger.info(f"Текстовое сообщение успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при публикации текста (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при публикации текста после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise # Пробрасываем таймаут наверх
except TelegramError as e:
# Проверяем, является ли это ошибкой FloodWait (429)
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after) raise RetryAfterException(retry_after)
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}") 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:
@@ -284,104 +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:
edit_caption_url = f"https://api.telegram.org/bot{BOT_TOKEN}/editMessageCaption" expected_urls.append(
edit_text_url = f"https://api.telegram.org/bot{BOT_TOKEN}/editMessageText" subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
)
if has_image and not is_org_message: if not await _edit_post_add_links(
try: bot, message_id, message_has_image, final_text, vk_post_id,
edit_caption_payload = { expected_urls,
'chat_id': CHANNEL_ID, ):
'message_id': message_id, logger.error(
'caption': final_text, f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
'parse_mode': 'HTML' f"но ссылки не добавлены — published_in_tg не обновляем"
} )
edit_caption_response = await httpx_client.post(edit_caption_url, json=edit_caption_payload) return False
if edit_caption_response.status_code == 200:
edit_result = edit_caption_response.json()
if not edit_result.get('ok'):
error_code = edit_result.get('error_code')
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise RetryAfterException(retry_after)
raise Exception(f"API error: {edit_result.get('description', 'Unknown error')}")
else:
if edit_caption_response.status_code == 429:
retry_after = 60
try:
error_data = edit_caption_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise RetryAfterException(retry_after)
raise Exception(f"HTTP {edit_caption_response.status_code}: {edit_caption_response.text}")
except RetryAfterException:
raise
except Exception as e:
logger.warning(f"Не удалось отредактировать подпись для записи VK ID {vk_post_id}: {e}. Пробуем отредактировать текстовое сообщение.")
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200:
edit_result = edit_text_response.json()
if not edit_result.get('ok'):
error_code = edit_result.get('error_code')
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise RetryAfterException(retry_after)
raise Exception(f"API error: {edit_result.get('description', 'Unknown error')}")
else:
if edit_text_response.status_code == 429:
retry_after = 60
try:
error_data = edit_text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise RetryAfterException(retry_after)
raise Exception(f"HTTP {edit_text_response.status_code}: {edit_text_response.text}")
else:
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200:
edit_result = edit_text_response.json()
if not edit_result.get('ok'):
error_code = edit_result.get('error_code')
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise RetryAfterException(retry_after)
raise Exception(f"API error: {edit_result.get('description', 'Unknown error')}")
else:
if edit_text_response.status_code == 429:
retry_after = 60
try:
error_data = edit_text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise RetryAfterException(retry_after)
raise Exception(f"HTTP {edit_text_response.status_code}: {edit_text_response.text}")
await asyncio.sleep(2) # задержка между обращениями к телеграм await asyncio.sleep(2) # задержка между обращениями к телеграм
@@ -392,9 +550,8 @@ async def publish_to_tg(vk_post_id):
# Парсим варианты ответа # Парсим варианты ответа
options = json.loads(post['poll_options']) options = json.loads(post['poll_options'])
# Отправляем опрос (в каналах можно отправлять только анонимные опросы) # Подготавливаем параметры для опроса
send_poll_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendPoll" poll_kwargs = {
poll_payload = {
'chat_id': CHANNEL_ID, 'chat_id': CHANNEL_ID,
'question': post['poll_question'], 'question': post['poll_question'],
'options': options, 'options': options,
@@ -407,41 +564,55 @@ async def publish_to_tg(vk_post_id):
if post['poll_end_date']: if post['poll_end_date']:
# Преобразуем datetime в Unix timestamp # Преобразуем datetime в Unix timestamp
if isinstance(post['poll_end_date'], datetime): if isinstance(post['poll_end_date'], datetime):
poll_payload['close_date'] = int(post['poll_end_date'].timestamp()) poll_kwargs['close_date'] = int(post['poll_end_date'].timestamp())
else: else:
poll_payload['close_date'] = post['poll_end_date'] poll_kwargs['close_date'] = post['poll_end_date']
poll_response = await httpx_client.post(send_poll_url, json=poll_payload) # Повторные попытки отправки опроса при таймауте
poll_message = None
if poll_response.status_code == 200: for attempt in range(1, max_retries + 1):
poll_result = poll_response.json()
if poll_result.get('ok'):
poll_message_data = poll_result.get('result', {})
poll_message_id = poll_message_data.get('message_id')
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
else:
error_code = poll_result.get('error_code')
if error_code == 429:
retry_after = poll_result.get('parameters', {}).get('retry_after', 60)
raise RetryAfterException(retry_after)
raise Exception(f"API error: {poll_result.get('description', 'Unknown error')}")
else:
if poll_response.status_code == 429:
retry_after = 60
try: try:
error_data = poll_response.json() poll_message = await bot.send_poll(**poll_kwargs)
retry_after = error_data.get('parameters', {}).get('retry_after', 60) poll_message_id = poll_message.message_id
except: logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
pass 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) raise RetryAfterException(retry_after)
raise Exception(f"HTTP {poll_response.status_code}: {poll_response.text}") 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: except RetryAfterException:
raise 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
@@ -462,8 +633,13 @@ async def publish_to_tg(vk_post_id):
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 httpx.TimeoutException as e: except TimedOut as e:
logger.error(f"ТАЙМАУТ при публикации записи 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:
error_msg = str(e) error_msg = str(e)
@@ -472,10 +648,9 @@ async def publish_to_tg(vk_post_id):
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}") logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}")
logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой") logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
raise RetryAfterException(retry_after) raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {e}") logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
return False logger.error(f"Детали ошибки: {repr(e)}")
except Exception as e: # Убеждаемся, что БД не обновляется при ошибке
logger.error(f"Неожиданная ошибка при публикации записи VK ID {vk_post_id}: {e}")
return False return False
finally: finally:
if conn: if conn:
+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:
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Тестовый модуль для проверки загрузки описания категории. Тестовый модуль для проверки загрузки описания категории.
Использование: python volk_category_testing.py <category_id>
Использование (из корня проекта):
python tools/dev/volk_category_testing.py <category_id>
""" """
import os import os
import sys import sys
@@ -15,7 +17,8 @@ from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError from urllib.error import URLError, HTTPError
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()
# Параметры из .env # Параметры из .env
@@ -260,8 +263,8 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Примеры использования: Примеры использования:
python volk_category_testing.py volk26_nri python tools/dev/volk_category_testing.py volk26_nri
python volk_category_testing.py volk26_nri volk26_other volk26_third python tools/dev/volk_category_testing.py volk26_nri volk26_other volk26_third
""" """
) )
parser.add_argument( parser.add_argument(
@@ -8,9 +8,16 @@ import json
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv 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() 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') POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME') RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
@@ -68,6 +75,7 @@ async def test_telegram_publish():
logger.info(f" MAX_TEXT_LENGTH: {MAX_TEXT_LENGTH}") logger.info(f" MAX_TEXT_LENGTH: {MAX_TEXT_LENGTH}")
logger.info(f" PUBLISH_SILENTLY: {PUBLISH_SILENTLY}") logger.info(f" PUBLISH_SILENTLY: {PUBLISH_SILENTLY}")
logger.info(f" USE_SUBSCRIPTION_BOT: {USE_SUBSCRIPTION_BOT}") logger.info(f" USE_SUBSCRIPTION_BOT: {USE_SUBSCRIPTION_BOT}")
logger.info(f" RELAY_URL: {get_relay_url()}")
# Проверка обязательных параметров # Проверка обязательных параметров
if not POSTER_BOT_TOKEN: if not POSTER_BOT_TOKEN:
@@ -92,7 +100,11 @@ async def test_telegram_publish():
# Формируем строку ссылок # Формируем строку ссылок
placeholder_message_id = '1234567890' placeholder_message_id = '1234567890'
if USE_SUBSCRIPTION_BOT: if USE_SUBSCRIPTION_BOT:
links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{test_number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{placeholder_message_id}">Подписка</a>' 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: else:
links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a>' links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a>'
@@ -147,7 +159,7 @@ async def test_telegram_publish():
logger.info("=" * 80) logger.info("=" * 80)
logger.info("ПОПЫТКА ПОЛУЧЕНИЯ ИНФОРМАЦИИ О БОТЕ") logger.info("ПОПЫТКА ПОЛУЧЕНИЯ ИНФОРМАЦИИ О БОТЕ")
logger.info("=" * 80) logger.info("=" * 80)
get_me_url = f"https://api.telegram.org/bot{POSTER_BOT_TOKEN}/getMe" get_me_url = bot_api_method_url(POSTER_BOT_TOKEN, "getMe")
logger.info(f"URL запроса: {get_me_url}") logger.info(f"URL запроса: {get_me_url}")
logger.info(f"Метод: GET") logger.info(f"Метод: GET")
@@ -209,7 +221,7 @@ async def test_telegram_publish():
logger.info("=" * 80) logger.info("=" * 80)
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ") logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ")
logger.info("=" * 80) logger.info("=" * 80)
send_message_url = f"https://api.telegram.org/bot{POSTER_BOT_TOKEN}/sendMessage" send_message_url = bot_api_method_url(POSTER_BOT_TOKEN, "sendMessage")
logger.info(f"URL запроса: {send_message_url}") logger.info(f"URL запроса: {send_message_url}")
logger.info(f"Метод: POST") logger.info(f"Метод: POST")
logger.info(f"Параметры запроса:") logger.info(f"Параметры запроса:")
@@ -258,7 +270,7 @@ async def test_telegram_publish():
logger.info("=" * 80) logger.info("=" * 80)
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ") logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ")
logger.info("=" * 80) logger.info("=" * 80)
edit_message_url = f"https://api.telegram.org/bot{POSTER_BOT_TOKEN}/editMessageText" edit_message_url = bot_api_method_url(POSTER_BOT_TOKEN, "editMessageText")
logger.info(f"URL запроса: {edit_message_url}") logger.info(f"URL запроса: {edit_message_url}")
logger.info(f"Метод: POST") logger.info(f"Метод: POST")
logger.info(f"Параметры запроса:") logger.info(f"Параметры запроса:")
@@ -336,7 +348,7 @@ async def test_telegram_publish():
logger.info("=" * 80) logger.info("=" * 80)
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ С КАРТИНКОЙ") logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ С КАРТИНКОЙ")
logger.info("=" * 80) logger.info("=" * 80)
send_photo_url = f"https://api.telegram.org/bot{POSTER_BOT_TOKEN}/sendPhoto" 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" image_url = "https://base.volk-nn.ru/uploads/events/volk26/volk26_event/20251210_175059_0.jpg"
# Формируем текст для сообщения с картинкой # Формируем текст для сообщения с картинкой
@@ -391,7 +403,7 @@ async def test_telegram_publish():
logger.info("=" * 80) logger.info("=" * 80)
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ С КАРТИНКОЙ") logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ С КАРТИНКОЙ")
logger.info("=" * 80) logger.info("=" * 80)
edit_photo_caption_url = f"https://api.telegram.org/bot{POSTER_BOT_TOKEN}/editMessageCaption" edit_photo_caption_url = bot_api_method_url(POSTER_BOT_TOKEN, "editMessageCaption")
logger.info(f"URL запроса: {edit_photo_caption_url}") logger.info(f"URL запроса: {edit_photo_caption_url}")
logger.info(f"Метод: POST") logger.info(f"Метод: POST")
logger.info(f"Параметры запроса:") logger.info(f"Параметры запроса:")
@@ -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())
+15 -8
View File
@@ -24,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"):
"""Функция для записи в лог-файл с повторными попытками при блокировке""" """Функция для записи в лог-файл с повторными попытками при блокировке"""
@@ -141,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:
@@ -445,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:
# Обновление существующей записи # Обновление существующей записи
@@ -555,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()