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
LOG_FILE = /opt/testbot-git/logs/testzilantposter.log
# LOG_FILE = /opt/volk/logs/volkposter.log
# WORKMODE = "ZILANT"
WORKMODE = "VOLK"
WORKMODE = "ZILANT"
# WORKMODE = "VOLK"
# Код сезона для deep link / callback подписок (zk2026_post_…, zk2026_event_…)
SEASON = "zk2026"
# Telegram API через internal_relay (retransbot) — обязательно
# Нужна схема http:// или https:// (если без схемы — подставится http://)
# Локально: http://127.0.0.1:5100 или http://192.168.0.109:5100
# Через nginx: https://rbin.aabpro.ru
RELAY_URL = "http://192.168.0.109:5100"
# Webhook Responder-bot
WEBHOOK_URL = "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_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
@@ -23,33 +38,53 @@ WEBCTRL_URL = "https://bot.aabpro.ru/testedit"
WEBCTRL_PORT = 5006
WEBCTRL_PREFIX = "/testedit"
# WEBCTRL_URL = "https://bot.aabpro.ru/volk"
# WEBCTRL_PORT = 5009
# WEBCTRL_PREFIX = "/volk"
# логин и пароль по умолчанию для первого запуска веб-контроллера
DEFNM=admin
DEFPW=Z1l@ntAdm1n23
# VK access
# Zilant group
# VK_GROUP_ID = -3342146
VK_GROUP_ID = -3342146
# VOLK group
VK_GROUP_ID = -58475527
# VK_GROUP_ID = -58475527
VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012e946b952a8'
VK_API_VERSION = '5.131'
# MySQL database
# MDBASE = 'testposts'
MDBASE = 'testposts'
# 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_USER = 'zilantposter'
# MDB_PW = 'Z1l@ntp08t3r'
MDB_USER = 'testuser'
MDB_PW = 'T38tU83r69'
# MDB_USER = 'volkuser'
# MDB_PW = 'V0lK@dM1n23'
# Poster bot
# Debug bot
# Debug bot @posto_1638_bot
# BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
# @info_1638_bot
POSTER_BOT_TOKEN = "8215624618:AAF6VtqRtFr4twFRTiTjedSDE6txkbIy6rc"
# VOLK production bot @VOLK_informer_bot
# POSTER_BOT_TOKEN = "8279661544:AAEsJee6icCUn_AbWxhDpk2CLxQhzaartuI"
# VOLK backup production bot @VOLK_informer2_bot
# POSTER_BOT_TOKEN = "8001674071:AAFyGW4xibAFnuApEaSJvshqEyYHmD7RRts"
# VOLK responder bot
# RESPONDER_BOT_TOKEN = "8072579275:AAFiyKN1EPPnP5ZVZNTDGEriYJf1xADbfsY"
# RESPONDER_BOT_NAME = "VOLK_responder_bot"
# @posto_1638_bot
RESPONDER_BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
RESPONDER_BOT_NAME = "posto_1638_bot"
# Zilant info bot
# BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
#
@@ -60,7 +95,7 @@ RESPONDER_BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
# POSTER_BOT_TOKEN = "8115998792:AAHamcNBlTgTXG0Birc1gNbZ5B7z4jMEdik"
# RESPONDER_BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
# RESPONDER_BOT_NAME = "Zilant_info_bot"
RESPONDER_BOT_NAME = "posto_1638_bot"
# RESPONDER_BOT_NAME = "posto_1638_bot"
# Parameters
#
@@ -69,6 +104,8 @@ RESPONDER_BOT_NAME = "posto_1638_bot"
ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
# tag to detect posts to not repost from VK
VK_NO_REPOST_TAG = "#окоЗиланта"
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
VK_POST_MIN_LETTERS = 5
# Minutes delay before publishing an event
@@ -85,6 +122,9 @@ EVENT_POST_DELAY = 1440
CHANNEL_ID = "@posto1638"
# Zilant production channel
# CHANNEL_ID = "@zilantkon"
# VOLK production channel
# CHANNEL_ID = "@VOLKinformer"
MAX_CAPTION_LENGTH = 1000
MAX_TEXT_LENGTH = 4000
@@ -93,30 +133,30 @@ MAX_TEXT_LENGTH = 4000
#
# add event number and trailing /
#
PZK_PREFIX = "https://reg.konvent.ru/zk2025/draft/plan/api/add/"
EVT_PREFIX = "https://reg.konvent.ru/zk2025/events/actions/api/"
DESC_PREFIX = "https://reg.konvent.ru/zk2025/events/actions/"
PZK_PREFIX = "https://reg.konvent.ru/zk2026/draft/plan/api/add/"
EVT_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/api/"
DESC_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/"
# VOLK mode API links
# add category ID (volk24_larp for ex.)
VOLK_API_VERSION = "1.0"
VOLK_CATEGORY_LIST = "https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event"
VOLK_CATEGORY_DESC_PREFIX = "https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category="
VOLK_EVENT_LIST_PREFIX = "https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category="
# VOLK_API_VERSION = "1.0"
# VOLK_CATEGORY_LIST = "https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event"
# VOLK_CATEGORY_DESC_PREFIX = "https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category="
# VOLK_EVENT_LIST_PREFIX = "https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category="
# Event status
IS_EVENT = true
AUTO_PUBLISH = false
AUTO_PUB_EVT = false
AUTO_PUBLISH = true
AUTO_PUB_EVT = true
PROCESS_EVENT_UPDATES = false
PUBLISH_SILENTLY = true
USE_SUBSCRIPTION_BOT = false
USE_SUBSCRIPTION_BOT = true
LOG_DEBUG_DATA = false
# RouterAI (генерация shortname)
RA_KEY = "sk-7pJxRlbYWUGMNX9y2mHzi2soKDIsZPzp"
RA_MODEL = "moonshotai/kimi-k2-thinking"
# OpenRouter
OR_KEY = "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"
OR_MODEL_NAME = "qwen/qwen3-vl-235b-a22b-instruct"
# OR_MODEL_NAME = "deepseek/deepseek-chat-v3.1"
# OR_MODEL_NAME = "deepseek/deepseek-r1-0528:free"
# OR_MODEL_NAME = "qwen/qwen3-coder:free"
# OpenRouter (не используется, оставлен для справки)
# OR_KEY = "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"
# OR_MODEL_NAME = "anthropic/claude-sonnet-5"
+128
View File
@@ -0,0 +1,128 @@
# =============================================================================
# Шаблон окружения для инстанса проекта.
# Скопируйте в .env и заполните значения:
# cp .env.example .env
# Генерация SECRET_KEY / WEBHOOK_SECRET:
# python tools/ops/genkey.py
# =============================================================================
# --- Общие настройки ---------------------------------------------------------
SECRET_KEY=CHANGE_ME_hex_64_chars
LOG_FILE=./logs/app.log
# Режим работы: ZILANT | VOLK
WORKMODE=ZILANT
# Код сезона для deep link / callback подписок (zk2026_post_…, subscribe_zk2026_…)
SEASON=zk2026
# --- Telegram через internal_relay (retransbot) — обязательно ----------------
# Схема http:// или https:// обязательна (иначе подставится http://)
# Примеры: http://127.0.0.1:5100 | https://relay.example.com
RELAY_URL=http://127.0.0.1:5100
# --- Webhook responder-бота --------------------------------------------------
# WEBHOOK_URL — адрес локальной доставки апдейтов от relay к gunicorn-боту
# (доступен с машины relay). Это не публичный URL для Telegram.
# ZILANT (пример): http://bot.example.com:5005/testbot
# VOLK (пример): http://bot.example.com:5010/volk_bot
WEBHOOK_URL=http://192.168.0.1:5005/testbot
WEBHOOK_PORT=5005
WEBHOOK_SECRET=CHANGE_ME_webhook_secret
# Опционально: webhook poster-бота (если используется)
# POSTHOOK_URL=https://bot.example.com/poster
# POSTHOOK_PORT=5008
# POSTHOOK_SECRET=CHANGE_ME
# --- Веб-контроллер (db_edit) ------------------------------------------------
# ZILANT (пример):
WEBCTRL_URL=https://bot.example.com/testedit
WEBCTRL_PORT=5006
WEBCTRL_PREFIX=/testedit
# VOLK (пример, раскомментировать при WORKMODE=VOLK):
# WEBCTRL_URL=https://bot.example.com/volk
# WEBCTRL_PORT=5009
# WEBCTRL_PREFIX=/volk
# Логин/пароль первого администратора веб-UI (создаётся ensure_db при пустой таблице users)
DEFNM=admin
DEFPW=CHANGE_ME_admin_password
# --- VK API ------------------------------------------------------------------
# ZILANT group (пример): -3342146
# VOLK group (пример): -58475527
VK_GROUP_ID=-3342146
VK_ACCESS_TOKEN=CHANGE_ME_vk_token
VK_API_VERSION=5.131
# --- MariaDB -----------------------------------------------------------------
# ZILANT: zilant_2026 | tempzilant | testposts
# VOLK: volkdata | volk_test
MDBASE=CHANGE_ME_database_name
MDB_HOST=localhost
MDB_USER=CHANGE_ME_db_user_name
MDB_PW=CHANGE_ME_db_password
# --- Telegram-боты -----------------------------------------------------------
# Poster — публикация в канал
POSTER_BOT_TOKEN=CHANGE_ME_poster_bot_token
# Responder — подписки / webhook
RESPONDER_BOT_TOKEN=CHANGE_ME_responder_bot_token
RESPONDER_BOT_NAME=your_responder_bot_username
# --- Канал -------------------------------------------------------------------
# ZILANT debug: @posto1638 | production: @zilantkon
# VOLK production: @VOLKinformer
CHANNEL_ID=@your_channel
MAX_CAPTION_LENGTH=1000
MAX_TEXT_LENGTH=4000
# --- Теги VK (ZILANT) --------------------------------------------------------
# Org messages go without picture
# to fit the long text into TG message limit
ORG_MESSAGE_TAG=#оргинфаЗиланткона
# VK posts with no repost tag are skipped by poster
VK_NO_REPOST_TAG=#окоЗиланта
# Минимум букв (isalpha) в тексте VK-поста. Меньше — в БД как «- = Пустой пост = -», не для публикации
VK_POST_MIN_LETTERS=5
# --- Публикация событий ------------------------------------------------------
# Задержка перед публикацией события (минуты). В БД время в UTC.
# Ближе к событию: 60; далеко: 1440
EVENT_POST_DELAY=1440
# --- Ссылки ZILANT (План Захвата / карточки событий) --------------------------
# В конце обычно trailing slash; к URL добавляется номер события
PZK_PREFIX=https://reg.konvent.ru/zk2026/draft/plan/api/add/
EVT_PREFIX=https://reg.konvent.ru/zk2026/events/actions/api/
DESC_PREFIX=https://reg.konvent.ru/zk2026/events/actions/
# --- API VOLK (нужны при WORKMODE=VOLK) --------------------------------------
# Раскомментируйте и заполните для режима VOLK:
# VOLK_API_VERSION=1.0
# VOLK_CATEGORY_LIST=https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event
# VOLK_CATEGORY_DESC_PREFIX=https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category=
# VOLK_EVENT_LIST_PREFIX=https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category=
# --- Флаги поведения ---------------------------------------------------------
# IS_EVENT=true - каждый пост в ВК считать пригодным для репоста в канал
# AUTO_PUBLISH=true - автоматическая публикация потсов из ВК в канал
# AUTO_PUB_EVT=true - автоматическая публикация событий в канал
# PROCESS_EVENT_UPDATES=false - не репостить уже опубликованные события при обновлениях
# PUBLISH_SILENTLY=true - публиковать в канал в режиме "без звука"
# USE_SUBSCRIPTION_BOT=true - использовать бота подписок
# LOG_DEBUG_DATA=false - не вести расширенного журналирования отладочных данных
IS_EVENT=true
AUTO_PUBLISH=true
AUTO_PUB_EVT=true
PROCESS_EVENT_UPDATES=false
PUBLISH_SILENTLY=true
USE_SUBSCRIPTION_BOT=true
LOG_DEBUG_DATA=false
# --- RouterAI (генерация shortname через «Назвать») ---------------------------
RA_KEY=CHANGE_ME_routerai_key
RA_MODEL=moonshotai/kimi-k2-thinking
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*$py.class
# Секреты инстанса — только шаблон .env.example в репозитории
.env
.env.local
.env.*.local
# Логи инстанса
logs/
+67 -3
View File
@@ -1,4 +1,68 @@
# Zilant2025
# Zilant / VOLK — Telegram poster и подписки
Zilantkon bot testarea
test update #8
**Версия:** 1.1.4 ([`VERSION`](VERSION))
Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link.
## Режимы
| `WORKMODE` | Описание |
|------------|----------|
| `ZILANT` | VK + Zilant JSON → канал (основной пайплайн) |
| `VOLK` | VOLK API → категории и события → канал |
Подробнее: [`docs/volk-mode.md`](docs/volk-mode.md).
## Быстрый старт (сервер)
```bash
# Deploy через Woodpecker → rsync + post-deploy.sh
# Сервисы:
sudo systemctl status testbot-git.service # бот :5005
sudo systemctl status testedit-git.service # UI :5006
sudo systemctl status runner.timer # hourly pipeline
```
Конфигурация — скопируйте [`.env.example`](.env.example) в `.env` и заполните значения
(файл `.env` в git не коммитится). Ключевые переменные: `WORKMODE`, `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
Генерация секретов: `python tools/ops/genkey.py`.
## Пайплайн (ZILANT)
`runner.sh` по расписанию:
`vk_load``zk_load``db_update_shortname``tg_publish``evtg_publish`
Настройка timer: [`docs/runner-setup.md`](docs/runner-setup.md).
## Документация
| Документ | О чём |
|----------|--------|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Компоненты, карта файлов, deploy |
| [`docs/volk-mode.md`](docs/volk-mode.md) | Режим VOLK |
| [`docs/telegram-messaging.md`](docs/telegram-messaging.md) | API отправки сообщений пользователям |
| [`docs/runner-setup.md`](docs/runner-setup.md) | Systemd timer / cron для runner |
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | История версий |
| [`tools/README.md`](tools/README.md) | Вспомогательные и legacy-скрипты |
## Версионирование
SemVer в [`VERSION`](VERSION). При изменениях обновлять `VERSION` и [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
## Структура каталогов
```
├── tg_mainbot.py, wsgi_bot.py # responder-бот
├── db_edit.py, wsgi_edit.py # веб-UI
├── tg_publish.py, evtg_publish.py # публикация (ZILANT)
├── volk_load.py, volk_cat_publish.py # VOLK
├── runner.sh, backupdb.sh
├── services/ # systemd units, post-deploy
├── templates/ # HTML UI
├── docs/ # документация
└── tools/
├── ops/ # genkey, register_tg_webhook, copy_databases
├── dev/ # send_telegram_example, volk_*_testing
└── legacy/ # evt_prefetch
```
+2 -203
View File
@@ -1,204 +1,3 @@
# Отправка сообщений через 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`:
- `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 запросов
- Проверка корректности входных данных
- Логирование всех попыток отправки для аудита
- Обработка ошибок без раскрытия внутренней информации системы
Документация: [`docs/telegram-messaging.md`](docs/telegram-messaging.md)
+1
View File
@@ -0,0 +1 @@
1.1.4
+270 -12
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 zk_load import load_json_all
from volk_load import load_categories, load_all_events
from volk_cat_publish import tg_post_category_by_id, tg_post_all_categories
from test_tg_poster import make_test_post
# Загружаем настройки из .env
load_dotenv()
@@ -676,6 +678,28 @@ def api_toggle_event_publication(event_id):
finally:
db.close()
@bp.route('/api/events/mark_all_for_publication', methods=['POST'])
@login_required
def api_mark_all_events_for_publication():
"""Отмечает все события для публикации"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
UPDATE events
SET marked_to_publication = 1
WHERE marked_to_publication = 0
""")
count = cursor.rowcount
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Отмечено для публикации: {count} событий, IP {client_ip}")
return jsonify({'success': True, 'count': count})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/events', methods=['POST'])
@login_required
def api_add_event():
@@ -803,6 +827,28 @@ def api_toggle_publication(post_id):
finally:
db.close()
@bp.route('/api/posts/mark_all_for_publication', methods=['POST'])
@login_required
def api_mark_all_posts_for_publication():
"""Отмечает все посты для публикации"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
UPDATE posts
SET marked_for_publication = 1
WHERE marked_for_publication = 0
""")
count = cursor.rowcount
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Отмечено для публикации: {count} постов, IP {client_ip}")
return jsonify({'success': True, 'count': count})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/posts', methods=['POST'])
@login_required
def api_add_post():
@@ -940,6 +986,24 @@ def api_generate_all_shortnames():
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/test_post', methods=['POST'])
@login_required
def api_test_post():
try:
log_event("Запуск публикации тестового сообщения...")
result = make_test_post()
if result:
log_event("Тестовое сообщение успешно опубликовано")
return jsonify({'success': True})
else:
error_msg = "Не удалось опубликовать тестовое сообщение"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
except Exception as e:
error_msg = f"Ошибка при публикации тестового сообщения: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/generate_shortname/<int:post_id>', methods=['POST'])
@login_required
def api_generate_shortname(post_id):
@@ -947,12 +1011,23 @@ def api_generate_shortname(post_id):
try:
post = db.get_post(post_id)
if not post:
return jsonify({'error': 'Post not found'}), 404
return jsonify({'success': False, 'error': 'Post not found'}), 404
log_event(f"Генерация названия для поста ID: {post_id}")
shortname = generate_ai_shortname(post['text'])
if not shortname:
return jsonify({
'success': False,
'error': 'Не удалось сгенерировать название (пустой ответ AI)',
}), 500
# После долгого AI-запроса MySQL-соединение могло протухнуть
try:
db.conn.ping(reconnect=True)
except Exception:
db.close()
db = get_db()
# Обновляем shortname в базе данных
with db.conn.cursor() as cursor:
cursor.execute(
"UPDATE posts SET shortname = %s WHERE id = %s",
@@ -967,7 +1042,10 @@ def api_generate_shortname(post_id):
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
finally:
db.close()
try:
db.close()
except Exception:
pass
@bp.route('/api/publish_post/<int:post_id>', methods=['POST'])
@login_required
@@ -987,11 +1065,49 @@ def api_publish_post(post_id):
# Функция для запуска в отдельном потоке
def run_async_func():
try:
# Устанавливаем правильную политику event loop для работы с anyio
# Это критично для правильной работы httpx в отдельном потоке
if sys.platform == 'win32':
# На Windows используем ProactorEventLoop
policy = asyncio.WindowsProactorEventLoopPolicy()
else:
# На Linux используем DefaultEventLoopPolicy
policy = asyncio.DefaultEventLoopPolicy()
asyncio.set_event_loop_policy(policy)
# Создаем новый event loop для этого потока
# Это важно при работе с gunicorn, который может иметь свой event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(publish_to_tg(post['vk_post_id']))
return result
# Устанавливаем переменную окружения для anyio backend
import os
os.environ['ANYIO_BACKEND'] = 'asyncio'
try:
result = loop.run_until_complete(publish_to_tg(post['vk_post_id']))
return result
finally:
# Закрываем event loop после завершения
# Это важно для предотвращения утечек ресурсов
try:
# Отменяем все незавершенные задачи
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Ждем завершения отмененных задач
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
except Exception:
pass
finally:
loop.close()
# Сбрасываем политику event loop
asyncio.set_event_loop_policy(None)
except Exception as e:
import traceback
log_event(f"Ошибка в run_async_func: {str(e)}\n{traceback.format_exc()}")
return e
# Создаем очередь для передачи результата
@@ -1005,6 +1121,12 @@ def api_publish_post(post_id):
if isinstance(result, Exception):
raise result
# Проверяем, что публикация действительно успешна (результат True)
if result is not True:
error_msg = f"Публикация поста ID: {post_id} не удалась (результат: {result})"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
log_event(f"Публикация поста ID: {post_id} выполнена успешно")
return jsonify({'success': True})
except Exception as e:
@@ -1072,6 +1194,92 @@ def api_publish_all_events():
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/publish_all_categories', methods=['POST'])
@login_required
def api_publish_all_categories():
try:
log_event("Запуск публикации всех площадок...")
# Создаем асинхронную обертку для запуска в отдельном потоке
import asyncio
from threading import Thread
import queue
def run_async_func():
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(tg_post_all_categories())
return result
except Exception as e:
return e
# Создаем очередь для передачи результата
q = queue.Queue()
# Запускаем в отдельном потоке
thread = Thread(target=lambda: q.put(run_async_func()))
thread.start()
thread.join()
result = q.get()
if isinstance(result, Exception):
raise result
log_event("Публикация всех площадок выполнена успешно")
return jsonify({'success': True})
except Exception as e:
error_msg = f"Ошибка при публикации всех площадок: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/publish_category/<category_id>', methods=['POST'])
@login_required
def api_publish_category(category_id):
try:
# Сначала проверяем существование категории
db = get_db()
with db.conn.cursor() as cursor:
cursor.execute("SELECT ID, TITLE FROM categories WHERE ID = %s", (category_id,))
category = cursor.fetchone()
if not category:
return jsonify({'success': False, 'error': 'Category not found'}), 404
db.close()
log_event(f"Запуск публикации площадки ID: {category_id}")
# Создаем асинхронную обертку для запуска в отдельном потоке
import asyncio
from threading import Thread
import queue
def run_async_func():
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(tg_post_category_by_id(category_id))
return result
except Exception as e:
return e
# Создаем очередь для передачи результата
q = queue.Queue()
# Запускаем в отдельном потоке
thread = Thread(target=lambda: q.put(run_async_func()))
thread.start()
thread.join()
result = q.get()
if isinstance(result, Exception):
raise result
log_event(f"Публикация площадки ID {category_id} выполнена успешно")
return jsonify({'success': True})
except Exception as e:
error_msg = f"Ошибка при публикации площадки: {str(e)}"
log_event(error_msg)
return jsonify({'success': False, 'error': error_msg}), 500
@bp.route('/api/load_categories', methods=['POST'])
@login_required
def api_load_categories():
@@ -1175,15 +1383,17 @@ def api_create_category():
data = request.json
with db.conn.cursor() as cursor:
cursor.execute("""
INSERT INTO categories (ID, TITLE, description, image_url, tg_id, marked_for_publication)
VALUES (%s, %s, %s, %s, %s, %s)
INSERT INTO categories (ID, TITLE, description, image_url, tg_id, marked_for_publication, is_tg_published, tg_published_date)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
data.get('id'),
data.get('title', ''),
data.get('description', ''),
data.get('image_url', ''),
data.get('tg_id'),
bool(data.get('marked_for_publication', False))
bool(data.get('marked_for_publication', False)),
bool(data.get('is_tg_published', False)),
data.get('tg_published_date') or None
))
db.conn.commit()
client_ip = get_client_ip()
@@ -1201,23 +1411,49 @@ def api_update_category(category_id):
db = get_db()
try:
data = request.json
new_id = data.get('id', '').strip()
if not new_id:
return jsonify({'success': False, 'error': 'ID is required'}), 400
with db.conn.cursor() as cursor:
# Сначала проверяем существование категории по старому ID
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (category_id,))
category = cursor.fetchone()
if not category:
return jsonify({'success': False, 'error': 'Category not found'}), 404
auto_id = category['AUTO_ID']
# Если новый ID отличается от старого, проверяем, не занят ли он
if new_id != category_id:
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (new_id,))
existing = cursor.fetchone()
if existing and existing['AUTO_ID'] != auto_id:
return jsonify({'success': False, 'error': f'Category with ID "{new_id}" already exists'}), 400
# Обновляем категорию, включая ID если он изменился
cursor.execute("""
UPDATE categories
SET TITLE = %s, description = %s, image_url = %s, tg_id = %s, marked_for_publication = %s
WHERE ID = %s
SET ID = %s, TITLE = %s, description = %s, image_url = %s, tg_id = %s,
marked_for_publication = %s, is_tg_published = %s, tg_published_date = %s
WHERE AUTO_ID = %s
""", (
new_id,
data.get('title', ''),
data.get('description', ''),
data.get('image_url', ''),
data.get('tg_id'),
bool(data.get('marked_for_publication', False)),
category_id
bool(data.get('is_tg_published', False)),
data.get('tg_published_date') or None,
auto_id
))
db.conn.commit()
if cursor.rowcount > 0:
client_ip = get_client_ip()
log_event(f"Обновлена площадка: {category_id}, IP {client_ip}")
log_event(f"Обновлена площадка: {category_id} -> {new_id}, IP {client_ip}")
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': 'Category not found'}), 404
@@ -1298,6 +1534,28 @@ def api_toggle_category_publication(category_id):
finally:
db.close()
@bp.route('/api/categories/mark_all_for_publication', methods=['POST'])
@login_required
def api_mark_all_categories_for_publication():
"""Отмечает все категории для публикации"""
db = get_db()
try:
with db.conn.cursor() as cursor:
cursor.execute("""
UPDATE categories
SET marked_for_publication = 1
WHERE marked_for_publication = 0
""")
count = cursor.rowcount
db.conn.commit()
client_ip = get_client_ip()
log_event(f"Отмечено для публикации: {count} площадок, IP {client_ip}")
return jsonify({'success': True, 'count': count})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/categories/<category_id>/events_count')
@login_required
def api_category_events_count(category_id):
+149 -22
View File
@@ -21,14 +21,88 @@ DB_CONFIG = {
'charset': 'utf8mb4'
}
OPENROUTER_CONFIG = {
'api_key': os.getenv('OR_KEY'),
'model': os.getenv('OR_MODEL_NAME')
ROUTERAI_CONFIG = {
'api_key': os.getenv('RA_KEY'),
'model': os.getenv('RA_MODEL'),
'base_url': 'https://routerai.ru/api/v1',
}
# Настройки журналирования
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе
def _truncate_for_log(value, max_len=None):
"""Обрезает длинные значения для журнала, не теряя информацию о размере."""
if value is None:
return None
if not isinstance(value, str):
value = json.dumps(value, ensure_ascii=False, default=str)
limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX
if len(value) <= limit:
return value
return f"{value[:limit]}... [обрезано, всего {len(value)} символов]"
def _message_fields_for_log(message):
"""Все значимые поля message из ответа chat/completions."""
if not message:
return {}
logged = {}
for key in (
'role', 'content', 'reasoning_content', 'reasoning',
'refusal', 'tool_calls', 'function_call', 'audio',
):
if key not in message:
continue
value = message.get(key)
if value in (None, '', [], {}):
logged[key] = value
elif isinstance(value, str):
logged[key] = _truncate_for_log(value)
else:
logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str))
return logged
def _log_routerai_request(text, prompt):
"""Журнал параметров исходящего запроса (без API-ключа)."""
log_message(
"Запрос RouterAI: "
f"model={ROUTERAI_CONFIG['model']}, "
f"text_len={len(text)}, prompt_len={len(prompt)}, "
f"text_preview={_truncate_for_log(text.strip(), 300)}"
)
def _log_routerai_response(response, result=None, error_body=None):
"""Подробный журнал HTTP-ответа RouterAI."""
payload = {
"http_status": getattr(response, 'status_code', None),
"model": ROUTERAI_CONFIG.get('model'),
}
if result is not None:
payload["response_id"] = result.get("id")
payload["object"] = result.get("object")
payload["created"] = result.get("created")
payload["usage"] = result.get("usage")
payload["system_fingerprint"] = result.get("system_fingerprint")
choices = result.get("choices") or []
payload["choices_count"] = len(choices)
payload["choices"] = []
for i, choice in enumerate(choices):
payload["choices"].append({
"index": choice.get("index", i),
"finish_reason": choice.get("finish_reason"),
"message": _message_fields_for_log(choice.get("message") or {}),
"logprobs": choice.get("logprobs"),
})
if result.get("error"):
payload["api_error"] = result.get("error")
if error_body is not None:
payload["error_body"] = _truncate_for_log(error_body)
log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}")
def log_message(message, max_retries=5, retry_delay=0.1):
"""
@@ -59,11 +133,16 @@ def log_message(message, max_retries=5, retry_delay=0.1):
return False
def _generate_shortname_by_api(text):
"""Внутренняя функция для генерации краткого названия через OpenRouter API"""
url = "https://openrouter.ai/api/v1/chat/completions"
"""Внутренняя функция для генерации краткого названия через RouterAI API"""
if not ROUTERAI_CONFIG['api_key'] or not ROUTERAI_CONFIG['model']:
log_message("RA_KEY или RA_MODEL не заданы в .env")
print("Ошибка: RA_KEY или RA_MODEL не заданы в .env")
return None
url = f"{ROUTERAI_CONFIG['base_url']}/chat/completions"
headers = {
"Authorization": f"Bearer {OPENROUTER_CONFIG['api_key']}",
"Content-Type": "application/json"
"Authorization": f"Bearer {ROUTERAI_CONFIG['api_key']}",
"Content-Type": "application/json",
}
prompt = f"""
@@ -78,14 +157,16 @@ def _generate_shortname_by_api(text):
Название:"""
data = {
"model": OPENROUTER_CONFIG['model'],
"model": ROUTERAI_CONFIG['model'],
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 5000,
"temperature": 0.3
"temperature": 0.3,
}
_log_routerai_request(text, prompt)
try:
response = requests.post(url, headers=headers, json=data, timeout=60)
response = requests.post(url, headers=headers, json=data, timeout=180)
# Обработка ошибки 429 (Too Many Requests)
if response.status_code == 429:
@@ -98,27 +179,55 @@ def _generate_shortname_by_api(text):
# Пытаемся распарсить JSON ответ
try:
error_info["body"] = response.json()
except:
except Exception:
pass
_log_routerai_response(response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
sys.exit(1)
# Не 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()
_log_routerai_response(response, result=result)
# Извлекаем только текст ответа
shortname = result['choices'][0]['message']['content'].strip()
# Извлекаем только текст ответа (у thinking-моделей ответ в content,
# рассуждения — в reasoning_content / reasoning)
choices = result.get('choices') or []
if not choices:
log_message("RouterAI вернул пустой choices — название не извлечь")
return None
message = choices[0].get('message') or {}
shortname = (message.get('content') or '').strip()
if not shortname:
reasoning = (message.get('reasoning_content') or message.get('reasoning') or '').strip()
log_message(
"Пустой content в ответе RouterAI; "
f"finish_reason={choices[0].get('finish_reason')!r}, "
f"message_keys={list(message.keys())}, "
f"reasoning_len={len(reasoning)}"
)
return None
# Удаляем возможные кавычки и лишние символы
shortname = re.sub(r'^["\']|["\']$', '', shortname)
# Если модель вернула многострочный ответ — берём первую непустую строку
for line in shortname.splitlines():
line = line.strip()
if line:
shortname = line
break
# Обрезаем до 80 символов
return shortname[:80]
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
if e.response is not None and e.response.status_code == 429:
error_info = {
"status_code": e.response.status_code,
"headers": dict(e.response.headers),
@@ -128,17 +237,35 @@ def _generate_shortname_by_api(text):
# Пытаемся распарсить JSON ответ
try:
error_info["body"] = e.response.json()
except:
except Exception:
pass
_log_routerai_response(e.response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
sys.exit(1)
raise RuntimeError("RouterAI 429: превышен лимит запросов")
else:
if e.response is not None:
try:
_log_routerai_response(e.response, error_body=e.response.text)
except Exception:
pass
error_msg = f"HTTP ошибка при генерации названия: {e}"
log_message(error_msg)
print(error_msg)
return None
except requests.exceptions.Timeout as e:
error_msg = f"Таймаут запроса RouterAI ({e})"
log_message(error_msg)
print(error_msg)
return None
except requests.exceptions.RequestException as e:
error_msg = f"Сетевая ошибка RouterAI: {e}"
log_message(error_msg)
print(error_msg)
return None
except RuntimeError:
raise
except Exception as e:
error_msg = f"Ошибка при генерации названия: {e}"
log_message(error_msg)
@@ -228,10 +355,10 @@ def ai_shortname_all():
log_message("Обработка всех записей завершена")
print("Обработка завершена")
except SystemExit:
# Перехватываем системный выход для корректного закрытия соединения
log_message("Завершение работы из-за ошибки 429")
print("Завершение работы из-за ошибки 429")
except RuntimeError as e:
# 429 и прочие явные ошибки RouterAI
log_message(f"Завершение работы: {e}")
print(f"Завершение работы: {e}")
except Exception as e:
error_msg = f"Произошла ошибка при обработке записей: {e}"
log_message(error_msg)
+165
View File
@@ -0,0 +1,165 @@
# Архитектура проекта
Версия: см. [`VERSION`](../VERSION). История изменений: [`docs/CHANGELOG.md`](CHANGELOG.md).
## Назначение
Система загружает посты и события из внешних источников, хранит их в MariaDB, публикует анонсы в Telegram-канал и даёт веб-интерфейс для редактирования и управления подписками через responder-бота.
Поддерживаются два режима работы (`WORKMODE` в `.env`):
| Режим | Источники данных | Публикация |
|-------|------------------|------------|
| **ZILANT** | VK (`vk_load.py`), JSON Zilant (`zk_load.py`) | `tg_publish.py`, `evtg_publish.py` |
| **VOLK** | VOLK API (`volk_load.py`) | `volk_cat_publish.py` |
Подробнее о VOLK: [`docs/volk-mode.md`](volk-mode.md).
## Компоненты runtime
```
┌─────────────────┐ rsync ┌──────────────────┐
│ Woodpecker CI │ ─────────────► │ /opt/testbot-git │
└─────────────────┘ └────────┬─────────┘
┌──────────────────────────────────┼──────────────────────────┐
▼ ▼ ▼
runner.timer testbot-git.service testedit-git.service
→ runner.sh gunicorn :5005 gunicorn :5006
vk_load, zk_load, wsgi_bot → tg_mainbot wsgi_edit → db_edit
db_update_shortname,
tg_publish, evtg_publish
```
### Responder-бот (подписки)
- **`tg_mainbot.py`** — Flask + webhook, обработка `/start`, callback-кнопок подписки.
- **`wsgi_bot.py`** — точка входа gunicorn; однократная регистрация webhook с file lock.
- **`season_links.py`** — сезонные payload (`zk2026_post_*`, `subscribe_zk2026_*` и т.д.).
- Старые ссылки без префикса сезона → сообщение «сезон завершён» + кнопка в канал.
### Poster (публикация в канал)
- **`tg_publish.py`** — посты VK → Telegram.
- **`evtg_publish.py`** — события → Telegram.
- **`volk_cat_publish.py`** — категории VOLK → Telegram (режим VOLK).
- Все исходящие вызовы Bot API — через **`telegram_relay.py`** (`RELAY_URL`).
### Веб-интерфейс
- **`db_edit.py`** — CRUD постов/событий, кнопки рескана, публикации, «Назвать» (AI shortname).
- **`db_update_shortname.py`** — генерация коротких названий через RouterAI.
- **`test_tg_poster.py`** — тестовый пост в канал (кнопка «Тест» в UI).
- Шаблоны: `templates/`.
### База данных
- **`ensure_db.py`** — создание/миграция схемы (вызывается из loader-скриптов).
- **`backupdb.sh`** — дамп БД при deploy (`post-deploy.sh`).
## Пайплайн runner.sh (ZILANT)
Порядок выполнения (systemd timer `runner.timer`):
1. `vk_load.py` — загрузка постов из VK
2. `zk_load.py` — загрузка событий из JSON
3. `db_update_shortname.py` — AI shortname для новых записей
4. `tg_publish.py` — публикация постов
5. `evtg_publish.py` — публикация событий
> **`tools/legacy/evt_prefetch.py`** раньше стоял перед `evtg_publish.py`; с 1.0.0 снят с пайплайна.
## Deploy
- **`.woodpecker.yml`** — rsync на сервер, затем `services/post-deploy.sh`.
- **`post-deploy.sh`** — venv, pip, backup БД, обновление systemd units, restart сервисов.
## Конфигурация (.env)
Шаблон для нового инстанса: [`.env.example`](../.env.example).
```bash
cp .env.example .env
# заполнить CHANGE_ME_*; секреты: python tools/ops/genkey.py
```
| Переменная | Назначение |
|------------|------------|
| `WORKMODE` | `ZILANT` или `VOLK` |
| `SEASON` | Префикс сезона для deep link (`zk2026`) |
| `RELAY_URL` | Internal relay для Bot API |
| `WEBHOOK_URL`, `WEBHOOK_SECRET` | Доставка апдейтов боту |
| `MDB_*`, `MDBASE` | MariaDB |
| `POSTER_BOT_TOKEN`, `RESPONDER_BOT_TOKEN` | Токены ботов |
| `PZK_PREFIX`, `EVT_PREFIX`, `DESC_PREFIX` | Ссылки ZILANT |
| `VK_NO_REPOST_TAG` | Тег VK, отключающий автопубликацию |
| `VK_POST_MIN_LETTERS` | Минимум букв в тексте VK-поста (иначе «пустой пост», не публиковать) |
| `VOLK_*` | API VOLK (при `WORKMODE=VOLK`) |
| `RA_KEY`, `RA_MODEL` | RouterAI для shortname |
## Карта файлов
### Ядро (runtime)
| Файл | Роль |
|------|------|
| `tg_mainbot.py` | Responder-бот, webhook |
| `wsgi_bot.py` | Gunicorn entry (бот) |
| `db_edit.py` | Веб-UI |
| `wsgi_edit.py` | Gunicorn entry (UI) |
| `tg_publish.py` | Публикация постов |
| `evtg_publish.py` | Публикация событий |
| `vk_load.py` | Загрузка VK |
| `zk_load.py` | Загрузка Zilant JSON |
| `volk_load.py` | Загрузка VOLK (режим VOLK) |
| `volk_cat_publish.py` | Публикация VOLK (режим VOLK) |
| `db_update_shortname.py` | AI shortname |
| `formatter.py` | Форматирование текста постов/событий |
| `ensure_db.py` | Схема БД |
| `telegram_relay.py` | Relay для Bot API |
| `season_links.py` | Сезонные ссылки и callback |
| `test_tg_poster.py` | Тестовая публикация (UI) |
| `runner.sh` | Пайплайн по расписанию |
| `backupdb.sh` | Бэкап БД |
| `gunicorn_bot.conf.py` | Stub-конфиг gunicorn (бот) |
### Инфраструктура
| Путь | Роль |
|------|------|
| `services/testbot-git.service` | systemd: бот :5005 |
| `services/testedit-git.service` | systemd: UI :5006 |
| `services/runner.service`, `runner.timer` | systemd: hourly runner |
| `services/post-deploy.sh` | Post-deploy |
| `.woodpecker.yml` | CI deploy |
### Вспомогательные (`tools/`)
| Путь | Роль |
|------|------|
| `tools/ops/genkey.py` | Генерация секретов |
| `tools/ops/register_tg_webhook.py` | Ручная регистрация webhook |
| `tools/ops/copy_databases.sh` | Клонирование MariaDB |
| `tools/dev/send_telegram_example.py` | CLI-пример `/send_message` |
| `tools/dev/volk_telegram_testing.py` | Отладка публикации в Telegram |
| `tools/dev/volk_category_testing.py` | Отладка загрузки категории VOLK |
| `tools/legacy/evt_prefetch.py` | Legacy: prefetch событий |
См. [`tools/README.md`](../tools/README.md).
### Документация
| Файл | Содержание |
|------|------------|
| `README.md` | Быстрый старт |
| `docs/ARCHITECTURE.md` | Этот файл |
| `docs/volk-mode.md` | Режим VOLK |
| `docs/telegram-messaging.md` | API `/send_message` |
| `docs/runner-setup.md` | Настройка runner timer |
| `docs/CHANGELOG.md` | История версий |
## Версионирование
- Версия в корне: **`VERSION`** (SemVer: `MAJOR.MINOR.PATCH`).
- При каждом значимом изменении обновлять `VERSION` и запись в `docs/CHANGELOG.md`.
- Правило для агентов: `.cursor/rules/version-and-docs.mdc`.
+98
View File
@@ -0,0 +1,98 @@
# Changelog
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
Версия проекта — в файле [`VERSION`](../VERSION) (SemVer).
## [1.1.4] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: убрано сравнение `min_length` (HTML длиннее plain text от Telegram) — лишние retry, мигание ссылок и обрыв пакетной публикации из UI.
### Changed
- Проверка footer-ссылок: убрано сравнение `min_length` (HTML длиннее plain text от Telegram).
## [1.1.3] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: сравнение по конкретным URL (Инфо/Иду/Подписка, ВК), а не по любому `text_link` или словам в тексте тела события — устраняет ложный «успех» при пакетной публикации.
## [1.1.2] - 2026-09-02
### Fixed
- Проверка успешного edit ссылок: Telegram отдаёт plain text и `text_link` entities, а не `href=` в caption; `message is not modified` трактуется как успех (ссылки уже применены). Устраняет ложные ошибки при успешной публикации.
## [1.1.1] - 2026-09-02
### Fixed
- Публикация постов и событий: второй шаг (edit со строкой ссылок) повторяется до появления `href=` в ответе API; без подтверждённых ссылок запись не помечается опубликованной. Устраняет редкие случаи, когда edit логировался как успех, а ссылки в канале отсутствовали.
## [1.1.0] - 2026-08-30
### Added
- Импорт VK (`vk_load.py`): посты с числом букв меньше `VK_POST_MIN_LETTERS` (по умолчанию 5) сохраняются с названием `- = Пустой пост = -` и без отметки «к публикации».
### Documentation
- Параметр `VK_POST_MIN_LETTERS` в `.env.example` и `.env`.
## [1.0.4] - 2026-08-30
### Changed
- `runner.sh` пишет лог в `logs/runner.log` (каталог создаётся при первом запуске).
## [1.0.3] - 2026-08-30
### Fixed
- Публикация событий (`evtg_publish.py`): картинка скачивается на сервере и уходит в `sendPhoto` файлом, как у постов VK. Раньше в Bot API передавался URL (`http://reg.konvent.ru/...`), Telegram не мог его забрать (`failed to get HTTP URL content`) и пост уходил текстом.
## [1.0.2] - 2026-08-30
### Added
- Шаблон окружения [`.env.example`](../.env.example) для ZILANT и VOLK (секреты — плейсхолдеры).
- `.env` добавлен в [`.gitignore`](../.gitignore).
### Documentation
- README и `docs/ARCHITECTURE.md`: копирование `.env.example``.env` при настройке инстанса.
## [1.0.1] - 2026-08-30
### Changed
- Утилиты перенесены в `tools/`:
- `tools/ops/``register_tg_webhook.py`, `copy_databases.sh` (рядом с `genkey.py`)
- `tools/dev/``send_telegram_example.py`, `volk_telegram_testing.py`, `volk_category_testing.py`
- Скрипты в `tools/` загружают `.env` из корня проекта и добавляют корень в `sys.path` при импорте модулей ядра.
### Documentation
- Обновлены `tools/README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`, `docs/telegram-messaging.md`, `README.md`.
## [1.0.0] - 2026-08-30
### Added
- Файл версии `VERSION` (SemVer).
- Документация: `README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`.
- Каталог `tools/` с README; `tools/ops/genkey.py`, `tools/legacy/evt_prefetch.py`.
- Сезонные deep link и callback с префиксом `SEASON` (`season_links.py`, по умолчанию `zk2026`).
- Интеграция Telegram Bot API через internal relay (`telegram_relay.py`, `RELAY_URL`).
### Changed
- `evt_prefetch.py` перенесён в `tools/legacy/` — не входит в `runner.sh`.
- `genkey.py` перенесён в `tools/ops/`.
- Документация по runner и отправке сообщений — в `docs/`.
### Documentation
- Правило Cursor `.cursor/rules/version-and-docs.mdc` — поддержка версии и changelog при изменениях.
+77
View File
@@ -0,0 +1,77 @@
# Настройка автоматического запуска runner.sh
См. также [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) — состав пайплайна.
Есть два способа настроить запуск `runner.sh` каждый час:
## Вариант 1: Systemd Timer (рекомендуется)
В проекте уже используются systemd-сервисы.
### Установка
1. Скопируйте файлы в systemd:
```bash
sudo cp services/runner.service /etc/systemd/system/
sudo cp services/runner.timer /etc/systemd/system/
```
2. Или используйте скрипт установки:
```bash
chmod +x services/install_runner_timer.sh
sudo bash services/install_runner_timer.sh
```
3. Вручную:
```bash
sudo systemctl daemon-reload
sudo systemctl enable runner.timer
sudo systemctl start runner.timer
```
### Управление
- Статус: `sudo systemctl status runner.timer`
- Остановить: `sudo systemctl stop runner.timer`
- Запустить: `sudo systemctl start runner.timer`
- Отключить: `sudo systemctl disable runner.timer`
- Логи: `sudo journalctl -u runner.service -f`
- Список timers: `sudo systemctl list-timers`
### Расписание
Файл `/etc/systemd/system/runner.timer`:
- `OnCalendar=hourly` — каждый час
- `OnCalendar=*-*-* *:00:00` — в начале каждого часа
- `OnCalendar=*-*-* 0,6,12,18:00:00` — в 0, 6, 12, 18 часов
- `OnCalendar=Mon..Fri 09:00:00` — будни в 9:00
После изменения: `sudo systemctl daemon-reload`
---
## Вариант 2: Cron
```bash
sudo crontab -e
```
```bash
0 * * * * /bin/bash /opt/testbot-git/runner.sh
```
Другие варианты: `30 * * * *`, `*/30 * * * *`, `0 9-17 * * *`.
---
## Состав runner.sh (ZILANT)
`vk_load.py``zk_load.py``db_update_shortname.py``tg_publish.py``evtg_publish.py`
Legacy `tools/legacy/evt_prefetch.py` в пайплайн не входит (с 1.0.0).
Лог пайплайна: `/opt/testbot-git/logs/runner.log` (каталог `logs/` создаётся автоматически).
+94
View File
@@ -0,0 +1,94 @@
# Отправка сообщений через Telegram бота
## Описание
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
1. **API endpoint** для отправки сообщений из других скриптов
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
3. **Обработку ошибок** и логирование всех операций
## Архитектура системы
Система состоит из двух основных компонентов:
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
### Конфигурация портов
Порты и URL настраиваются в файле `.env`:
- `RELAY_URL` - базовый URL **internal_relay** (все исходящие вызовы Bot API идут только через него)
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
- `WEBHOOK_URL` - URL доставки апдейтов от internal_relay к боту
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
## Использование через веб-интерфейс
В заголовке страницы добавлены поля **ID пользователя**, **Сообщение** и кнопка **Отправить**.
## Использование через API
### Endpoint
```
POST /send_message
POST {WEBHOOK_PATH}/send_message
```
### Заголовки
```
Content-Type: application/json
X-API-Secret-Token: <WEBHOOK_SECRET>
```
### Тело запроса
```json
{
"user_id": 123456789,
"message": "Текст сообщения"
}
```
### Пример (Python)
```python
import requests
def send_telegram_message(user_id, message):
url = "http://192.168.0.109:5005/testbot/send_message"
headers = {
"Content-Type": "application/json",
"X-API-Secret-Token": "ваш_секретный_токен",
}
response = requests.post(url, json={"user_id": user_id, "message": message}, headers=headers)
return response.json()
```
### Пример (curl)
```bash
curl -X POST http://localhost:5005/testbot/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
```
Опционально: CLI-скрипт `tools/dev/send_telegram_example.py`.
## Коды ошибок API
- `INVALID_TOKEN`, `NO_DATA`, `NO_USER_ID`, `NO_MESSAGE`, `INVALID_USER_ID`
- `TELEGRAM_ERROR`, `INTERNAL_ERROR`
## Требования
- Бот запущен (webhook через `wsgi_bot.py`)
- Пользователь начал диалог с ботом (`/start`)
См. также [`ARCHITECTURE.md`](ARCHITECTURE.md).
+53
View File
@@ -0,0 +1,53 @@
# Режим VOLK
Режим включается в `.env` (скопируйте из [`.env.example`](../.env.example)):
```env
WORKMODE = "VOLK"
```
Проект поддерживает оба режима — **ZILANT** и **VOLK**; переключение только через `WORKMODE`, без смены кодовой базы.
## Отличия от ZILANT
| Аспект | ZILANT | VOLK |
|--------|--------|------|
| Загрузка данных | `vk_load.py`, `zk_load.py` | `volk_load.py` |
| Публикация | `tg_publish.py`, `evtg_publish.py` | `volk_cat_publish.py` |
| Runner | Полный пайплайн ZILANT | Отдельные кнопки рескана в UI |
| UI | Посты + события VK/Zilant | Категории (площадки) + события VOLK |
## Модули VOLK
### `volk_load.py`
- Загружает список категорий и событий с VOLK API.
- Переменные: `VOLK_CATEGORY_LIST`, `VOLK_CATEGORY_DESC_PREFIX`, `VOLK_EVENT_LIST_PREFIX`, `VOLK_API_VERSION`.
- Вызывается из `db_edit.py` (API `/api/run_volk_rescan`, кнопка «Рескан ВОЛК»).
### `volk_cat_publish.py`
- Публикует категории (площадки) в Telegram-канал.
- Функции `tg_post_category_by_id`, `tg_post_all_categories` — из веб-UI.
### Шаблоны
- `templates/edit_category.html` — редактирование площадки (только VOLK).
- Блоки `{% if g.workmode == 'VOLK' %}` в `templates/index.html`.
## Отладочные скрипты (не в production-пайплайне)
| Файл | Запуск |
|------|--------|
| `tools/dev/volk_category_testing.py` | `python tools/dev/volk_category_testing.py <category_id>` |
| `tools/dev/volk_telegram_testing.py` | `python tools/dev/volk_telegram_testing.py` |
См. [`tools/README.md`](../tools/README.md).
## Переключение режима на сервере
1. Изменить `WORKMODE` в `/opt/testbot-git/.env`.
2. Перезапустить UI: `sudo systemctl restart testedit-git.service`.
3. Responder-бот (`testbot-git`) от режима не зависит; сезонные ссылки общие (`SEASON`).
При смене режима проверьте переменные VOLK/ZILANT в `.env` (префиксы URL, API, `LOG_FILE`).
+50 -6
View File
@@ -46,6 +46,10 @@ def ensure_database_structure():
conn = None
cursor = None
# Проверка режима отладки
log_debug = os.getenv('LOG_DEBUG_DATA', 'false').lower() == 'true'
changes_made = False # Флаг для отслеживания изменений
try:
# Подключение без указания базы данных
conn = mysql.connector.connect(
@@ -56,9 +60,24 @@ def ensure_database_structure():
cursor = conn.cursor(buffered=True) # Используем буферизованный курсор
# Создание базы данных если не существует
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
# Проверяем существование базы данных
cursor.execute("""
SELECT COUNT(*)
FROM information_schema.schemata
WHERE schema_name = %s
""", (DB_CONFIG['database'],))
db_exists = cursor.fetchone()[0] > 0
if not db_exists:
cursor.execute(f"CREATE DATABASE {DB_CONFIG['database']} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
changes_made = True
log_message(f"Database {DB_CONFIG['database']} created")
else:
if log_debug:
log_message(f"Database {DB_CONFIG['database']} already exists")
cursor.execute(f"USE {DB_CONFIG['database']}")
log_message("Database checked/created successfully")
# Список таблиц и их структур
tables = {
@@ -185,9 +204,11 @@ def ensure_database_structure():
if cursor.fetchone()[0] == 0:
# Таблица не существует, создаем её
cursor.execute(table_sql)
changes_made = True
log_message(f"Table {table_name} created")
else:
log_message(f"Table {table_name} exists")
if log_debug:
log_message(f"Table {table_name} exists")
except mysql.connector.Error as err:
log_message(f"Error checking/creating table {table_name}: {err}")
@@ -206,8 +227,10 @@ def ensure_database_structure():
"INSERT INTO users (username, password) VALUES (%s, %s)",
(username, hashed_password)
)
changes_made = True
log_message(f"Created default admin user: {username}")
else:
# Важное предупреждение - всегда логируем
log_message("Environment variables DEFNM/DEFPW not set. Admin not created.")
except mysql.connector.Error as err:
log_message(f"Error creating default admin: {err}")
@@ -257,9 +280,11 @@ def ensure_database_structure():
if cursor.fetchone()[0] == 0:
# Столбец не существует, добавляем его
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column} {column_type}")
changes_made = True
log_message(f"Added column {column} to table {table_name}")
else:
log_message(f"Column {column} already exists in table {table_name}")
if log_debug:
log_message(f"Column {column} already exists in table {table_name}")
except mysql.connector.Error as err:
log_message(f"Error checking/adding column {column} to table {table_name}: {err}")
@@ -309,13 +334,32 @@ def ensure_database_structure():
for index_sql in all_indexes:
try:
cursor.execute(index_sql)
# Извлекаем имя индекса и таблицы из SQL
index_name = index_sql.split('IF NOT EXISTS')[1].split(' ON ')[0].strip()
log_message(f"Index created: {index_name}")
table_name = index_sql.split(' ON ')[1].split(' (')[0].strip()
# Проверяем существование индекса
cursor.execute("""
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = %s AND table_name = %s AND index_name = %s
""", (DB_CONFIG['database'], table_name, index_name))
index_exists = cursor.fetchone()[0] > 0
if not index_exists:
# Индекс не существует, создаем его
cursor.execute(index_sql)
changes_made = True
log_message(f"Index created: {index_name}")
else:
if log_debug:
log_message(f"Index {index_name} already exists")
except mysql.connector.Error as err:
log_message(f"Error creating index: {err}")
conn.commit()
# Финальное сообщение выводится всегда
log_message("Database structure ensured successfully")
except mysql.connector.Error as err:
+328 -197
View File
@@ -6,8 +6,18 @@ import time
import html
import httpx
from datetime import datetime, timezone, timedelta
from urllib.parse import urlparse, unquote
from dotenv import load_dotenv
from formatter import get_event_text
from telegram_relay import (
bot_api_method_url,
EDIT_LINKS_INITIAL_DELAY_SEC,
EDIT_LINKS_MAX_ATTEMPTS,
EDIT_LINKS_RETRY_INTERVAL_SEC,
published_message_has_expected_links,
extract_text_link_urls,
)
from season_links import subscription_start_link
# Загрузка переменных окружения
load_dotenv()
@@ -90,14 +100,188 @@ def prepare_text(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):
"""Публикация одного события"""
try:
# Форматирование текста
tags = format_tags(event_data['tags'])
unit_name = prepare_text(event_data['unit_name'] or "")
unit_name_raw = event_data.get('unit_name') or ""
name = prepare_text(event_data['name'] or "")
# Формируем название площадки с возможной ссылкой
unit_name = ""
if unit_name_raw:
unit_tg_id = event_data.get('unit_tg_id')
unit_title = event_data.get('unit_title') or unit_name_raw
unit_title_escaped = prepare_text(unit_title)
if unit_tg_id:
# Если площадка опубликована, делаем ссылку на сообщение
# Используем логику из format_channel_link из tg_mainbot.py
if CHANNEL_ID.startswith('@'):
# Публичный канал
channel_link = f"https://t.me/{CHANNEL_ID[1:]}/{unit_tg_id}"
elif CHANNEL_ID.startswith('-100'):
# Приватный канал с префиксом -100
channel_link = f"https://t.me/c/{CHANNEL_ID[4:]}/{unit_tg_id}"
else:
# Приватный канал без префикса или другой формат
channel_link = f"https://t.me/c/{CHANNEL_ID}/{unit_tg_id}"
# Формируем ссылку на сообщение
unit_name = f'<a href="{channel_link}">{unit_title_escaped}</a>'
else:
# Если площадка не опубликована, просто текст
unit_name = unit_title_escaped
# Получаем и подготавливаем описание события
about_raw = event_data['about']
@@ -116,12 +300,22 @@ async def tg_post_event(httpx_client, event_data):
number = event_data['number']
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:
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>')
links_parts.append(
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", placeholder_message_id)}">Подписка</a>'
)
if links_parts:
links_line_placeholder = ' | '.join(links_parts)
links_line_length = len(links_line_placeholder)
else:
links_line_placeholder = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>'
links_line_length = len(links_line_placeholder)
links_line_placeholder = ""
links_line_length = 0
# Определяем максимальную длину в зависимости от типа сообщения
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}")
# Публикация первоначального сообщения без ссылок
send_photo_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendPhoto"
send_message_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
message_is_photo = False
if has_image:
try:
logger.info(f"Попытка отправки фото для события {event_data['number']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}")
photo_payload = {
logger.info(
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,
'photo': image_url,
'caption': initial_text,
'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:
photo_result = photo_response.json()
if photo_result.get('ok'):
message_data = photo_result.get('result', {})
message_id = message_data.get('message_id')
message_is_photo = True
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение
@@ -317,167 +522,51 @@ async def tg_post_event(httpx_client, event_data):
raise
# Формируем финальный текст с ссылками
links_parts = []
if DESC_PREFIX:
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
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>')
links_parts.append(
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)}">Подписка</a>'
)
if links_parts:
links_line = ' | '.join(links_parts)
final_text = f"{initial_text}\n\n{links_line}"
else:
links_line = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>'
final_text = f"{initial_text}\n\n{links_line}"
final_text = initial_text
await asyncio.sleep(2)
expected_urls = []
if DESC_PREFIX:
expected_urls.append(f"{DESC_PREFIX}{number}/")
if USE_SUBSCRIPTION_BOT:
expected_urls.append(f"{PZK_PREFIX}{number}/")
expected_urls.append(
subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)
)
# Редактируем сообщение, добавляя ссылки
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 final_text == initial_text:
logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
return message_id
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:
# Ошибка при редактировании подписи - пробуем редактировать текст
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:
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" 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 edit_result.get('ok'):
logger.info(f"Текст успешно отредактирован для события {event_data['number']}")
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:
edit_result = edit_text_response.json()
if edit_result.get('ok'):
logger.info(f"Текст успешно отредактирован для события {event_data['number']}")
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
entity_label = f"события {event_data['number']}"
links_ok = await _edit_event_add_links(
httpx_client, message_is_photo, message_id, final_text, entity_label,
expected_urls,
)
if not links_ok and message_is_photo:
links_ok = await _edit_event_add_links(
httpx_client, False, message_id, final_text, entity_label,
expected_urls,
)
if not links_ok:
logger.error(
f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
f"но ссылки не добавлены"
)
return None
return message_id # Возвращаем только message_id
@@ -529,7 +618,8 @@ async def tg_post_event_by_id(id_event):
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
cursorclass=pymysql.cursors.DictCursor,
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
)
# Проверка времени изменения только в режиме ZILANT
@@ -566,6 +656,24 @@ async def tg_post_event_by_id(id_event):
logger.info(f"Событие ID {id_event} не найдено или уже опубликовано")
return
# Получаем информацию о категории (площадке), если указана unit_name
unit_tg_id = None
unit_title = None
if event.get('unit_name'):
cursor.execute("""
SELECT TITLE, tg_id
FROM categories
WHERE ID = %s
""", (event['unit_name'],))
category = cursor.fetchone()
if category:
unit_title = category.get('TITLE')
unit_tg_id = category.get('tg_id')
# Добавляем информацию о категории в данные события
event['unit_tg_id'] = unit_tg_id
event['unit_title'] = unit_title
old_message_id = event.get('tg_message_id') # Сохраняем старый ID сообщения
# Создаем httpx клиент с отключенным HTTP/2
httpx_client = httpx.AsyncClient(
@@ -573,6 +681,7 @@ async def tg_post_event_by_id(id_event):
timeout=20.0,
follow_redirects=True
)
tg_message_id = None
try:
tg_message_id = await tg_post_event(httpx_client, event) # Получаем новый message_id
finally:
@@ -582,29 +691,58 @@ async def tg_post_event_by_id(id_event):
# Используем UTC время для записи в базу данных
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
# Обновляем подписки если был предыдущий message_id
if old_message_id:
cursor.execute("""
UPDATE marks_evt
SET tg_event_id = %s
WHERE tg_event_id = %s
""", (tg_message_id, old_message_id))
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
# Обновляем базу данных в отдельном блоке try-except для гарантии сохранения
try:
# Обновляем подписки если был предыдущий message_id
if old_message_id:
cursor.execute("""
UPDATE marks_evt
SET tg_event_id = %s
WHERE tg_event_id = %s
""", (tg_message_id, old_message_id))
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
cursor.execute("""
UPDATE events
SET is_posted_tg = True,
tg_message_id = %s,
tg_posted_date = %s
WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event))
conn.commit()
logger.info(f"Событие номер {event['number']} успешно опубликовано")
# Обновляем данные о публикации события
cursor.execute("""
UPDATE events
SET is_posted_tg = True,
tg_message_id = %s,
tg_posted_date = %s
WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event))
conn.commit()
logger.info(f"Событие номер {event['number']} успешно опубликовано. Данные сохранены в БД: message_id={tg_message_id}, date={current_time_utc}")
except Exception as db_error:
logger.error(f"Ошибка при сохранении данных о публикации события {id_event} в БД: {db_error}")
# Пытаемся выполнить commit даже при ошибке
try:
conn.rollback()
# Повторная попытка обновления
cursor.execute("""
UPDATE events
SET is_posted_tg = True,
tg_message_id = %s,
tg_posted_date = %s
WHERE id_event = %s
""", (tg_message_id, current_time_utc, id_event))
conn.commit()
logger.info(f"Данные о публикации события {id_event} успешно сохранены после повторной попытки")
except Exception as retry_error:
logger.error(f"Критическая ошибка при сохранении данных о публикации события {id_event}: {retry_error}")
raise
else:
logger.error(f"Не удалось опубликовать событие ID {id_event}")
except RetryAfterException as e:
# Обработка RetryAfter (FloodWait)
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: RetryAfter")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" retry_after: {e.retry_after}")
logger.error(f" Полное представление: {repr(e)}")
raise
except Exception as e:
# Проверяем, не RetryAfter ли это
# Проверяем, не 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)
@@ -615,19 +753,11 @@ async def tg_post_event_by_id(id_event):
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_event_by_id для события ID {id_event}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
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" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
finally:
if conn:
@@ -644,7 +774,8 @@ async def tg_post_all_events():
password=MDB_PW,
database=MDBASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
cursorclass=pymysql.cursors.DictCursor,
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
)
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"
# Список скриптов для запуска в порядке выполнения
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "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")
# Legacy prefetch (не в пайплайне): tools/legacy/evt_prefetch.py
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tools/legacy/evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py")
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py")
# Интервалы между скриптами (в секундах)
# DELAYS=(2 2 2 2 30)
DELAYS=(2 2 2 30)
DELAYS=(2 2 2)
# Лог-файл
LOG_FILE="$SCRIPTS_DIR/runner.log"
LOG_DIR="$SCRIPTS_DIR/logs"
LOG_FILE="$LOG_DIR/runner.log"
# Функция для логирования
log_message() {
@@ -24,11 +27,20 @@ log_message() {
# Проверка и настройка лог-файла
setup_logfile() {
# Проверяем, существует ли файл и можно ли в него писать
if [ ! -d "$LOG_DIR" ]; then
if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
if sudo mkdir -p "$LOG_DIR" 2>/dev/null; then
sudo chown "$(whoami)" "$LOG_DIR"
sudo chmod 755 "$LOG_DIR"
else
echo "ОШИБКА: Не удалось создать каталог $LOG_DIR!"
exit 1
fi
fi
fi
if [ ! -f "$LOG_FILE" ]; then
# Пытаемся создать файл
if ! touch "$LOG_FILE" 2>/dev/null; then
# Если не получается, пробуем с sudo
if sudo touch "$LOG_FILE" 2>/dev/null; then
sudo chown $(whoami) "$LOG_FILE"
sudo chmod 644 "$LOG_FILE"
@@ -40,9 +52,7 @@ setup_logfile() {
fi
fi
# Проверяем права на запись
if [ ! -w "$LOG_FILE" ]; then
# Пытаемся изменить права
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
log_message "Изменены права на лог-файл: $LOG_FILE"
else
@@ -112,4 +122,3 @@ done
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
log_message "=== ЗАВЕРШЕНИЕ ==="
+127
View File
@@ -0,0 +1,127 @@
"""
Сезонные deep link и callback для подписок.
Формат текущего сезона (SEASON=zk2026):
start=zk2026_post_{tg_message_id}
start=zk2026_event_{tg_message_id}
callback: subscribe_zk2026_{id} / unsubscribe_zk2026_{id}
callback: subscribe_evt_zk2026_{id} / unsubscribe_evt_zk2026_{id}
Старые форматы (post_123, event_123, subscribe_123, …) и чужой сезон
считаются завершённым прошлым сезоном.
"""
import os
import re
from typing import Optional, Tuple
from dotenv import load_dotenv
_ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
load_dotenv(_ENV_PATH)
load_dotenv()
SEASON = (os.getenv("SEASON") or "zk2026").strip().strip('"').strip("'")
# start: zk2026_post_123 / zk2026_event_123
_START_RE = re.compile(
rf"^(?P<season>[A-Za-z0-9]+)_((?P<kind>post|event)_)(?P<id>\d+)$"
)
# legacy start: post_123 / event_123
_START_LEGACY_RE = re.compile(r"^(?P<kind>post|event)_(?P<id>\d+)$")
# callback posts: subscribe_zk2026_123
_CB_POST_RE = re.compile(
r"^(?P<action>subscribe|unsubscribe)_(?P<season>[A-Za-z0-9]+)_(?P<id>\d+)$"
)
# legacy: subscribe_123
_CB_POST_LEGACY_RE = re.compile(r"^(?P<action>subscribe|unsubscribe)_(?P<id>\d+)$")
# callback events: subscribe_evt_zk2026_123
_CB_EVT_RE = re.compile(
r"^(?P<action>subscribe|unsubscribe)_evt_(?P<season>[A-Za-z0-9]+)_(?P<id>\d+)$"
)
# legacy: subscribe_evt_123
_CB_EVT_LEGACY_RE = re.compile(r"^(?P<action>subscribe|unsubscribe)_evt_(?P<id>\d+)$")
def subscription_start_link(bot_name: str, kind: str, message_id) -> str:
"""HTTPS deep link на бота: kind = 'post' | 'event'."""
payload = f"{SEASON}_{kind}_{message_id}"
return f"https://t.me/{bot_name}?start={payload}"
def callback_subscribe_post(message_id) -> str:
return f"subscribe_{SEASON}_{message_id}"
def callback_unsubscribe_post(message_id) -> str:
return f"unsubscribe_{SEASON}_{message_id}"
def callback_subscribe_event(message_id) -> str:
return f"subscribe_evt_{SEASON}_{message_id}"
def callback_unsubscribe_event(message_id) -> str:
return f"unsubscribe_evt_{SEASON}_{message_id}"
def parse_start_payload(payload: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Разбор аргумента /start.
Returns:
(kind, id, is_current_season)
kind: 'post' | 'event' | None
is_current_season: False → показать «сезон завершён»
"""
if not payload:
return None, None, True
m = _START_RE.match(payload)
if m:
return m.group("kind"), m.group("id"), m.group("season") == SEASON
m = _START_LEGACY_RE.match(payload)
if m:
return m.group("kind"), m.group("id"), False
# Неизвестный payload — считаем устаревшим/чужим, без действия по id
return None, None, False
def parse_post_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Returns:
(action, id, is_current_season) для кнопок подписки на посты.
action: 'subscribe' | 'unsubscribe' | None
"""
if data.startswith("subscribe_evt_") or data.startswith("unsubscribe_evt_"):
return None, None, True
m = _CB_POST_RE.match(data)
if m:
return m.group("action"), m.group("id"), m.group("season") == SEASON
m = _CB_POST_LEGACY_RE.match(data)
if m:
return m.group("action"), m.group("id"), False
return None, None, True
def parse_event_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Returns:
(action, id, is_current_season) для кнопок подписки на события.
"""
m = _CB_EVT_RE.match(data)
if m:
return m.group("action"), m.group("id"), m.group("season") == SEASON
m = _CB_EVT_LEGACY_RE.match(data)
if m:
return m.group("action"), m.group("id"), False
return None, None, True
+3
View File
@@ -0,0 +1,3 @@
# Перенесено
Документация: [`docs/runner-setup.md`](../docs/runner-setup.md)
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Скрипт для установки systemd timer для runner.sh
# Путь к директории проекта
PROJECT_DIR="/opt/testbot-git"
SERVICES_DIR="$PROJECT_DIR/services"
echo "Установка systemd timer для runner.sh..."
# Копирование файлов в systemd
sudo cp "$SERVICES_DIR/runner.service" /etc/systemd/system/
sudo cp "$SERVICES_DIR/runner.timer" /etc/systemd/system/
# Перезагрузка systemd для чтения новых файлов
sudo systemctl daemon-reload
# Включение и запуск timer
sudo systemctl enable runner.timer
sudo systemctl start runner.timer
# Проверка статуса
echo ""
echo "Статус timer:"
sudo systemctl status runner.timer --no-pager
echo ""
echo "Список всех активных timers:"
sudo systemctl list-timers --no-pager
echo ""
echo "Для просмотра логов используйте:"
echo " sudo journalctl -u runner.service -f"
echo " sudo journalctl -u runner.timer -f"
+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]
User=root
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
[Install]
+1 -1
View File
@@ -6,7 +6,7 @@ After=network.target
[Service]
User=root
WorkingDirectory=/opt/testbot-git
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5006 --workers 4 --timeout=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
[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>
</div>
<!-- Опубликовано в TG -->
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" id="is_tg_published">
<label class="form-check-label" for="is_tg_published">Опубликовано в TG</label>
</div>
<!-- Дата публикации в TG -->
<div class="d-flex align-items-center mb-3">
<div class="label-narrow">
<label for="tg_published_date" class="form-label">Дата публикации в TG:</label>
</div>
<div style="flex: 0 0 300px;">
<input type="datetime-local" class="form-control" id="tg_published_date">
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<button type="button" class="btn btn-secondary me-2" id="cancel-btn">Отмена</button>
<button type="submit" class="btn btn-primary">
@@ -210,6 +226,17 @@
document.getElementById('image_url').value = category.image_url || '';
document.getElementById('tg_id').value = category.tg_id || '';
document.getElementById('marked_for_publication').checked = category.marked_for_publication || false;
document.getElementById('is_tg_published').checked = category.is_tg_published || false;
// Устанавливаем дату публикации, если она есть
if (category.tg_published_date) {
// Преобразуем ISO дату в формат datetime-local (YYYY-MM-DDTHH:mm)
const date = new Date(category.tg_published_date);
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
document.getElementById('tg_published_date').value = localDate.toISOString().slice(0, 16);
} else {
document.getElementById('tg_published_date').value = '';
}
// Показываем предпросмотр изображения, если есть
if (category.image_url) {
@@ -226,13 +253,23 @@
// Сохранение категории
async function saveCategory(categoryId) {
const form = document.getElementById('category-form');
const tgPublishedDateValue = document.getElementById('tg_published_date').value;
let tgPublishedDate = null;
if (tgPublishedDateValue) {
// Преобразуем локальное время в UTC для сохранения в БД
const localDate = new Date(tgPublishedDateValue);
tgPublishedDate = localDate.toISOString().slice(0, 19).replace('T', ' ');
}
const formData = {
id: document.getElementById('id').value.trim(),
title: document.getElementById('title').value.trim(),
description: document.getElementById('description').value.trim(),
image_url: document.getElementById('image_url').value.trim(),
tg_id: document.getElementById('tg_id').value.trim() || null,
marked_for_publication: document.getElementById('marked_for_publication').checked
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) {
+245 -12
View File
@@ -469,12 +469,18 @@
<button class="btn btn-primary" id="rescan-btn">
<i class="fas fa-search me-1"></i> Рескан ВК
</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">
<i class="fas fa-paper-plane me-1"></i> Опубликовать
<i class="fas fa-paper-plane me-1"></i> Опубликовать все
</button>
<button class="btn btn-info" id="generate-names-btn">
<i class="fas fa-font me-1"></i> Названия
</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;">
<span>Выбранное событие: </span>
<span id="post-selected-event-name"></span>
@@ -578,6 +584,9 @@
<i class="fas fa-search me-1"></i> Рескан Зилант-сервера
</button>
{% 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">
<i class="fas fa-paper-plane me-1"></i> Опубликовать все
</button>
@@ -668,6 +677,12 @@
<!-- Вкладка площадок -->
<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">
<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">
<i class="fas fa-sync-alt me-1"></i> Площадки
</button>
@@ -924,8 +939,10 @@
loadPosts();
// Добавляем обработчики для кнопок запуска скриптов
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('generate-names-btn').addEventListener('click', generateAllShortnames);
document.getElementById('test-post-btn').addEventListener('click', runTestPost);
document.getElementById('refresh-events-btn').addEventListener('click', loadEvents);
// Обработчики для кнопок добавления события
document.getElementById('add-event-btn').addEventListener('click', () => {
@@ -1005,8 +1022,17 @@
zkRescanBtn.addEventListener('click', runZkRescan);
}
{% endif %}
document.getElementById('mark-all-events-pub-btn').addEventListener('click', markAllEventsForPublication);
document.getElementById('publish-all-events-btn').addEventListener('click', publishAllEvents);
{% 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');
if (loadCategoriesBtn) {
loadCategoriesBtn.addEventListener('click', loadCategories);
@@ -1157,6 +1183,64 @@
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() {
const currentEventId = selectedEventId;
@@ -1460,10 +1544,10 @@
alert('Произошла ошибка при генерации названий');
});
}
// Функция для генерации названия для конкретного поста
function generateShortname(postId) {
showProcessingMessage("Генерация названия для поста...");
fetch(`${PREFIX}/api/generate_shortname/${postId}`, {
// Функция для запуска тестового поста
function runTestPost() {
showProcessingMessage("Публикация тестового сообщения...");
fetch(`${PREFIX}/api/test_post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -1474,19 +1558,67 @@
.then(data => {
hideProcessingMessage();
if (data.success) {
alert('Название успешно сгенерировано: ' + data.shortname);
loadPosts(); // Перезагружаем список постов
// Восстанавливаем выбранный пост
highlightSelectedPost(postId);
showPostDetails(postId);
alert('Тестовое сообщение успешно опубликовано!');
} else {
alert('Ошибка генерации названия: ' + (data.error || 'неизвестная ошибка'));
alert('Ошибка публикации тестового сообщения: ' + (data.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();
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></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">
${category.marked_for_publication ?
'<i class="fas fa-times-circle me-1"></i>Снять с публикации' :
@@ -2103,6 +2238,14 @@
detailsHtml += `</div>`;
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');
if (togglePubBtn) {
@@ -2226,6 +2369,95 @@
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() {
const tableBody = document.getElementById('posts-table');
@@ -2759,6 +2991,7 @@
window.deletePost = deletePost;
window.deleteEvent = deleteEvent;
window.generateAllShortnames = generateAllShortnames;
window.runTestPost = runTestPost;
window.generateShortname = generateShortname;
window.publishPost = publishPost;
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

+532 -167
View File
File diff suppressed because it is too large Load Diff
+395 -220
View File
@@ -7,9 +7,36 @@ import html
import json
import re
import httpx
import traceback
import io
import sys
from datetime import datetime, timezone
from dotenv import load_dotenv
from formatter import get_event_text
from telegram import Bot
from telegram.error import TelegramError, TimedOut
from telegram.request import HTTPXRequest
from telegram_relay import (
get_ptb_base_urls,
get_relay_url,
EDIT_LINKS_INITIAL_DELAY_SEC,
EDIT_LINKS_MAX_ATTEMPTS,
EDIT_LINKS_RETRY_INTERVAL_SEC,
published_message_has_expected_links,
extract_text_link_urls,
)
from season_links import subscription_start_link
# Настройка anyio для правильной работы в отдельном потоке
# Устанавливаем правильный backend для anyio (используем asyncio)
try:
import anyio
# Устанавливаем backend для anyio явно
if not hasattr(anyio, '_backend'):
# Используем asyncio backend
os.environ.setdefault('ANYIO_BACKEND', 'asyncio')
except ImportError:
pass
# Загрузка переменных окружения
load_dotenv()
@@ -88,6 +115,133 @@ def has_org_tag(text):
pattern = r'#?' + re.escape(ORG_MESSAGE_TAG) + r'\b'
return bool(re.search(pattern, text, re.IGNORECASE))
def log_telegram_error(response, context=""):
"""Логирует полную информацию об ошибке от Telegram API
Работает как с httpx.Response, так и с requests.Response
"""
try:
if response is not None:
logger.error(f"{context} Статус код: {response.status_code}")
logger.error(f"{context} Заголовки ответа: {dict(response.headers)}")
try:
# Работает для обоих типов ответов (httpx и requests)
error_data = response.json()
logger.error(f"{context} Полный ответ от Telegram API: {json.dumps(error_data, ensure_ascii=False, indent=2)}")
except:
# Для requests используем text, для httpx тоже text
response_text = getattr(response, 'text', str(response.content[:500]) if hasattr(response, 'content') else 'N/A')
logger.error(f"{context} Текст ответа: {response_text}")
else:
logger.error(f"{context} Ответ от Telegram API отсутствует (None)")
except Exception as e:
logger.error(f"{context} Ошибка при логировании информации об ошибке: {e}")
async def download_image(image_url, httpx_client):
"""Скачивает изображение по URL и возвращает его содержимое в виде bytes"""
try:
logger.info(f"Скачивание изображения: {image_url}")
download_start_time = time.time()
response = await httpx_client.get(image_url, timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0))
response.raise_for_status()
download_duration = time.time() - download_start_time
image_data = response.content
logger.info(f"Изображение скачано за {download_duration:.2f} секунд, размер: {len(image_data)} байт")
return image_data
except Exception as e:
logger.error(f"Ошибка при скачивании изображения {image_url}: {e}")
raise
async def _edit_post_add_links(
bot, message_id, message_has_image, final_text, vk_post_id, expected_urls
):
"""
Второй шаг публикации: editMessageCaption/Text со ссылками.
Повторяет попытку, пока в ответе API нет всех expected_urls в text_link entities.
"""
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
try:
if message_has_image:
edited = await bot.edit_message_caption(
chat_id=CHANNEL_ID,
message_id=message_id,
caption=final_text,
parse_mode='HTML',
)
published_text = edited.caption or ""
entities = edited.caption_entities or []
else:
edited = await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode='HTML',
disable_web_page_preview=True,
)
published_text = edited.text or ""
entities = edited.entities or []
if published_message_has_expected_links(entities, expected_urls):
logger.info(
f"Ссылки добавлены для записи VK ID {vk_post_id} "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
found = extract_text_link_urls(entities)
logger.warning(
f"editMessage* ok, но footer-ссылки не подтверждены "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для VK ID {vk_post_id}; "
f"ожидались {expected_urls}, найдены text_link: {found}"
)
except TimedOut as e:
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
logger.error(
f"Таймаут редактирования ссылок для VK ID {vk_post_id} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
)
return False
logger.warning(
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}, повтор"
)
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
raise RetryAfterException(getattr(e, 'retry_after', 60))
err = str(e).lower()
if "not modified" in err:
logger.info(
f"Caption уже содержит ссылки для VK ID {vk_post_id} "
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"Ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}: {e}"
)
except RetryAfterException:
raise
except Exception as e:
logger.warning(
f"Неожиданная ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для VK ID {vk_post_id}: {e}"
)
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
logger.error(
f"Не удалось добавить строку ссылок для VK ID {vk_post_id} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
)
return False
async def publish_to_tg(vk_post_id):
"""Публикация одной записи по VK post ID"""
logger.info(f"Запуск публикации для записи VK ID {vk_post_id}")
@@ -117,13 +271,48 @@ async def publish_to_tg(vk_post_id):
logger.info(f"Запись VK ID {vk_post_id} не найдена или уже опубликована")
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(
http2=False,
timeout=20.0,
timeout=timeout_config,
follow_redirects=True
)
# Параметры для повторных попыток при таймаутах
max_retries = 10
retry_interval = 5
# Переменная для отслеживания успешности публикации
message_id = None
publication_successful = False
# Флаг, указывающий, было ли отправлено изображение (True) или текстовое сообщение (False)
message_has_image = False
try:
# Проверяем наличие организационного хештега
is_org_message = has_org_tag(post['text'])
@@ -140,7 +329,9 @@ async def publish_to_tg(vk_post_id):
# Ссылка на подписку (только для событий и если включен бот подписки)
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 ""
@@ -173,108 +364,151 @@ async def publish_to_tg(vk_post_id):
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:
try:
photo_payload = {
'chat_id': CHANNEL_ID,
'photo': image_url,
'caption': text,
'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY
}
photo_response = await httpx_client.post(send_photo_url, json=photo_payload)
# Логируем информацию об изображении перед отправкой
logger.info(f"Отправка изображения для записи VK ID {vk_post_id}")
logger.info(f"URL изображения: {image_url}")
logger.info(f"Длина URL изображения: {len(image_url)} символов")
logger.info(f"Длина подписи: {len(text)} символов")
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')
else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение
error_code = photo_result.get('error_code')
if error_code == 429:
retry_after = photo_result.get('parameters', {}).get('retry_after', 60)
# Скачиваем изображение
image_data = None
try:
image_data = await download_image(image_url, httpx_client)
except Exception as download_error:
logger.error(f"Ошибка при скачивании изображения для записи VK ID {vk_post_id}: {download_error}")
raise # Пробрасываем ошибку, чтобы перейти к fallback
file_size_mb = len(image_data) / (1024 * 1024)
logger.info(f"Размер файла: {file_size_mb:.2f} МБ")
# Повторные попытки отправки фото при таймауте (до 10 раз)
message = None
for attempt in range(1, max_retries + 1):
try:
image_file = io.BytesIO(image_data)
message = await bot.send_photo(
chat_id=CHANNEL_ID,
photo=image_file,
caption=text,
parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY
)
message_id = message.message_id
message_has_image = True
logger.info(f"Сообщение с изображением успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при публикации изображения (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при публикации изображения после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise # Пробрасываем таймаут наверх
except TelegramError as e:
# Проверяем, является ли это ошибкой FloodWait (429)
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
raise Exception(f"API error: {photo_result.get('description', 'Unknown error')}")
else:
if photo_response.status_code == 429:
retry_after = 60
try:
error_data = photo_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
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:
raise
except Exception as e:
# Если не удалось отправить изображение, пробуем отправить текстовое сообщение
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
text_payload = {
'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)
message_has_image = False # Отправляем текстовое сообщение вместо изображения
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')
else:
error_code = text_result.get('error_code')
if error_code == 429:
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
# Повторные попытки отправки текста (fallback) при таймауте
for attempt in range(1, max_retries + 1):
try:
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=text,
parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True
)
message_id = message.message_id
logger.info(f"Текстовое сообщение (fallback) успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при отправке текста (fallback) (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при отправке текста (fallback) после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise
except TelegramError as e:
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
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 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:
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
text_payload = {
'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)
message_has_image = False # Отправляем текстовое сообщение
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')
else:
error_code = text_result.get('error_code')
if error_code == 429:
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
# Повторные попытки отправки текста при таймауте
message = None
for attempt in range(1, max_retries + 1):
try:
message = await bot.send_message(
chat_id=CHANNEL_ID,
text=text,
parse_mode='HTML',
disable_notification=PUBLISH_SILENTLY,
disable_web_page_preview=True
)
message_id = message.message_id
logger.info(f"Текстовое сообщение успешно опубликовано. Message ID: {message_id}")
break # Успешная публикация, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при публикации текста (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
await asyncio.sleep(retry_interval)
else:
logger.error(f"Таймаут при публикации текста после {max_retries} попыток для записи VK ID {vk_post_id}")
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
raise # Пробрасываем таймаут наверх
except TelegramError as e:
# Проверяем, является ли это ошибкой FloodWait (429)
if hasattr(e, 'retry_after') or '429' in str(e):
retry_after = getattr(e, 'retry_after', 60)
raise RetryAfterException(retry_after)
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 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:
@@ -284,104 +518,28 @@ async def publish_to_tg(vk_post_id):
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
if post['is_event'] and USE_SUBSCRIPTION_BOT:
updated_links.append(f'<a href="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)
final_text = f"{text}\n\n{links_line}"
await asyncio.sleep(2) # задержка между обращениями к телеграм
# Редактируем сообщение, добавляя ссылки
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 and not is_org_message:
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 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}")
expected_urls = []
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
expected_urls.append(post['vk_post_url'])
if post['is_event'] and USE_SUBSCRIPTION_BOT:
expected_urls.append(
subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
)
if not await _edit_post_add_links(
bot, message_id, message_has_image, final_text, vk_post_id,
expected_urls,
):
logger.error(
f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
f"но ссылки не добавлены — published_in_tg не обновляем"
)
return False
await asyncio.sleep(2) # задержка между обращениями к телеграм
@@ -392,9 +550,8 @@ async def publish_to_tg(vk_post_id):
# Парсим варианты ответа
options = json.loads(post['poll_options'])
# Отправляем опрос (в каналах можно отправлять только анонимные опросы)
send_poll_url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendPoll"
poll_payload = {
# Подготавливаем параметры для опроса
poll_kwargs = {
'chat_id': CHANNEL_ID,
'question': post['poll_question'],
'options': options,
@@ -407,41 +564,55 @@ async def publish_to_tg(vk_post_id):
if post['poll_end_date']:
# Преобразуем datetime в Unix timestamp
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:
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)
if poll_response.status_code == 200:
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')
# Повторные попытки отправки опроса при таймауте
poll_message = None
for attempt in range(1, max_retries + 1):
try:
poll_message = await bot.send_poll(**poll_kwargs)
poll_message_id = poll_message.message_id
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
else:
error_code = poll_result.get('error_code')
if error_code == 429:
retry_after = poll_result.get('parameters', {}).get('retry_after', 60)
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 Exception(f"API error: {poll_result.get('description', 'Unknown error')}")
else:
if poll_response.status_code == 429:
retry_after = 60
try:
error_data = poll_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
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:
raise
except Exception as 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")
cursor.execute("""
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"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
raise
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при публикации записи VK ID {vk_post_id}: {e}")
except TimedOut as e:
logger.error(f"ТАЙМАУТ при публикации записи VK ID {vk_post_id}")
logger.error(f"Тип исключения: {type(e).__name__}")
logger.error(f"Сообщение об ошибке: {str(e)}")
logger.error(f"Полная информация об исключении: {repr(e)}")
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
# Убеждаемся, что БД не обновляется при таймауте
return False
except Exception as e:
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"Необходимо подождать {retry_after} секунд перед следующей попыткой")
raise RetryAfterException(retry_after)
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при публикации записи VK ID {vk_post_id}: {e}")
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
logger.error(f"Детали ошибки: {repr(e)}")
# Убеждаемся, что БД не обновляется при ошибке
return False
finally:
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
"""
Пример скрипта для отправки сообщений через Telegram бота
Использование: python send_telegram_example.py <user_id> <message>
Пример скрипта для отправки сообщений через Telegram бота.
Использование (из корня проекта):
python tools/dev/send_telegram_example.py <user_id> <message>
"""
import sys
@@ -9,6 +11,12 @@ import requests
import json
import os
from dotenv import load_dotenv
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
def send_message(user_id, message_text, bot_url=None, api_token=None):
"""
Отправляет сообщение пользователю через Telegram бота
@@ -64,8 +72,8 @@ def send_message(user_id, message_text, bot_url=None, api_token=None):
def main():
if len(sys.argv) != 3:
print("Использование: python send_telegram_example.py <user_id> <message>")
print("Пример: python send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
print("Использование: python tools/dev/send_telegram_example.py <user_id> <message>")
print("Пример: python tools/dev/send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
sys.exit(1)
try:
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""
Тестовый модуль для проверки загрузки описания категории.
Использование: python volk_category_testing.py <category_id>
Использование (из корня проекта):
python tools/dev/volk_category_testing.py <category_id>
"""
import os
import sys
@@ -15,7 +17,8 @@ from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError
from dotenv import load_dotenv
# Загрузка переменных окружения
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
# Параметры из .env
@@ -260,8 +263,8 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Примеры использования:
python 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
python tools/dev/volk_category_testing.py volk26_nri volk26_other volk26_third
"""
)
parser.add_argument(
@@ -8,9 +8,16 @@ import json
from datetime import datetime
from dotenv import load_dotenv
# Загрузка переменных окружения
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
from telegram_relay import bot_api_method_url, get_relay_url
from season_links import subscription_start_link
# Настройки из переменных окружения
POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
@@ -68,6 +75,7 @@ async def test_telegram_publish():
logger.info(f" MAX_TEXT_LENGTH: {MAX_TEXT_LENGTH}")
logger.info(f" PUBLISH_SILENTLY: {PUBLISH_SILENTLY}")
logger.info(f" USE_SUBSCRIPTION_BOT: {USE_SUBSCRIPTION_BOT}")
logger.info(f" RELAY_URL: {get_relay_url()}")
# Проверка обязательных параметров
if not POSTER_BOT_TOKEN:
@@ -92,7 +100,11 @@ async def test_telegram_publish():
# Формируем строку ссылок
placeholder_message_id = '1234567890'
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:
links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a>'
@@ -147,7 +159,7 @@ async def test_telegram_publish():
logger.info("=" * 80)
logger.info("ПОПЫТКА ПОЛУЧЕНИЯ ИНФОРМАЦИИ О БОТЕ")
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"Метод: GET")
@@ -209,7 +221,7 @@ async def test_telegram_publish():
logger.info("=" * 80)
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ")
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"Метод: POST")
logger.info(f"Параметры запроса:")
@@ -258,7 +270,7 @@ async def test_telegram_publish():
logger.info("=" * 80)
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ")
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"Метод: POST")
logger.info(f"Параметры запроса:")
@@ -336,7 +348,7 @@ async def test_telegram_publish():
logger.info("=" * 80)
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ С КАРТИНКОЙ")
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"
# Формируем текст для сообщения с картинкой
@@ -391,7 +403,7 @@ async def test_telegram_publish():
logger.info("=" * 80)
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ С КАРТИНКОЙ")
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"Метод: POST")
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 mysql.connector
from mysql.connector import Error
from datetime import datetime, timezone
from dotenv import load_dotenv
# Загрузка переменных окружения
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
MDB_HOST = os.getenv('MDB_HOST')
@@ -12,6 +21,7 @@ MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE')
def prefetch_all_events():
"""
Проверяет записи в таблице events и устанавливает флаг marked_to_publication
@@ -20,7 +30,6 @@ def prefetch_all_events():
- не имеют флага marked_to_publication = 1
"""
try:
# Подключение к базе данных
connection = mysql.connector.connect(
host=MDB_HOST,
user=MDB_USER,
@@ -28,14 +37,12 @@ def prefetch_all_events():
database=MDBASE
)
# Установка временной зоны соединения в UTC
cursor_temp = connection.cursor()
cursor_temp.execute("SET time_zone = '+00:00'")
cursor_temp.close()
cursor = connection.cursor()
# SQL запрос для обновления записей
update_query = """
UPDATE events
SET marked_to_publication = 1
@@ -43,15 +50,11 @@ def prefetch_all_events():
AND marked_to_publication = 0
"""
# Выполнение запроса
cursor.execute(update_query)
updated_count = cursor.rowcount
# Фиксация изменений
connection.commit()
print(f"Обновлено записей: {updated_count}")
return updated_count
except Error as e:
@@ -67,5 +70,6 @@ def prefetch_all_events():
cursor.close()
connection.close()
if __name__ == "__main__":
prefetch_all_events()
@@ -1,7 +1,9 @@
#!/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 # Завершать выполнение при любой ошибке
+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')
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации
# Минимум букв (isalpha), чтобы пост не считался пустым (видео без описания и т.п.)
try:
VK_POST_MIN_LETTERS = int((os.getenv('VK_POST_MIN_LETTERS') or '5').strip().strip('"').strip("'"))
except ValueError:
VK_POST_MIN_LETTERS = 5
if VK_POST_MIN_LETTERS < 0:
VK_POST_MIN_LETTERS = 0
EMPTY_POST_SHORTNAME = "- = Пустой пост = -"
LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
@@ -63,7 +71,7 @@ def get_vk_posts():
url = 'https://api.vk.ru/method/wall.get'
params = {
'owner_id': VK_GROUP_ID,
'count': 10,
'count': 20,
'access_token': VK_ACCESS_TOKEN,
'v': VK_API_VERSION,
'extended': 1
@@ -86,6 +94,12 @@ def get_vk_posts():
log_message(error_msg)
raise Exception(error_msg)
def count_letters(text):
"""Количество букв в тексте (Unicode isalpha), без цифр и знаков."""
return sum(1 for c in (text or "") if c.isalpha())
def process_post(post):
"""Извлекает необходимые данные из поста VK, включая обработку репостов"""
post_id = post['id']
@@ -144,11 +158,15 @@ def process_post(post):
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
post_text = source_post.get('text', '')
own_text = post.get('text', '')
letter_count = count_letters(post_text)
if is_repost:
letter_count += count_letters(own_text)
# Если у репоста есть свой текст, добавляем его перед текстом оригинала
if post.get('text', '').strip():
post_text = f"{post['text']}\n\n---\n\n{post_text}"
if own_text.strip():
post_text = f"{own_text}\n\n---\n\n{post_text}"
post_text = repost_prefix + post_text + repost_info
is_empty_text = letter_count < VK_POST_MIN_LETTERS
return {
'vk_post_id': post_id,
@@ -161,7 +179,9 @@ def process_post(post):
'poll_options': poll_options,
'poll_multiple': poll_multiple,
'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):
@@ -175,9 +195,14 @@ def save_to_database(posts):
try:
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
post_text = post.get('text', '')
is_empty_text = post.get('is_empty_text', False)
should_auto_publish = AUTO_PUBLISH
shortname = None
if 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
should_auto_publish = False
@@ -201,16 +226,21 @@ def save_to_database(posts):
int(post['poll_multiple']),
post['poll_end_date'],
IS_EVENT,
int(should_auto_publish), # Значение с учетом проверки тега
None, # shortname - пока не используется, устанавливаем NULL
None # action_number - пока не используется, устанавливаем NULL
int(should_auto_publish),
shortname,
None # action_number
))
if cursor.rowcount > 0:
new_posts_count += 1
# Логируем добавление нового поста с указанием причины отключения автопубликации
auto_publish_reason = 'Да'
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 + ')'
else:
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)
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')
MDBASE = os.getenv('MDBASE')
LOG_FILE = os.getenv('LOG_FILE')
AUTO_PUB_EVT = os.getenv('AUTO_PUB_EVT', 'false').lower() in ('true', '1', 'yes', 'on')
def log_message(message, category="VOLK_loader"):
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
@@ -141,12 +142,15 @@ def load_categories():
if not existing:
# Вставка новой записи
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
marked_for_publication = 1 if AUTO_PUB_EVT else 0
cursor.execute(
"INSERT INTO categories (ID, TITLE, description) VALUES (%s, %s, %s)",
(category_id, title, None)
"INSERT INTO categories (ID, TITLE, description, marked_for_publication) VALUES (%s, %s, %s, %s)",
(category_id, title, None, marked_for_publication)
)
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:
# Обновление существующей записи (всегда обновляем TITLE, даже если оно пустое)
if existing['TITLE'] != title:
@@ -445,13 +449,16 @@ def load_events_by_category(category_id):
if not existing:
# Вставка новой записи
# Используем id из JSON для id_event, а для number используем автоинкремент
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
marked_to_publication = 1 if AUTO_PUB_EVT else 0
cursor.execute(
"""INSERT INTO events (id_event, number, unit_name, name, about, about_social_picture, authors)
VALUES (%s, %s, %s, %s, %s, %s, %s)""",
(id_event, current_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, %s)""",
(id_event, current_number, unit_name, name, about, about_social_picture, authors, marked_to_publication)
)
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
else:
# Обновление существующей записи
@@ -555,5 +562,5 @@ def load_all_events():
log_message(error_msg, "ERROR")
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__":
setup_webhook()
app.run()