Compare commits
134
Commits
34fd7c25e8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87ccd23e96 | ||
|
|
f74953e082 | ||
|
|
709178d9ea | ||
|
|
ac17043a08 | ||
|
|
62a77dba78 | ||
|
|
ea6c5e5a7d | ||
|
|
80e0847329 | ||
|
|
c7ba0479ba | ||
|
|
d7e105d986 | ||
|
|
352c0da209 | ||
|
|
4500cf5bf4 | ||
|
|
a1aadb6393 | ||
|
|
251fa0be11 | ||
|
|
adc49c285c | ||
|
|
1b080990fa | ||
|
|
7af6748c74 | ||
|
|
e0e27ad569 | ||
|
|
739e7171ea | ||
|
|
9b8b5514b8 | ||
|
|
7c59c0d5e2 | ||
|
|
2797183834 | ||
|
|
72c7d38609 | ||
|
|
116b9bca10 | ||
|
|
c5ba5d6462 | ||
|
|
4795079dce | ||
|
|
8c3a62d021 | ||
|
|
064d22cfd8 | ||
|
|
0b5f20b748 | ||
|
|
df45f6c83c | ||
|
|
7c4efdbc8e | ||
|
|
b0f94c69e2 | ||
|
|
b2ead01f15 | ||
|
|
2d59cac00b | ||
|
|
3e793aff91 | ||
|
|
a8f61f724f | ||
|
|
96e7bb3da1 | ||
|
|
720ba0ed36 | ||
|
|
93930717a7 | ||
|
|
c7ca9cf487 | ||
|
|
e9d190254d | ||
|
|
906aa4ed93 | ||
|
|
9580d167bd | ||
|
|
7608afa77b | ||
|
|
538213eae9 | ||
|
|
8087908c27 | ||
|
|
feed7d641a | ||
|
|
f9aa9dfb29 | ||
|
|
2f0c2766aa | ||
|
|
5e95fd2d9d | ||
|
|
972474ad45 | ||
|
|
54ddc05058 | ||
|
|
c999898c58 | ||
|
|
5b2d09f4be | ||
|
|
8556b1f508 | ||
|
|
bccf3e7717 | ||
|
|
d908c28b5d | ||
|
|
a5705b2ceb | ||
|
|
66af108914 | ||
|
|
073628838c | ||
|
|
7f916cf0f9 | ||
|
|
d3dab7a96e | ||
|
|
0cfa26152b | ||
|
|
c8f1cc2595 | ||
|
|
82bb7984e9 | ||
|
|
208a388e53 | ||
|
|
cca4afd9a5 | ||
|
|
33068735b5 | ||
|
|
0bae7ee6de | ||
|
|
37d56e2e78 | ||
|
|
e961d0ee90 | ||
|
|
5a9a4514e1 | ||
|
|
93a99f758b | ||
|
|
0576e98f5d | ||
|
|
f16b8417b0 | ||
|
|
825428bca3 | ||
|
|
30a4737db5 | ||
|
|
008ecbd3ed | ||
|
|
84cc0573e5 | ||
|
|
bc8b1569aa | ||
|
|
1cda8969db | ||
|
|
179d78a750 | ||
|
|
837d5a171f | ||
|
|
685938d9f7 | ||
|
|
2cbc2a1b46 | ||
|
|
a25ac707e8 | ||
|
|
399b02d29f | ||
|
|
2284fd3263 | ||
|
|
0490135d9e | ||
|
|
4703043062 | ||
|
|
e339cac7d9 | ||
|
|
18e7aaef34 | ||
|
|
e03fb9a872 | ||
|
|
26d12a4758 | ||
|
|
0d411bb5ce | ||
|
|
5c46c0bcf0 | ||
|
|
e3de30e429 | ||
|
|
ebaa7e89e4 | ||
|
|
de7fc5ad8a | ||
|
|
d493cea3dc | ||
|
|
3cbfc15bfd | ||
|
|
6246a75458 | ||
|
|
c1db85f6ee | ||
|
|
47960abc38 | ||
|
|
cd5615779b | ||
|
|
d4770fbbac | ||
|
|
d89b4efcba | ||
|
|
e095fc35bc | ||
|
|
35c8a00822 | ||
|
|
0ec925ec2f | ||
|
|
e1e5e725c7 | ||
|
|
b25f65b6a1 | ||
|
|
c2fd69cf39 | ||
|
|
ae1d9eb8a7 | ||
|
|
6c40abbb7f | ||
|
|
20b8f9353a | ||
|
|
58a8f1909f | ||
|
|
8772eaaf3d | ||
|
|
53db795675 | ||
|
|
aa417538a1 | ||
|
|
7ad4b1b623 | ||
|
|
d622805d66 | ||
|
|
3a7710f009 | ||
|
|
e6ebcaeeea | ||
|
|
97847c7a99 | ||
|
|
4fb1f39323 | ||
|
|
fde121a7c5 | ||
|
|
54453c1064 | ||
|
|
e74779fa32 | ||
|
|
cf57eb3743 | ||
|
|
5f9f9a3a1b | ||
|
|
992fa14859 | ||
|
|
3f27404959 | ||
|
|
0546f3fc51 | ||
|
|
ced50eec66 |
@@ -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**.
|
||||
@@ -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,31 +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"
|
||||
POSTER_BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
|
||||
# @info_1638_bot
|
||||
POSTER_BOT_TOKEN = "8215624618:AAF6VtqRtFr4twFRTiTjedSDE6txkbIy6rc"
|
||||
# VOLK production bot @VOLK_informer_bot
|
||||
# POSTER_BOT_TOKEN = "8279661544:AAEsJee6icCUn_AbWxhDpk2CLxQhzaartuI"
|
||||
# VOLK backup production bot @VOLK_informer2_bot
|
||||
# POSTER_BOT_TOKEN = "8001674071:AAFyGW4xibAFnuApEaSJvshqEyYHmD7RRts"
|
||||
# VOLK responder bot
|
||||
# RESPONDER_BOT_TOKEN = "8072579275:AAFiyKN1EPPnP5ZVZNTDGEriYJf1xADbfsY"
|
||||
# RESPONDER_BOT_NAME = "VOLK_responder_bot"
|
||||
# @posto_1638_bot
|
||||
RESPONDER_BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
|
||||
RESPONDER_BOT_NAME = "posto_1638_bot"
|
||||
# Zilant info bot
|
||||
# BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
|
||||
#
|
||||
@@ -58,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
|
||||
#
|
||||
@@ -67,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
|
||||
@@ -83,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
|
||||
|
||||
@@ -91,29 +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-dev.volk-nn.ru/api/categories?event=volk24&form=volk24_event"
|
||||
VOLK_CATEGORY_DESC_PREFIX = "https://base-dev.volk-nn.ru/api/category?event=volk24&form=volk24_event&category="
|
||||
VOLK_EVENT_LIST_PREFIX = "https://base-dev.volk-nn.ru/api/applications?event=volk24&form=volk24_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
@@ -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
@@ -0,0 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Секреты инстанса — только шаблон .env.example в репозитории
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Логи инстанса
|
||||
logs/
|
||||
+1
-1
@@ -35,5 +35,5 @@ steps:
|
||||
# "
|
||||
|
||||
when:
|
||||
event: push
|
||||
event: [push, manual]
|
||||
branch: main
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+526
-5
@@ -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
|
||||
|
||||
# Создаем очередь для передачи результата
|
||||
@@ -1004,6 +1120,12 @@ def api_publish_post(post_id):
|
||||
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
|
||||
# Проверяем, что публикация действительно успешна (результат True)
|
||||
if result is not True:
|
||||
error_msg = f"Публикация поста ID: {post_id} не удалась (результат: {result})"
|
||||
log_event(error_msg)
|
||||
return jsonify({'success': False, 'error': error_msg}), 500
|
||||
|
||||
log_event(f"Публикация поста ID: {post_id} выполнена успешно")
|
||||
return jsonify({'success': True})
|
||||
@@ -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():
|
||||
@@ -1092,6 +1300,296 @@ def api_load_categories():
|
||||
log_event(error_msg)
|
||||
return jsonify({'success': False, 'error': error_msg}), 500
|
||||
|
||||
@bp.route('/api/categories')
|
||||
@login_required
|
||||
def api_categories():
|
||||
"""Получает список всех категорий (площадок) из таблицы categories"""
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT AUTO_ID, ID, TITLE, description, marked_for_publication, is_tg_published
|
||||
FROM categories
|
||||
ORDER BY TITLE, ID
|
||||
""")
|
||||
categories = cursor.fetchall()
|
||||
|
||||
categories_list = []
|
||||
for cat in categories:
|
||||
categories_list.append({
|
||||
'auto_id': cat['AUTO_ID'],
|
||||
'id': cat['ID'],
|
||||
'title': cat['TITLE'] or '',
|
||||
'description': cat['description'] or '',
|
||||
'marked_for_publication': bool(cat['marked_for_publication']) if cat['marked_for_publication'] is not None else False,
|
||||
'is_tg_published': bool(cat['is_tg_published']) if cat['is_tg_published'] is not None else False
|
||||
})
|
||||
|
||||
return jsonify(categories_list)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/categories/<category_id>')
|
||||
@login_required
|
||||
def api_category_details(category_id):
|
||||
"""Получает детали категории по ID"""
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT AUTO_ID, ID, TITLE, description, image_url, tg_id, is_tg_published, tg_published_date, marked_for_publication
|
||||
FROM categories
|
||||
WHERE ID = %s
|
||||
""", (category_id,))
|
||||
category = cursor.fetchone()
|
||||
|
||||
if not category:
|
||||
return jsonify({'error': 'Category not found'}), 404
|
||||
|
||||
# Получаем количество событий для этой категории
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as event_count
|
||||
FROM events
|
||||
WHERE unit_name = %s
|
||||
""", (category_id,))
|
||||
event_count_result = cursor.fetchone()
|
||||
event_count = event_count_result['event_count'] if event_count_result else 0
|
||||
|
||||
return jsonify({
|
||||
'auto_id': category['AUTO_ID'],
|
||||
'id': category['ID'],
|
||||
'title': category['TITLE'] or '',
|
||||
'description': category['description'] or '',
|
||||
'image_url': category['image_url'] or '',
|
||||
'tg_id': category['tg_id'],
|
||||
'is_tg_published': bool(category['is_tg_published']) if category['is_tg_published'] is not None else False,
|
||||
'tg_published_date': category['tg_published_date'].isoformat() if category['tg_published_date'] else None,
|
||||
'marked_for_publication': bool(category['marked_for_publication']) if category['marked_for_publication'] is not None else False,
|
||||
'event_count': event_count
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/categories', methods=['POST'])
|
||||
@login_required
|
||||
def api_create_category():
|
||||
"""Создает новую категорию"""
|
||||
db = get_db()
|
||||
try:
|
||||
data = request.json
|
||||
with db.conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
INSERT INTO categories (ID, TITLE, description, image_url, tg_id, marked_for_publication, is_tg_published, tg_published_date)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
data.get('id'),
|
||||
data.get('title', ''),
|
||||
data.get('description', ''),
|
||||
data.get('image_url', ''),
|
||||
data.get('tg_id'),
|
||||
bool(data.get('marked_for_publication', False)),
|
||||
bool(data.get('is_tg_published', False)),
|
||||
data.get('tg_published_date') or None
|
||||
))
|
||||
db.conn.commit()
|
||||
client_ip = get_client_ip()
|
||||
log_event(f"Создана площадка: {data.get('id')}, IP {client_ip}")
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/categories/<category_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def api_update_category(category_id):
|
||||
"""Обновляет категорию"""
|
||||
db = get_db()
|
||||
try:
|
||||
data = request.json
|
||||
new_id = data.get('id', '').strip()
|
||||
if not new_id:
|
||||
return jsonify({'success': False, 'error': 'ID is required'}), 400
|
||||
|
||||
with db.conn.cursor() as cursor:
|
||||
# Сначала проверяем существование категории по старому ID
|
||||
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (category_id,))
|
||||
category = cursor.fetchone()
|
||||
|
||||
if not category:
|
||||
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||
|
||||
auto_id = category['AUTO_ID']
|
||||
|
||||
# Если новый ID отличается от старого, проверяем, не занят ли он
|
||||
if new_id != category_id:
|
||||
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (new_id,))
|
||||
existing = cursor.fetchone()
|
||||
if existing and existing['AUTO_ID'] != auto_id:
|
||||
return jsonify({'success': False, 'error': f'Category with ID "{new_id}" already exists'}), 400
|
||||
|
||||
# Обновляем категорию, включая ID если он изменился
|
||||
cursor.execute("""
|
||||
UPDATE categories
|
||||
SET ID = %s, TITLE = %s, description = %s, image_url = %s, tg_id = %s,
|
||||
marked_for_publication = %s, is_tg_published = %s, tg_published_date = %s
|
||||
WHERE AUTO_ID = %s
|
||||
""", (
|
||||
new_id,
|
||||
data.get('title', ''),
|
||||
data.get('description', ''),
|
||||
data.get('image_url', ''),
|
||||
data.get('tg_id'),
|
||||
bool(data.get('marked_for_publication', False)),
|
||||
bool(data.get('is_tg_published', False)),
|
||||
data.get('tg_published_date') or None,
|
||||
auto_id
|
||||
))
|
||||
db.conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
client_ip = get_client_ip()
|
||||
log_event(f"Обновлена площадка: {category_id} -> {new_id}, IP {client_ip}")
|
||||
return jsonify({'success': True})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/categories/<category_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def api_delete_category(category_id):
|
||||
"""Удаляет категорию"""
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
# Проверяем наличие привязанных событий
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as event_count
|
||||
FROM events
|
||||
WHERE unit_name = %s
|
||||
""", (category_id,))
|
||||
event_count_result = cursor.fetchone()
|
||||
event_count = event_count_result['event_count'] if event_count_result else 0
|
||||
|
||||
if event_count > 0:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Cannot delete category with linked events',
|
||||
'event_count': event_count
|
||||
}), 400
|
||||
|
||||
# Получаем название категории для лога
|
||||
cursor.execute("SELECT TITLE FROM categories WHERE ID = %s", (category_id,))
|
||||
category = cursor.fetchone()
|
||||
category_title = category['TITLE'] if category else category_id
|
||||
|
||||
cursor.execute("DELETE FROM categories WHERE ID = %s", (category_id,))
|
||||
db.conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
client_ip = get_client_ip()
|
||||
log_event(f"Удалена площадка: {category_title}, ID: {category_id}, IP {client_ip}")
|
||||
return jsonify({'success': True})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/categories/<category_id>/toggle_publication', methods=['POST'])
|
||||
@login_required
|
||||
def api_toggle_category_publication(category_id):
|
||||
"""Переключает статус публикации категории"""
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
# Получаем текущее значение
|
||||
cursor.execute("SELECT marked_for_publication FROM categories WHERE ID = %s", (category_id,))
|
||||
category = cursor.fetchone()
|
||||
if not category:
|
||||
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||
|
||||
# Переключаем значение
|
||||
new_value = not bool(category['marked_for_publication'])
|
||||
cursor.execute("""
|
||||
UPDATE categories
|
||||
SET marked_for_publication = %s
|
||||
WHERE ID = %s
|
||||
""", (new_value, category_id))
|
||||
db.conn.commit()
|
||||
|
||||
client_ip = get_client_ip()
|
||||
log_event(f"Изменен статус публикации площадки: {category_id}, новый статус: {new_value}, IP {client_ip}")
|
||||
return jsonify({'success': True, 'marked_for_publication': new_value})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/categories/mark_all_for_publication', methods=['POST'])
|
||||
@login_required
|
||||
def api_mark_all_categories_for_publication():
|
||||
"""Отмечает все категории для публикации"""
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
UPDATE categories
|
||||
SET marked_for_publication = 1
|
||||
WHERE marked_for_publication = 0
|
||||
""")
|
||||
count = cursor.rowcount
|
||||
db.conn.commit()
|
||||
client_ip = get_client_ip()
|
||||
log_event(f"Отмечено для публикации: {count} площадок, IP {client_ip}")
|
||||
return jsonify({'success': True, 'count': count})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/categories/<category_id>/events_count')
|
||||
@login_required
|
||||
def api_category_events_count(category_id):
|
||||
"""Получает количество событий, привязанных к категории"""
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as event_count
|
||||
FROM events
|
||||
WHERE unit_name = %s
|
||||
""", (category_id,))
|
||||
result = cursor.fetchone()
|
||||
return jsonify({'count': result['event_count'] if result else 0})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/events/count')
|
||||
@login_required
|
||||
def api_events_count():
|
||||
"""Получает количество событий в таблице events"""
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
cursor.execute("SELECT COUNT(*) as count FROM events")
|
||||
result = cursor.fetchone()
|
||||
return jsonify({'count': result['count'] if result else 0})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@bp.route('/api/run_volk_rescan', methods=['POST'])
|
||||
@login_required
|
||||
def api_run_volk_rescan():
|
||||
@@ -1510,6 +2008,29 @@ def edit_event_page(event_id):
|
||||
def add_event_page():
|
||||
return render_template('edit_event.html', event_id=None)
|
||||
|
||||
@bp.route('/edit_category/<category_id>')
|
||||
@login_required
|
||||
def edit_category_page(category_id):
|
||||
db = get_db()
|
||||
try:
|
||||
with db.conn.cursor() as cursor:
|
||||
cursor.execute("SELECT ID, TITLE FROM categories WHERE ID = %s", (category_id,))
|
||||
category = cursor.fetchone()
|
||||
if not category:
|
||||
flash('Площадка не найдена', 'danger')
|
||||
return redirect(url_for('zilant.index'))
|
||||
|
||||
client_ip = get_client_ip()
|
||||
log_event(f"Открыта площадка на редактирование: {category['TITLE']}, ID: {category_id}, IP {client_ip}")
|
||||
finally:
|
||||
db.close()
|
||||
return render_template('edit_category.html', category_id=category_id)
|
||||
|
||||
@bp.route('/add_category')
|
||||
@login_required
|
||||
def add_category_page():
|
||||
return render_template('edit_category.html', category_id=None)
|
||||
|
||||
# Регистрируем Blueprint в приложении
|
||||
app.register_blueprint(bp)
|
||||
|
||||
|
||||
+152
-25
@@ -21,14 +21,88 @@ DB_CONFIG = {
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
OPENROUTER_CONFIG = {
|
||||
'api_key': os.getenv('OR_KEY'),
|
||||
'model': os.getenv('OR_MODEL_NAME')
|
||||
ROUTERAI_CONFIG = {
|
||||
'api_key': os.getenv('RA_KEY'),
|
||||
'model': os.getenv('RA_MODEL'),
|
||||
'base_url': 'https://routerai.ru/api/v1',
|
||||
}
|
||||
|
||||
# Настройки журналирования
|
||||
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
|
||||
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
|
||||
LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе
|
||||
|
||||
|
||||
def _truncate_for_log(value, max_len=None):
|
||||
"""Обрезает длинные значения для журнала, не теряя информацию о размере."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||
limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return f"{value[:limit]}... [обрезано, всего {len(value)} символов]"
|
||||
|
||||
|
||||
def _message_fields_for_log(message):
|
||||
"""Все значимые поля message из ответа chat/completions."""
|
||||
if not message:
|
||||
return {}
|
||||
logged = {}
|
||||
for key in (
|
||||
'role', 'content', 'reasoning_content', 'reasoning',
|
||||
'refusal', 'tool_calls', 'function_call', 'audio',
|
||||
):
|
||||
if key not in message:
|
||||
continue
|
||||
value = message.get(key)
|
||||
if value in (None, '', [], {}):
|
||||
logged[key] = value
|
||||
elif isinstance(value, str):
|
||||
logged[key] = _truncate_for_log(value)
|
||||
else:
|
||||
logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str))
|
||||
return logged
|
||||
|
||||
|
||||
def _log_routerai_request(text, prompt):
|
||||
"""Журнал параметров исходящего запроса (без API-ключа)."""
|
||||
log_message(
|
||||
"Запрос RouterAI: "
|
||||
f"model={ROUTERAI_CONFIG['model']}, "
|
||||
f"text_len={len(text)}, prompt_len={len(prompt)}, "
|
||||
f"text_preview={_truncate_for_log(text.strip(), 300)}"
|
||||
)
|
||||
|
||||
|
||||
def _log_routerai_response(response, result=None, error_body=None):
|
||||
"""Подробный журнал HTTP-ответа RouterAI."""
|
||||
payload = {
|
||||
"http_status": getattr(response, 'status_code', None),
|
||||
"model": ROUTERAI_CONFIG.get('model'),
|
||||
}
|
||||
if result is not None:
|
||||
payload["response_id"] = result.get("id")
|
||||
payload["object"] = result.get("object")
|
||||
payload["created"] = result.get("created")
|
||||
payload["usage"] = result.get("usage")
|
||||
payload["system_fingerprint"] = result.get("system_fingerprint")
|
||||
choices = result.get("choices") or []
|
||||
payload["choices_count"] = len(choices)
|
||||
payload["choices"] = []
|
||||
for i, choice in enumerate(choices):
|
||||
payload["choices"].append({
|
||||
"index": choice.get("index", i),
|
||||
"finish_reason": choice.get("finish_reason"),
|
||||
"message": _message_fields_for_log(choice.get("message") or {}),
|
||||
"logprobs": choice.get("logprobs"),
|
||||
})
|
||||
if result.get("error"):
|
||||
payload["api_error"] = result.get("error")
|
||||
if error_body is not None:
|
||||
payload["error_body"] = _truncate_for_log(error_body)
|
||||
log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}")
|
||||
|
||||
def log_message(message, max_retries=5, retry_delay=0.1):
|
||||
"""
|
||||
@@ -59,11 +133,16 @@ def log_message(message, max_retries=5, retry_delay=0.1):
|
||||
return False
|
||||
|
||||
def _generate_shortname_by_api(text):
|
||||
"""Внутренняя функция для генерации краткого названия через OpenRouter API"""
|
||||
url = "https://openrouter.ai/api/v1/chat/completions"
|
||||
"""Внутренняя функция для генерации краткого названия через RouterAI API"""
|
||||
if not ROUTERAI_CONFIG['api_key'] or not ROUTERAI_CONFIG['model']:
|
||||
log_message("RA_KEY или RA_MODEL не заданы в .env")
|
||||
print("Ошибка: RA_KEY или RA_MODEL не заданы в .env")
|
||||
return None
|
||||
|
||||
url = f"{ROUTERAI_CONFIG['base_url']}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {OPENROUTER_CONFIG['api_key']}",
|
||||
"Content-Type": "application/json"
|
||||
"Authorization": f"Bearer {ROUTERAI_CONFIG['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
prompt = f"""
|
||||
@@ -78,14 +157,16 @@ def _generate_shortname_by_api(text):
|
||||
Название:"""
|
||||
|
||||
data = {
|
||||
"model": OPENROUTER_CONFIG['model'],
|
||||
"model": ROUTERAI_CONFIG['model'],
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 5000,
|
||||
"temperature": 0.3
|
||||
"temperature": 0.3,
|
||||
}
|
||||
|
||||
_log_routerai_request(text, prompt)
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data, timeout=60)
|
||||
response = requests.post(url, headers=headers, json=data, timeout=180)
|
||||
|
||||
# Обработка ошибки 429 (Too Many Requests)
|
||||
if response.status_code == 429:
|
||||
@@ -98,27 +179,55 @@ def _generate_shortname_by_api(text):
|
||||
# Пытаемся распарсить JSON ответ
|
||||
try:
|
||||
error_info["body"] = response.json()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_log_routerai_response(response, error_body=error_info.get("body"))
|
||||
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
||||
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
||||
sys.exit(1)
|
||||
|
||||
response.raise_for_status()
|
||||
# Не sys.exit — эта функция вызывается и из веб-API Flask
|
||||
raise RuntimeError("RouterAI 429: превышен лимит запросов")
|
||||
|
||||
if not response.ok:
|
||||
_log_routerai_response(response, error_body=response.text)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
_log_routerai_response(response, result=result)
|
||||
|
||||
# Извлекаем только текст ответа
|
||||
shortname = result['choices'][0]['message']['content'].strip()
|
||||
# Извлекаем только текст ответа (у thinking-моделей ответ в content,
|
||||
# рассуждения — в reasoning_content / reasoning)
|
||||
choices = result.get('choices') or []
|
||||
if not choices:
|
||||
log_message("RouterAI вернул пустой choices — название не извлечь")
|
||||
return None
|
||||
|
||||
message = choices[0].get('message') or {}
|
||||
shortname = (message.get('content') or '').strip()
|
||||
if not shortname:
|
||||
reasoning = (message.get('reasoning_content') or message.get('reasoning') or '').strip()
|
||||
log_message(
|
||||
"Пустой content в ответе RouterAI; "
|
||||
f"finish_reason={choices[0].get('finish_reason')!r}, "
|
||||
f"message_keys={list(message.keys())}, "
|
||||
f"reasoning_len={len(reasoning)}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Удаляем возможные кавычки и лишние символы
|
||||
shortname = re.sub(r'^["\']|["\']$', '', shortname)
|
||||
# Если модель вернула многострочный ответ — берём первую непустую строку
|
||||
for line in shortname.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
shortname = line
|
||||
break
|
||||
|
||||
# Обрезаем до 80 символов
|
||||
return shortname[:80]
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 429:
|
||||
if e.response is not None and e.response.status_code == 429:
|
||||
error_info = {
|
||||
"status_code": e.response.status_code,
|
||||
"headers": dict(e.response.headers),
|
||||
@@ -128,17 +237,35 @@ def _generate_shortname_by_api(text):
|
||||
# Пытаемся распарсить JSON ответ
|
||||
try:
|
||||
error_info["body"] = e.response.json()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_log_routerai_response(e.response, error_body=error_info.get("body"))
|
||||
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
||||
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
||||
sys.exit(1)
|
||||
raise RuntimeError("RouterAI 429: превышен лимит запросов")
|
||||
else:
|
||||
if e.response is not None:
|
||||
try:
|
||||
_log_routerai_response(e.response, error_body=e.response.text)
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f"HTTP ошибка при генерации названия: {e}"
|
||||
log_message(error_msg)
|
||||
print(error_msg)
|
||||
return None
|
||||
except requests.exceptions.Timeout as e:
|
||||
error_msg = f"Таймаут запроса RouterAI ({e})"
|
||||
log_message(error_msg)
|
||||
print(error_msg)
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"Сетевая ошибка RouterAI: {e}"
|
||||
log_message(error_msg)
|
||||
print(error_msg)
|
||||
return None
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при генерации названия: {e}"
|
||||
log_message(error_msg)
|
||||
@@ -228,10 +355,10 @@ def ai_shortname_all():
|
||||
|
||||
log_message("Обработка всех записей завершена")
|
||||
print("Обработка завершена")
|
||||
except SystemExit:
|
||||
# Перехватываем системный выход для корректного закрытия соединения
|
||||
log_message("Завершение работы из-за ошибки 429")
|
||||
print("Завершение работы из-за ошибки 429")
|
||||
except RuntimeError as e:
|
||||
# 429 и прочие явные ошибки RouterAI
|
||||
log_message(f"Завершение работы: {e}")
|
||||
print(f"Завершение работы: {e}")
|
||||
except Exception as e:
|
||||
error_msg = f"Произошла ошибка при обработке записей: {e}"
|
||||
log_message(error_msg)
|
||||
|
||||
@@ -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`.
|
||||
@@ -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 при изменениях.
|
||||
@@ -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/` создаётся автоматически).
|
||||
@@ -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).
|
||||
@@ -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`).
|
||||
+62
-8
@@ -45,6 +45,10 @@ def ensure_database_structure():
|
||||
"""Основная функция проверки и создания БД/таблиц"""
|
||||
conn = None
|
||||
cursor = None
|
||||
|
||||
# Проверка режима отладки
|
||||
log_debug = os.getenv('LOG_DEBUG_DATA', 'false').lower() == 'true'
|
||||
changes_made = False # Флаг для отслеживания изменений
|
||||
|
||||
try:
|
||||
# Подключение без указания базы данных
|
||||
@@ -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 = {
|
||||
@@ -162,7 +181,12 @@ def ensure_database_structure():
|
||||
AUTO_ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ID VARCHAR(255),
|
||||
TITLE VARCHAR(2048),
|
||||
description TEXT
|
||||
description TEXT,
|
||||
tg_id BIGINT NULL,
|
||||
image_url VARCHAR(2048),
|
||||
is_tg_published BOOLEAN DEFAULT 0,
|
||||
tg_published_date DATETIME NULL,
|
||||
marked_for_publication BOOLEAN DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
'''
|
||||
}
|
||||
@@ -180,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}")
|
||||
@@ -201,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}")
|
||||
@@ -224,7 +252,12 @@ def ensure_database_structure():
|
||||
{'column': 'auto_added', 'type': 'BOOLEAN DEFAULT 0'}
|
||||
],
|
||||
'categories': [
|
||||
{'column': 'description', 'type': 'TEXT'}
|
||||
{'column': 'description', 'type': 'TEXT'},
|
||||
{'column': 'tg_id', 'type': 'BIGINT NULL'},
|
||||
{'column': 'image_url', 'type': 'VARCHAR(2048)'},
|
||||
{'column': 'is_tg_published', 'type': 'BOOLEAN DEFAULT 0'},
|
||||
{'column': 'tg_published_date', 'type': 'DATETIME NULL'},
|
||||
{'column': 'marked_for_publication', 'type': 'BOOLEAN DEFAULT 0'}
|
||||
],
|
||||
'events': [
|
||||
{'column': 'authors', 'type': 'VARCHAR(255)'}
|
||||
@@ -247,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}")
|
||||
@@ -299,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:
|
||||
|
||||
+630
-122
@@ -4,11 +4,20 @@ import asyncio
|
||||
import pymysql
|
||||
import time
|
||||
import html
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from telegram import Bot, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from telegram.error import TelegramError, RetryAfter
|
||||
from urllib.parse import urlparse, unquote
|
||||
from dotenv import load_dotenv
|
||||
from formatter import get_event_text
|
||||
from telegram_relay import (
|
||||
bot_api_method_url,
|
||||
EDIT_LINKS_INITIAL_DELAY_SEC,
|
||||
EDIT_LINKS_MAX_ATTEMPTS,
|
||||
EDIT_LINKS_RETRY_INTERVAL_SEC,
|
||||
published_message_has_expected_links,
|
||||
extract_text_link_urls,
|
||||
)
|
||||
from season_links import subscription_start_link
|
||||
|
||||
# Загрузка переменных окружения
|
||||
load_dotenv()
|
||||
@@ -25,6 +34,7 @@ DESC_PREFIX = os.getenv('DESC_PREFIX')
|
||||
PZK_PREFIX = os.getenv('PZK_PREFIX')
|
||||
LOG_FILE = os.getenv('LOG_FILE', 'evtg_publisher.log')
|
||||
EVENT_POST_DELAY = int(os.getenv('EVENT_POST_DELAY', 0)) # Задержка в минутах
|
||||
WORKMODE = os.getenv('WORKMODE', 'ZILANT') # Режим работы: ZILANT или VOLK
|
||||
|
||||
# Параметры длины сообщений
|
||||
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
|
||||
@@ -60,6 +70,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# Класс для обработки RetryAfter (FloodWait 429)
|
||||
class RetryAfterException(Exception):
|
||||
"""Исключение для обработки FloodWait (429) от Telegram API"""
|
||||
def __init__(self, retry_after):
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"RetryAfter: {retry_after}")
|
||||
|
||||
def format_tags(tags_str):
|
||||
"""Форматирование тегов с добавлением #"""
|
||||
if not tags_str:
|
||||
@@ -83,14 +100,188 @@ def prepare_text(text):
|
||||
|
||||
return text
|
||||
|
||||
async def tg_post_event(bot, event_data):
|
||||
|
||||
def _filename_from_image_url(image_url):
|
||||
path = unquote(urlparse(image_url).path)
|
||||
name = os.path.basename(path) or "image.jpg"
|
||||
if "." not in name:
|
||||
name = f"{name}.jpg"
|
||||
return name
|
||||
|
||||
|
||||
async def download_image(image_url, httpx_client):
|
||||
"""Скачивает картинку самим сервером (как tg_publish), не отдаёт URL Telegram.
|
||||
|
||||
sendPhoto с photo=http://... Telegram качает сам и часто отвечает
|
||||
«failed to get HTTP URL content» (http, недоступность с DC Telegram).
|
||||
"""
|
||||
logger.info(f"Скачивание изображения: {image_url}")
|
||||
started = time.time()
|
||||
response = await httpx_client.get(
|
||||
image_url,
|
||||
timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0),
|
||||
)
|
||||
response.raise_for_status()
|
||||
image_data = response.content
|
||||
content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
|
||||
if not content_type.startswith("image/"):
|
||||
content_type = "image/jpeg"
|
||||
logger.info(
|
||||
f"Изображение скачано за {time.time() - started:.2f} с, "
|
||||
f"размер: {len(image_data)} байт, type={content_type}"
|
||||
)
|
||||
return image_data, content_type, _filename_from_image_url(image_url)
|
||||
|
||||
|
||||
async def _edit_event_add_links(
|
||||
httpx_client, has_image, message_id, final_text, entity_label, expected_urls
|
||||
):
|
||||
"""Edit caption/text со ссылками; повтор до подтверждения всех expected_urls в text_link."""
|
||||
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
|
||||
method = "editMessageCaption" if has_image else "editMessageText"
|
||||
field = "caption" if has_image else "text"
|
||||
entities_field = "caption_entities" if has_image else "entities"
|
||||
api_url = bot_api_method_url(BOT_TOKEN, method)
|
||||
|
||||
logger.info(
|
||||
f"Попытка редактирования {entity_label}: message_id={message_id}, "
|
||||
f"final_text_length={len(final_text)}"
|
||||
)
|
||||
|
||||
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
|
||||
payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'message_id': message_id,
|
||||
field: final_text,
|
||||
'parse_mode': 'HTML',
|
||||
}
|
||||
if not has_image:
|
||||
payload['disable_web_page_preview'] = True
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(api_url, json=payload)
|
||||
except httpx.TimeoutException as e:
|
||||
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
f"Таймаут edit ссылок для {entity_label} после "
|
||||
f"{EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
|
||||
)
|
||||
return False
|
||||
logger.warning(
|
||||
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}"
|
||||
)
|
||||
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||
continue
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('ok'):
|
||||
result = data.get('result') or {}
|
||||
result_text = result.get(field) or ""
|
||||
entities = result.get(entities_field) or []
|
||||
if published_message_has_expected_links(entities, expected_urls):
|
||||
logger.info(
|
||||
f"Ссылки добавлены для {entity_label} "
|
||||
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
found = extract_text_link_urls(entities)
|
||||
logger.warning(
|
||||
f"edit ok, но footer-ссылки не подтверждены "
|
||||
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}; "
|
||||
f"ожидались {expected_urls}, найдены text_link: {found}"
|
||||
)
|
||||
else:
|
||||
error_code = data.get('error_code')
|
||||
error_description = (data.get('description') or '').lower()
|
||||
if error_code == 429:
|
||||
retry_after = data.get('parameters', {}).get('retry_after', 60)
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
if error_code == 400 and "not modified" in error_description:
|
||||
logger.info(
|
||||
f"Caption уже содержит ссылки для {entity_label} "
|
||||
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
f"API error edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {data.get('description')}"
|
||||
)
|
||||
elif response.status_code == 429:
|
||||
retry_after = 60
|
||||
try:
|
||||
retry_after = response.json().get('parameters', {}).get('retry_after', 60)
|
||||
except Exception:
|
||||
pass
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
elif response.status_code == 400:
|
||||
try:
|
||||
error_description = (response.json().get('description') or '').lower()
|
||||
if "not modified" in error_description:
|
||||
logger.info(
|
||||
f"Caption уже содержит ссылки для {entity_label} "
|
||||
f"(message is not modified HTTP 400, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {response.text[:300]}"
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {response.text[:300]}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"HTTP {response.status_code} edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {response.text[:300]}"
|
||||
)
|
||||
|
||||
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
|
||||
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||
|
||||
logger.error(
|
||||
f"Не удалось добавить строку ссылок для {entity_label} "
|
||||
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def tg_post_event(httpx_client, event_data):
|
||||
"""Публикация одного события"""
|
||||
try:
|
||||
# Форматирование текста
|
||||
tags = format_tags(event_data['tags'])
|
||||
unit_name = prepare_text(event_data['unit_name'] or "")
|
||||
unit_name_raw = event_data.get('unit_name') or ""
|
||||
name = prepare_text(event_data['name'] or "")
|
||||
|
||||
# Формируем название площадки с возможной ссылкой
|
||||
unit_name = ""
|
||||
if unit_name_raw:
|
||||
unit_tg_id = event_data.get('unit_tg_id')
|
||||
unit_title = event_data.get('unit_title') or unit_name_raw
|
||||
unit_title_escaped = prepare_text(unit_title)
|
||||
|
||||
if unit_tg_id:
|
||||
# Если площадка опубликована, делаем ссылку на сообщение
|
||||
# Используем логику из format_channel_link из tg_mainbot.py
|
||||
if CHANNEL_ID.startswith('@'):
|
||||
# Публичный канал
|
||||
channel_link = f"https://t.me/{CHANNEL_ID[1:]}/{unit_tg_id}"
|
||||
elif CHANNEL_ID.startswith('-100'):
|
||||
# Приватный канал с префиксом -100
|
||||
channel_link = f"https://t.me/c/{CHANNEL_ID[4:]}/{unit_tg_id}"
|
||||
else:
|
||||
# Приватный канал без префикса или другой формат
|
||||
channel_link = f"https://t.me/c/{CHANNEL_ID}/{unit_tg_id}"
|
||||
|
||||
# Формируем ссылку на сообщение
|
||||
unit_name = f'<a href="{channel_link}">{unit_title_escaped}</a>'
|
||||
else:
|
||||
# Если площадка не опубликована, просто текст
|
||||
unit_name = unit_title_escaped
|
||||
|
||||
# Получаем и подготавливаем описание события
|
||||
about_raw = event_data['about']
|
||||
|
||||
@@ -109,12 +300,22 @@ async def tg_post_event(bot, 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']
|
||||
@@ -132,85 +333,275 @@ async def tg_post_event(bot, event_data):
|
||||
# Формируем первоначальный текст
|
||||
initial_text = f"{base_text}\n\n{about}" if about else base_text
|
||||
|
||||
# Логируем текст для отладки
|
||||
logger.info(f"Текст для публикации события номер {event_data['number']}: {initial_text}")
|
||||
# Логируем детальную информацию о данных для публикации
|
||||
logger.info(f"=== Детали публикации события номер {event_data['number']} ===")
|
||||
logger.info(f"Длина initial_text: {len(initial_text)} символов")
|
||||
logger.info(f"Длина base_text: {base_text_length} символов")
|
||||
logger.info(f"Длина about: {len(about)} символов")
|
||||
logger.info(f"Длина links_line_placeholder: {links_line_length} символов")
|
||||
logger.info(f"Максимальная длина (max_length): {max_length} символов")
|
||||
logger.info(f"Доступная длина для about: {available_length} символов")
|
||||
logger.info(f"Есть изображение: {has_image}")
|
||||
if has_image:
|
||||
logger.info(f"URL изображения: {image_url}")
|
||||
logger.info(f"Длина URL изображения: {len(image_url)} символов")
|
||||
logger.info(f"Текст для публикации: {initial_text[:200]}..." if len(initial_text) > 200 else f"Текст для публикации: {initial_text}")
|
||||
|
||||
# Публикация первоначального сообщения без ссылок
|
||||
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
|
||||
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
|
||||
message_is_photo = False
|
||||
|
||||
if has_image:
|
||||
try:
|
||||
message = await bot.send_photo(
|
||||
chat_id=CHANNEL_ID,
|
||||
photo=image_url,
|
||||
caption=initial_text,
|
||||
parse_mode="HTML",
|
||||
disable_notification=PUBLISH_SILENTLY
|
||||
logger.info(
|
||||
f"Попытка отправки фото для события {event_data['number']}: "
|
||||
f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
|
||||
f"image_url={image_url}"
|
||||
)
|
||||
except TelegramError as e:
|
||||
image_data, content_type, filename = await download_image(image_url, httpx_client)
|
||||
photo_data = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'caption': initial_text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
|
||||
}
|
||||
photo_files = {
|
||||
'photo': (filename, image_data, content_type),
|
||||
}
|
||||
photo_response = await httpx_client.post(
|
||||
send_photo_url, data=photo_data, files=photo_files
|
||||
)
|
||||
|
||||
if photo_response.status_code == 200:
|
||||
photo_result = photo_response.json()
|
||||
if photo_result.get('ok'):
|
||||
message_data = photo_result.get('result', {})
|
||||
message_id = message_data.get('message_id')
|
||||
message_is_photo = True
|
||||
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
|
||||
else:
|
||||
# Ошибка в ответе API - пробуем отправить текстовое сообщение
|
||||
error_code = photo_result.get('error_code')
|
||||
if error_code == 429:
|
||||
# FloodWait - пробрасываем как RetryAfter
|
||||
retry_after = photo_result.get('parameters', {}).get('retry_after', 60)
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
raise Exception(f"API error: {photo_result.get('description', 'Unknown error')}")
|
||||
else:
|
||||
if photo_response.status_code == 429:
|
||||
# FloodWait
|
||||
retry_after = 60
|
||||
try:
|
||||
error_data = photo_response.json()
|
||||
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
|
||||
except:
|
||||
pass
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
raise Exception(f"HTTP {photo_response.status_code}: {photo_response.text}")
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"ТАЙМАУТ при отправке изображения для события номер {event_data['number']}")
|
||||
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
logger.error(f" Параметры запроса:")
|
||||
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||
logger.error(f" image_url: {image_url}")
|
||||
logger.error(f" caption_length: {len(initial_text)}")
|
||||
logger.error(f" parse_mode: HTML")
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if error_msg.startswith("RetryAfter:"):
|
||||
retry_after = int(error_msg.split(":")[1])
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
logger.warning(f"Не удалось отправить изображение для события номер {event_data['number']}: {e}. Отправляем текстовое сообщение.")
|
||||
message = await bot.send_message(
|
||||
chat_id=CHANNEL_ID,
|
||||
text=initial_text,
|
||||
parse_mode="HTML",
|
||||
disable_notification=PUBLISH_SILENTLY,
|
||||
disable_web_page_preview=True # Отключаем предпросмотр ссылок
|
||||
)
|
||||
logger.error(f"Полная информация об ошибке Telegram при отправке изображения для события {event_data['number']}:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
logger.info(f"Попытка отправки текстового сообщения вместо фото для события {event_data['number']}")
|
||||
try:
|
||||
text_payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'text': initial_text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_notification': PUBLISH_SILENTLY,
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
text_response = await httpx_client.post(send_message_url, json=text_payload)
|
||||
|
||||
if text_response.status_code == 200:
|
||||
text_result = text_response.json()
|
||||
if text_result.get('ok'):
|
||||
message_data = text_result.get('result', {})
|
||||
message_id = message_data.get('message_id')
|
||||
logger.info(f"Текстовое сообщение успешно отправлено для события {event_data['number']}, message_id={message_id}")
|
||||
else:
|
||||
error_code = text_result.get('error_code')
|
||||
if error_code == 429:
|
||||
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
|
||||
else:
|
||||
if text_response.status_code == 429:
|
||||
retry_after = 60
|
||||
try:
|
||||
error_data = text_response.json()
|
||||
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
|
||||
except:
|
||||
pass
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для события номер {event_data['number']}")
|
||||
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
logger.error(f" Параметры запроса:")
|
||||
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||
logger.error(f" text_length: {len(initial_text)}")
|
||||
logger.error(f" parse_mode: HTML")
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if error_msg.startswith("RetryAfter:"):
|
||||
raise
|
||||
raise
|
||||
else:
|
||||
message = await bot.send_message(
|
||||
chat_id=CHANNEL_ID,
|
||||
text=initial_text,
|
||||
parse_mode="HTML",
|
||||
disable_notification=PUBLISH_SILENTLY,
|
||||
disable_web_page_preview=True # Отключаем предпросмотр ссылок
|
||||
)
|
||||
|
||||
# Получаем ID сообщения для использования в ссылке подписки
|
||||
message_id = message.message_id
|
||||
logger.info(f"Попытка отправки текстового сообщения для события {event_data['number']}: chat_id={CHANNEL_ID}, text_length={len(initial_text)}")
|
||||
try:
|
||||
text_payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'text': initial_text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_notification': PUBLISH_SILENTLY,
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
text_response = await httpx_client.post(send_message_url, json=text_payload)
|
||||
|
||||
if text_response.status_code == 200:
|
||||
text_result = text_response.json()
|
||||
if text_result.get('ok'):
|
||||
message_data = text_result.get('result', {})
|
||||
message_id = message_data.get('message_id')
|
||||
logger.info(f"Текстовое сообщение успешно отправлено для события {event_data['number']}, message_id={message_id}")
|
||||
else:
|
||||
error_code = text_result.get('error_code')
|
||||
if error_code == 429:
|
||||
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
|
||||
else:
|
||||
if text_response.status_code == 429:
|
||||
retry_after = 60
|
||||
try:
|
||||
error_data = text_response.json()
|
||||
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
|
||||
except:
|
||||
pass
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для события номер {event_data['number']}")
|
||||
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
logger.error(f" Параметры запроса:")
|
||||
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||
logger.error(f" text_length: {len(initial_text)}")
|
||||
logger.error(f" parse_mode: HTML")
|
||||
raise
|
||||
|
||||
# Формируем финальный текст с ссылками
|
||||
links_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>'
|
||||
else:
|
||||
links_line = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>'
|
||||
final_text = f"{initial_text}\n\n{links_line}"
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Редактируем сообщение, добавляя ссылки
|
||||
if has_image:
|
||||
try:
|
||||
await bot.edit_message_caption(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
caption=final_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramError as e:
|
||||
logger.warning(f"Не удалось отредактировать подпись для события номер {event_data['number']}: {e}. Пробуем отредактировать текстовое сообщение.")
|
||||
await bot.edit_message_text(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
text=final_text,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True # Отключаем предпросмотр ссылок
|
||||
)
|
||||
else:
|
||||
await bot.edit_message_text(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
text=final_text,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True # Отключаем предпросмотр ссылок
|
||||
links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
|
||||
links_parts.append(
|
||||
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)}">Подписка</a>'
|
||||
)
|
||||
|
||||
if links_parts:
|
||||
links_line = ' | '.join(links_parts)
|
||||
final_text = f"{initial_text}\n\n{links_line}"
|
||||
else:
|
||||
final_text = initial_text
|
||||
|
||||
expected_urls = []
|
||||
if DESC_PREFIX:
|
||||
expected_urls.append(f"{DESC_PREFIX}{number}/")
|
||||
if USE_SUBSCRIPTION_BOT:
|
||||
expected_urls.append(f"{PZK_PREFIX}{number}/")
|
||||
expected_urls.append(
|
||||
subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)
|
||||
)
|
||||
|
||||
# Проверяем, изменился ли текст. Если нет - не редактируем сообщение
|
||||
if final_text == initial_text:
|
||||
logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
|
||||
return message_id
|
||||
|
||||
entity_label = f"события {event_data['number']}"
|
||||
links_ok = await _edit_event_add_links(
|
||||
httpx_client, message_is_photo, message_id, final_text, entity_label,
|
||||
expected_urls,
|
||||
)
|
||||
if not links_ok and message_is_photo:
|
||||
links_ok = await _edit_event_add_links(
|
||||
httpx_client, False, message_id, final_text, entity_label,
|
||||
expected_urls,
|
||||
)
|
||||
if not links_ok:
|
||||
logger.error(
|
||||
f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
|
||||
f"но ссылки не добавлены"
|
||||
)
|
||||
return None
|
||||
|
||||
return message_id # Возвращаем только message_id
|
||||
|
||||
except RetryAfter as e:
|
||||
# Обработка ошибки FloodWait (429)
|
||||
logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}: {e}")
|
||||
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
|
||||
raise # Пробрасываем исключение выше для обработки на уровне цикла
|
||||
except TelegramError as e:
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"ТАЙМАУТ при публикации события номер {event_data['number']}")
|
||||
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
logger.error(f" Контекст события:")
|
||||
logger.error(f" number: {event_data.get('number')}")
|
||||
logger.error(f" has_image: {bool(event_data.get('about_social_picture'))}")
|
||||
logger.error(f" image_url_length: {len(event_data.get('about_social_picture', '') or '')}")
|
||||
logger.error(f" initial_text_length: {len(initial_text) if 'initial_text' in locals() else 'N/A'}")
|
||||
logger.error(f" final_text_length: {len(final_text) if 'final_text' in locals() else 'N/A'}")
|
||||
return None
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if error_msg.startswith("RetryAfter:"):
|
||||
# Обработка ошибки FloodWait (429)
|
||||
retry_after = int(error_msg.split(":")[1])
|
||||
logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}")
|
||||
logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
|
||||
logger.error(f"Полная информация об ошибке RetryAfter для события {event_data['number']}:")
|
||||
logger.error(f" Тип ошибки: RetryAfter")
|
||||
logger.error(f" Сообщение: {error_msg}")
|
||||
logger.error(f" retry_after: {retry_after}")
|
||||
raise RetryAfterException(retry_after)
|
||||
logger.error(f"Ошибка Telegram при публикации события номер {event_data['number']}: {e}")
|
||||
logger.error(f"Полная информация об ошибке Telegram для события {event_data['number']}:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при публикации события номер {event_data['number']}: {e}")
|
||||
@@ -227,60 +618,147 @@ async def tg_post_event_by_id(id_event):
|
||||
password=MDB_PW,
|
||||
database=MDBASE,
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
|
||||
)
|
||||
|
||||
# Вычисляем время, до которого должно быть изменено событие для публикации
|
||||
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
||||
|
||||
# Проверка времени изменения только в режиме ZILANT
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
|
||||
FROM events
|
||||
WHERE id_event = %s
|
||||
AND marked_to_publication = True
|
||||
AND is_posted_tg = False
|
||||
AND modified <= %s
|
||||
""", (id_event, modified_before))
|
||||
event = cursor.fetchone()
|
||||
if WORKMODE == "ZILANT":
|
||||
# Вычисляем время, до которого должно быть изменено событие для публикации
|
||||
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
|
||||
FROM events
|
||||
WHERE id_event = %s
|
||||
AND marked_to_publication = True
|
||||
AND is_posted_tg = False
|
||||
AND modified <= %s
|
||||
""", (id_event, modified_before))
|
||||
event = cursor.fetchone()
|
||||
|
||||
if not event:
|
||||
logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад")
|
||||
return
|
||||
if not event:
|
||||
logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад")
|
||||
return
|
||||
else:
|
||||
# В режиме VOLK проверка времени не выполняется
|
||||
cursor.execute("""
|
||||
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
|
||||
FROM events
|
||||
WHERE id_event = %s
|
||||
AND marked_to_publication = True
|
||||
AND is_posted_tg = False
|
||||
""", (id_event,))
|
||||
event = cursor.fetchone()
|
||||
|
||||
if not event:
|
||||
logger.info(f"Событие ID {id_event} не найдено или уже опубликовано")
|
||||
return
|
||||
|
||||
# Получаем информацию о категории (площадке), если указана unit_name
|
||||
unit_tg_id = None
|
||||
unit_title = None
|
||||
if event.get('unit_name'):
|
||||
cursor.execute("""
|
||||
SELECT TITLE, tg_id
|
||||
FROM categories
|
||||
WHERE ID = %s
|
||||
""", (event['unit_name'],))
|
||||
category = cursor.fetchone()
|
||||
if category:
|
||||
unit_title = category.get('TITLE')
|
||||
unit_tg_id = category.get('tg_id')
|
||||
|
||||
# Добавляем информацию о категории в данные события
|
||||
event['unit_tg_id'] = unit_tg_id
|
||||
event['unit_title'] = unit_title
|
||||
|
||||
old_message_id = event.get('tg_message_id') # Сохраняем старый ID сообщения
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
tg_message_id = await tg_post_event(bot, event) # Получаем новый message_id
|
||||
# Создаем httpx клиент с отключенным HTTP/2
|
||||
httpx_client = httpx.AsyncClient(
|
||||
http2=False,
|
||||
timeout=20.0,
|
||||
follow_redirects=True
|
||||
)
|
||||
tg_message_id = None
|
||||
try:
|
||||
tg_message_id = await tg_post_event(httpx_client, event) # Получаем новый message_id
|
||||
finally:
|
||||
await httpx_client.aclose()
|
||||
|
||||
if tg_message_id:
|
||||
# Используем UTC время для записи в базу данных
|
||||
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Обновляем подписки если был предыдущий message_id
|
||||
if old_message_id:
|
||||
cursor.execute("""
|
||||
UPDATE marks_evt
|
||||
SET tg_event_id = %s
|
||||
WHERE tg_event_id = %s
|
||||
""", (tg_message_id, old_message_id))
|
||||
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
|
||||
# Обновляем базу данных в отдельном блоке try-except для гарантии сохранения
|
||||
try:
|
||||
# Обновляем подписки если был предыдущий message_id
|
||||
if old_message_id:
|
||||
cursor.execute("""
|
||||
UPDATE marks_evt
|
||||
SET tg_event_id = %s
|
||||
WHERE tg_event_id = %s
|
||||
""", (tg_message_id, old_message_id))
|
||||
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE events
|
||||
SET is_posted_tg = True,
|
||||
tg_message_id = %s,
|
||||
tg_posted_date = %s
|
||||
WHERE id_event = %s
|
||||
""", (tg_message_id, current_time_utc, id_event))
|
||||
conn.commit()
|
||||
logger.info(f"Событие номер {event['number']} успешно опубликовано")
|
||||
# Обновляем данные о публикации события
|
||||
cursor.execute("""
|
||||
UPDATE events
|
||||
SET is_posted_tg = True,
|
||||
tg_message_id = %s,
|
||||
tg_posted_date = %s
|
||||
WHERE id_event = %s
|
||||
""", (tg_message_id, current_time_utc, id_event))
|
||||
conn.commit()
|
||||
logger.info(f"Событие номер {event['number']} успешно опубликовано. Данные сохранены в БД: message_id={tg_message_id}, date={current_time_utc}")
|
||||
except Exception as db_error:
|
||||
logger.error(f"Ошибка при сохранении данных о публикации события {id_event} в БД: {db_error}")
|
||||
# Пытаемся выполнить commit даже при ошибке
|
||||
try:
|
||||
conn.rollback()
|
||||
# Повторная попытка обновления
|
||||
cursor.execute("""
|
||||
UPDATE events
|
||||
SET is_posted_tg = True,
|
||||
tg_message_id = %s,
|
||||
tg_posted_date = %s
|
||||
WHERE id_event = %s
|
||||
""", (tg_message_id, current_time_utc, id_event))
|
||||
conn.commit()
|
||||
logger.info(f"Данные о публикации события {id_event} успешно сохранены после повторной попытки")
|
||||
except Exception as retry_error:
|
||||
logger.error(f"Критическая ошибка при сохранении данных о публикации события {id_event}: {retry_error}")
|
||||
raise
|
||||
else:
|
||||
logger.error(f"Не удалось опубликовать событие ID {id_event}")
|
||||
|
||||
except RetryAfter as e:
|
||||
raise e
|
||||
except RetryAfterException as e:
|
||||
# Обработка RetryAfter (FloodWait)
|
||||
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
|
||||
logger.error(f" Тип ошибки: RetryAfter")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" retry_after: {e.retry_after}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
# Проверяем, не RetryAfter ли это в строковом виде
|
||||
error_msg = str(e)
|
||||
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')):
|
||||
retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
|
||||
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
|
||||
logger.error(f" Тип ошибки: RetryAfter")
|
||||
logger.error(f" Сообщение: {error_msg}")
|
||||
logger.error(f" retry_after: {retry_after}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
raise RetryAfterException(retry_after)
|
||||
# Если это не RetryAfter, обрабатываем как обычную ошибку
|
||||
logger.error(f"Ошибка при публикации: {e}")
|
||||
logger.error(f"Полная информация об ошибке в tg_post_event_by_id для события ID {id_event}:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
@@ -296,21 +774,33 @@ async def tg_post_all_events():
|
||||
password=MDB_PW,
|
||||
database=MDBASE,
|
||||
charset='utf8mb4',
|
||||
cursorclass=pymysql.cursors.DictCursor
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
|
||||
)
|
||||
|
||||
# Вычисляем время, до которого должно быть изменено событие для публикации
|
||||
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
||||
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT id_event
|
||||
FROM events
|
||||
WHERE marked_to_publication = True
|
||||
AND is_posted_tg = False
|
||||
AND modified <= %s
|
||||
LIMIT 5
|
||||
""", (modified_before,))
|
||||
# Проверка времени изменения только в режиме ZILANT
|
||||
if WORKMODE == "ZILANT":
|
||||
# Вычисляем время, до которого должно быть изменено событие для публикации
|
||||
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id_event
|
||||
FROM events
|
||||
WHERE marked_to_publication = True
|
||||
AND is_posted_tg = False
|
||||
AND modified <= %s
|
||||
LIMIT 5
|
||||
""", (modified_before,))
|
||||
else:
|
||||
# В режиме VOLK проверка времени не выполняется
|
||||
cursor.execute("""
|
||||
SELECT id_event
|
||||
FROM events
|
||||
WHERE marked_to_publication = True
|
||||
AND is_posted_tg = False
|
||||
LIMIT 5
|
||||
""")
|
||||
events = cursor.fetchall()
|
||||
|
||||
event_count = 0
|
||||
@@ -323,11 +813,29 @@ async def tg_post_all_events():
|
||||
if event_count < len(events):
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except RetryAfter as e:
|
||||
except RetryAfterException as e:
|
||||
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
|
||||
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_all_events для события ID {event['id_event']}:")
|
||||
logger.error(f" Тип ошибки: RetryAfter")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" retry_after: {e.retry_after}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
|
||||
logger.error(f"Полная информация об ошибке в tg_post_all_events для события ID {event['id_event']}:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
# Продолжаем публикацию следующих событий, несмотря на ошибку
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
|
||||
logger.error(f"Полная информация об ошибке в tg_post_all_events для события ID {event['id_event']}:")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f" Сообщение: {str(e)}")
|
||||
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f" Полное представление: {repr(e)}")
|
||||
# Продолжаем публикацию следующих событий, несмотря на ошибку
|
||||
|
||||
logger.info(f"Опубликовано событий в этом запуске: {event_count}")
|
||||
|
||||
+57
-11
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
|
||||
def truncate_text(text, max_length):
|
||||
"""Обрезает текст до максимальной длины с учетом границ слов"""
|
||||
"""Обрезает текст до максимальной длины с учетом границ слов и HTML-тегов"""
|
||||
if text is None or not text:
|
||||
return ""
|
||||
|
||||
@@ -12,6 +12,18 @@ def truncate_text(text, max_length):
|
||||
truncated = text[:max_length]
|
||||
last_space = truncated.rfind(' ')
|
||||
|
||||
# Проверяем, не обрезали ли мы HTML-тег
|
||||
# Если после обрезки есть незакрытый тег <a, нужно найти его начало и обрезать до него
|
||||
open_tag_pos = truncated.rfind('<a ')
|
||||
if open_tag_pos > 0:
|
||||
# Проверяем, закрыт ли тег
|
||||
after_tag = truncated[open_tag_pos:]
|
||||
if '</a>' not in after_tag:
|
||||
# Тег не закрыт, обрезаем до начала тега
|
||||
truncated = truncated[:open_tag_pos]
|
||||
# Ищем последний пробел перед обрезанным тегом
|
||||
last_space = truncated.rfind(' ')
|
||||
|
||||
if last_space > 0:
|
||||
return truncated[:last_space] + "..."
|
||||
return truncated[:max_length-3] + "..."
|
||||
@@ -25,27 +37,61 @@ def replace_vk_links(text):
|
||||
Заменяет ссылки на сообщества ВК в форматах:
|
||||
[club123|Название сообщества] -> Название сообщества
|
||||
[id456|Имя пользователя] -> Имя пользователя
|
||||
[текст](url) -> <a href="url">текст</a> (для Telegram)
|
||||
[url|текст] -> <a href="url">текст</a> (для Telegram)
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Сначала обрабатываем ссылки формата [url|текст] и преобразуем их в HTML-ссылки
|
||||
# Сначала обрабатываем ВК-специфичные ссылки [club*|текст] и [id*|текст]
|
||||
# Это нужно сделать до обработки обычных URL, чтобы не перехватывать их
|
||||
vk_pattern = r'\[(?:club|id)\d+\|([^\]]+)\]'
|
||||
text = re.sub(vk_pattern, r'\1', text)
|
||||
|
||||
# Затем обрабатываем Markdown-ссылки [текст](url) и преобразуем их в HTML-ссылки
|
||||
def replace_markdown_link(match):
|
||||
link_text = match.group(1).strip()
|
||||
url = match.group(2).strip()
|
||||
|
||||
# Проверяем, что URL похож на ссылку (содержит http://, https://, или начинается с /)
|
||||
# Это предотвращает обработку обычного текста в квадратных скобках как ссылки
|
||||
if not (url.startswith('http://') or url.startswith('https://') or url.startswith('/') or url.startswith('#')):
|
||||
# Если это не похоже на URL, возвращаем исходный текст без изменений
|
||||
return match.group(0)
|
||||
|
||||
# Экранируем кавычки в URL для безопасности
|
||||
url = url.replace('"', '"')
|
||||
# Экранируем HTML-сущности в тексте ссылки
|
||||
link_text = link_text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
return f'<a href="{url}">{link_text}</a>'
|
||||
|
||||
# Регулярное выражение для поиска Markdown-ссылок формата [текст](url)
|
||||
markdown_pattern = r'\[([^\]]+)\]\(([^\)]+)\)'
|
||||
text = re.sub(markdown_pattern, replace_markdown_link, text)
|
||||
|
||||
# Затем обрабатываем ссылки формата [url|текст] и преобразуем их в HTML-ссылки
|
||||
def replace_url_link(match):
|
||||
url = match.group(1)
|
||||
link_text = match.group(2)
|
||||
url = match.group(1).strip()
|
||||
link_text = match.group(2).strip()
|
||||
|
||||
# Проверяем, что URL похож на ссылку (содержит http://, https://, или начинается с /)
|
||||
# Это предотвращает обработку обычного текста в квадратных скобках как ссылки
|
||||
if not (url.startswith('http://') or url.startswith('https://') or url.startswith('/') or url.startswith('#')):
|
||||
# Если это не похоже на URL, возвращаем исходный текст без изменений
|
||||
return match.group(0)
|
||||
|
||||
# Экранируем кавычки в URL для безопасности
|
||||
url = url.replace('"', '"')
|
||||
# Экранируем HTML-сущности в тексте ссылки
|
||||
link_text = link_text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
return f'<a href="{url}">{link_text}</a>'
|
||||
|
||||
# Регулярное выражение для поиска ссылок формата [url|текст]
|
||||
# Исключаем случаи, когда url начинается с club или id и цифр, за которыми сразу следует | (это обрабатывается отдельно)
|
||||
url_pattern = r'\[(?!club\d+\||id\d+\|)([^\|]+)\|([^\]]+)\]'
|
||||
# URL должен содержать хотя бы один символ, не быть пустым
|
||||
url_pattern = r'\[([^\|\]]+)\|([^\]]+)\]'
|
||||
text = re.sub(url_pattern, replace_url_link, text)
|
||||
|
||||
# Регулярное выражение для поиска ссылок формата [типID|Текст] (ВК-специфичные)
|
||||
vk_pattern = r'\[(?:club|id)\d+\|([^\]]+)\]'
|
||||
|
||||
# Заменяем все вхождения на текст внутри ссылки
|
||||
return re.sub(vk_pattern, r'\1', text)
|
||||
return text
|
||||
|
||||
def remove_html_tags(text):
|
||||
"""Удаляет HTML-теги с учетом специальной обработки <p> и <a>"""
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Конфиг gunicorn для responder-бота.
|
||||
|
||||
Регистрация webhook выполняется в wsgi_bot.py (с file lock), чтобы сработала
|
||||
даже если unit забыли обновить с --config.
|
||||
"""
|
||||
+1
-1
@@ -36,4 +36,4 @@ sniffio==1.3.1
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.5.0
|
||||
Werkzeug==3.1.3
|
||||
yarl==1.20.1
|
||||
yarl==1.20.1
|
||||
@@ -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"
|
||||
@@ -39,10 +51,8 @@ setup_logfile() {
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Проверяем права на запись
|
||||
|
||||
if [ ! -w "$LOG_FILE" ]; then
|
||||
# Пытаемся изменить права
|
||||
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
|
||||
log_message "Изменены права на лог-файл: $LOG_FILE"
|
||||
else
|
||||
@@ -83,7 +93,7 @@ cd "$SCRIPTS_DIR" || {
|
||||
# Запуск скриптов
|
||||
for i in "${!SCRIPTS[@]}"; do
|
||||
script="${SCRIPTS[i]}"
|
||||
|
||||
|
||||
# Проверка существования скрипта
|
||||
if [ ! -f "$script" ]; then
|
||||
log_message "ПРЕДУПРЕЖДЕНИЕ: Скрипт $script не найден, пропускаем."
|
||||
@@ -91,7 +101,7 @@ for i in "${!SCRIPTS[@]}"; do
|
||||
fi
|
||||
|
||||
log_message "Запуск скрипта: $script"
|
||||
|
||||
|
||||
# Запуск Python-скрипта с записью вывода в лог
|
||||
if python3 "$script" >> "$LOG_FILE" 2>&1; then
|
||||
log_message "УСПЕХ: Скрипт $script завершился успешно."
|
||||
@@ -112,4 +122,3 @@ done
|
||||
|
||||
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
|
||||
log_message "=== ЗАВЕРШЕНИЕ ==="
|
||||
|
||||
|
||||
+127
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Перенесено
|
||||
|
||||
Документация: [`docs/runner-setup.md`](../docs/runner-setup.md)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 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]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,314 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% if category_id %}Редактирование площадки{% else %}Добавление площадки{% endif %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
.form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.preview-image {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.required:after {
|
||||
content: " *";
|
||||
color: red;
|
||||
}
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
}
|
||||
.form-label {
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
height: 38px;
|
||||
}
|
||||
.form-control, .form-select {
|
||||
height: 38px;
|
||||
}
|
||||
textarea.form-control {
|
||||
height: auto;
|
||||
}
|
||||
.label-narrow {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
.field-wide {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>{% if category_id %}Редактирование площадки{% else %}Добавление площадки{% endif %}</h2>
|
||||
<a href="#" id="back-button" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left me-1"></i> Назад
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card form-container">
|
||||
<div class="card-body">
|
||||
<form id="category-form">
|
||||
<input type="hidden" id="category-id" value="{{ category_id if category_id else '' }}">
|
||||
|
||||
<!-- ID -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="label-narrow">
|
||||
<label for="id" class="form-label required">ID:</label>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<input type="text" class="form-control" id="id" maxlength="255" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Название -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="label-narrow">
|
||||
<label for="title" class="form-label">Название:</label>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<input type="text" class="form-control" id="title" maxlength="2048">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Описание -->
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Описание:</label>
|
||||
<textarea class="form-control" id="description" rows="6"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- URL изображения и предпросмотр -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="label-narrow">
|
||||
<label for="image_url" class="form-label">Картинка:</label>
|
||||
</div>
|
||||
<div class="field-wide">
|
||||
<input type="url" class="form-control" id="image_url" maxlength="2048">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<div id="image-preview-container" style="display: none;">
|
||||
<img id="image-preview" class="preview-image img-thumbnail" style="max-width: 200px; max-height: 200px; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TG ID -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="label-narrow">
|
||||
<label for="tg_id" class="form-label">TG ID:</label>
|
||||
</div>
|
||||
<div style="flex: 0 0 200px;">
|
||||
<input type="text" class="form-control" id="tg_id" pattern="[0-9]*" inputmode="numeric">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Переключатель публикации -->
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="marked_for_publication">
|
||||
<label class="form-check-label" for="marked_for_publication">Отметить для публикации</label>
|
||||
</div>
|
||||
|
||||
<!-- Опубликовано в TG -->
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="is_tg_published">
|
||||
<label class="form-check-label" for="is_tg_published">Опубликовано в TG</label>
|
||||
</div>
|
||||
|
||||
<!-- Дата публикации в TG -->
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="label-narrow">
|
||||
<label for="tg_published_date" class="form-label">Дата публикации в TG:</label>
|
||||
</div>
|
||||
<div style="flex: 0 0 300px;">
|
||||
<input type="datetime-local" class="form-control" id="tg_published_date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-4">
|
||||
<button type="button" class="btn btn-secondary me-2" id="cancel-btn">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if category_id %}Обновить{% else %}Создать{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контейнер для уведомлений -->
|
||||
<div class="toast-container">
|
||||
<div id="success-toast" class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="fas fa-check-circle me-2"></i> Площадка успешно сохранена!
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const PREFIX = "{{ g.prefix }}";
|
||||
console.log("Category Editor Prefix is:", PREFIX);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const categoryId = document.getElementById('category-id').value;
|
||||
const form = document.getElementById('category-form');
|
||||
const imageUrlInput = document.getElementById('image_url');
|
||||
const imagePreviewContainer = document.getElementById('image-preview-container');
|
||||
const imagePreview = document.getElementById('image-preview');
|
||||
const successToast = new bootstrap.Toast(document.getElementById('success-toast'));
|
||||
|
||||
// Обработчики кнопок отмены и назад
|
||||
const cancelHandler = () => {
|
||||
if (categoryId) {
|
||||
window.location.href = `${PREFIX}/?selected_category=${categoryId}&tab=categories`;
|
||||
} else {
|
||||
window.location.href = `${PREFIX}/?tab=categories`;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('cancel-btn').addEventListener('click', cancelHandler);
|
||||
document.getElementById('back-button').addEventListener('click', cancelHandler);
|
||||
|
||||
// Предпросмотр изображения
|
||||
imageUrlInput.addEventListener('change', () => {
|
||||
const url = imageUrlInput.value.trim();
|
||||
if (url) {
|
||||
imagePreview.src = `${PREFIX}/image_proxy?url=${encodeURIComponent(url)}`;
|
||||
imagePreviewContainer.style.display = 'block';
|
||||
} else {
|
||||
imagePreviewContainer.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка данных категории при редактировании
|
||||
if (categoryId) {
|
||||
loadCategoryData(categoryId);
|
||||
}
|
||||
|
||||
// Обработка отправки формы
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await saveCategory(categoryId);
|
||||
});
|
||||
});
|
||||
|
||||
// Загрузка данных категории
|
||||
async function loadCategoryData(categoryId) {
|
||||
try {
|
||||
const response = await fetch(`${PREFIX}/api/categories/${encodeURIComponent(categoryId)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Площадка не найдена');
|
||||
}
|
||||
|
||||
const category = await response.json();
|
||||
|
||||
// Заполняем форму
|
||||
document.getElementById('id').value = category.id || '';
|
||||
document.getElementById('title').value = category.title || '';
|
||||
document.getElementById('description').value = category.description || '';
|
||||
document.getElementById('image_url').value = category.image_url || '';
|
||||
document.getElementById('tg_id').value = category.tg_id || '';
|
||||
document.getElementById('marked_for_publication').checked = category.marked_for_publication || false;
|
||||
document.getElementById('is_tg_published').checked = category.is_tg_published || false;
|
||||
|
||||
// Устанавливаем дату публикации, если она есть
|
||||
if (category.tg_published_date) {
|
||||
// Преобразуем ISO дату в формат datetime-local (YYYY-MM-DDTHH:mm)
|
||||
const date = new Date(category.tg_published_date);
|
||||
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
document.getElementById('tg_published_date').value = localDate.toISOString().slice(0, 16);
|
||||
} else {
|
||||
document.getElementById('tg_published_date').value = '';
|
||||
}
|
||||
|
||||
// Показываем предпросмотр изображения, если есть
|
||||
if (category.image_url) {
|
||||
const imagePreview = document.getElementById('image-preview');
|
||||
const imagePreviewContainer = document.getElementById('image-preview-container');
|
||||
imagePreview.src = `${PREFIX}/image_proxy?url=${encodeURIComponent(category.image_url)}`;
|
||||
imagePreviewContainer.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка загрузки данных площадки: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранение категории
|
||||
async function saveCategory(categoryId) {
|
||||
const form = document.getElementById('category-form');
|
||||
const tgPublishedDateValue = document.getElementById('tg_published_date').value;
|
||||
let tgPublishedDate = null;
|
||||
if (tgPublishedDateValue) {
|
||||
// Преобразуем локальное время в UTC для сохранения в БД
|
||||
const localDate = new Date(tgPublishedDateValue);
|
||||
tgPublishedDate = localDate.toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
const formData = {
|
||||
id: document.getElementById('id').value.trim(),
|
||||
title: document.getElementById('title').value.trim(),
|
||||
description: document.getElementById('description').value.trim(),
|
||||
image_url: document.getElementById('image_url').value.trim(),
|
||||
tg_id: document.getElementById('tg_id').value.trim() || null,
|
||||
marked_for_publication: document.getElementById('marked_for_publication').checked,
|
||||
is_tg_published: document.getElementById('is_tg_published').checked,
|
||||
tg_published_date: tgPublishedDate
|
||||
};
|
||||
|
||||
if (!formData.id) {
|
||||
alert('ID площадки обязателен для заполнения');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = categoryId
|
||||
? `${PREFIX}/api/categories/${encodeURIComponent(categoryId)}`
|
||||
: `${PREFIX}/api/categories`;
|
||||
const method = categoryId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
const successToast = new bootstrap.Toast(document.getElementById('success-toast'));
|
||||
successToast.show();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = `${PREFIX}/?selected_category=${encodeURIComponent(formData.id)}&tab=categories`;
|
||||
}, 1000);
|
||||
} else {
|
||||
throw new Error(result.error || 'Ошибка сохранения площадки');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка сохранения площадки: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+919
-52
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
+534
-169
File diff suppressed because it is too large
Load Diff
+526
-145
@@ -6,11 +6,37 @@ import time
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import httpx
|
||||
import traceback
|
||||
import io
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from telegram import Bot, PollOption
|
||||
from telegram.error import TelegramError, RetryAfter
|
||||
from dotenv import load_dotenv
|
||||
from formatter import get_event_text
|
||||
from telegram import Bot
|
||||
from telegram.error import TelegramError, TimedOut
|
||||
from telegram.request import HTTPXRequest
|
||||
from telegram_relay import (
|
||||
get_ptb_base_urls,
|
||||
get_relay_url,
|
||||
EDIT_LINKS_INITIAL_DELAY_SEC,
|
||||
EDIT_LINKS_MAX_ATTEMPTS,
|
||||
EDIT_LINKS_RETRY_INTERVAL_SEC,
|
||||
published_message_has_expected_links,
|
||||
extract_text_link_urls,
|
||||
)
|
||||
from season_links import subscription_start_link
|
||||
|
||||
# Настройка anyio для правильной работы в отдельном потоке
|
||||
# Устанавливаем правильный backend для anyio (используем asyncio)
|
||||
try:
|
||||
import anyio
|
||||
# Устанавливаем backend для anyio явно
|
||||
if not hasattr(anyio, '_backend'):
|
||||
# Используем asyncio backend
|
||||
os.environ.setdefault('ANYIO_BACKEND', 'asyncio')
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Загрузка переменных окружения
|
||||
load_dotenv()
|
||||
@@ -36,6 +62,9 @@ PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1
|
||||
# Использование бота подписки
|
||||
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
# Режим отладочного логирования данных
|
||||
LOG_DEBUG_DATA = os.getenv('LOG_DEBUG_DATA', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
# Настройка логирования
|
||||
logger = logging.getLogger('TG_Poster')
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -60,6 +89,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# Класс для обработки RetryAfter (FloodWait 429)
|
||||
class RetryAfterException(Exception):
|
||||
"""Исключение для обработки FloodWait (429) от Telegram API"""
|
||||
def __init__(self, retry_after):
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"RetryAfter: {retry_after}")
|
||||
|
||||
def prepare_text(text):
|
||||
"""Подготовка текстовых полей с экранированием HTML-сущностей"""
|
||||
if not text:
|
||||
@@ -79,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}")
|
||||
@@ -108,168 +271,386 @@ async def publish_to_tg(vk_post_id):
|
||||
logger.info(f"Запись VK ID {vk_post_id} не найдена или уже опубликована")
|
||||
return False
|
||||
|
||||
bot = Bot(token=BOT_TOKEN)
|
||||
# Настройка таймаутов для HTTPXRequest (аналогично test_tg_poster.py)
|
||||
request = HTTPXRequest(
|
||||
read_timeout=120.0, # Таймаут на чтение (увеличен для больших изображений)
|
||||
write_timeout=120.0, # Таймаут на запись (увеличен для больших файлов)
|
||||
connect_timeout=30.0, # Таймаут на подключение (увеличен)
|
||||
pool_timeout=10.0, # Таймаут на получение соединения из пула
|
||||
media_write_timeout=120.0 # Таймаут для медиа
|
||||
)
|
||||
|
||||
# Проверяем наличие организационного хештега
|
||||
is_org_message = has_org_tag(post['text'])
|
||||
# Bot API только через internal_relay
|
||||
base_url, base_file_url = get_ptb_base_urls()
|
||||
bot = Bot(
|
||||
token=BOT_TOKEN,
|
||||
request=request,
|
||||
base_url=base_url,
|
||||
base_file_url=base_file_url,
|
||||
)
|
||||
logger.info(f"Telegram Bot API через ретранслятор: {get_relay_url()}")
|
||||
|
||||
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
|
||||
placeholder_message_id = '0' * 20 # Заполнитель для message_id
|
||||
# Создаем httpx клиент только для скачивания изображений
|
||||
timeout_config = httpx.Timeout(
|
||||
connect=10.0, # Таймаут на подключение
|
||||
read=120.0, # Таймаут на чтение (увеличен для больших изображений)
|
||||
write=120.0, # Таймаут на запись (увеличен для больших файлов)
|
||||
pool=10.0 # Таймаут на получение соединения из пула
|
||||
)
|
||||
httpx_client = httpx.AsyncClient(
|
||||
http2=False,
|
||||
timeout=timeout_config,
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
# Формируем ссылки
|
||||
links = []
|
||||
# Параметры для повторных попыток при таймаутах
|
||||
max_retries = 10
|
||||
retry_interval = 5
|
||||
|
||||
# Ссылка на оригинал в ВК
|
||||
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||
links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
||||
# Переменная для отслеживания успешности публикации
|
||||
message_id = None
|
||||
publication_successful = False
|
||||
# Флаг, указывающий, было ли отправлено изображение (True) или текстовое сообщение (False)
|
||||
message_has_image = False
|
||||
|
||||
# Ссылка на подписку (только для событий и если включен бот подписки)
|
||||
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||
links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{placeholder_message_id}">🔜 Подписка</a>')
|
||||
|
||||
# Формируем строку ссылок
|
||||
links_line = " | ".join(links) if links else ""
|
||||
links_line_length = len(links_line)
|
||||
|
||||
# Определяем максимальную длину в зависимости от типа сообщения
|
||||
# Если это организационное сообщение, используем текстовый лимит
|
||||
image_url = post['image_url']
|
||||
has_image = image_url and image_url.strip() and not is_org_message
|
||||
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
|
||||
|
||||
# Вычисляем доступную длину для текста
|
||||
available_length = max_length - links_line_length - 2 # -2 для символов переноса строки
|
||||
if available_length < 0:
|
||||
available_length = 0
|
||||
|
||||
# Получаем обработанный текст с учетом доступной длины
|
||||
text = get_event_text(post['text'], available_length) if post['text'] else ""
|
||||
|
||||
# Публикация первоначального сообщения без ссылок
|
||||
if has_image and not is_org_message:
|
||||
try:
|
||||
message = await bot.send_photo(
|
||||
chat_id=CHANNEL_ID,
|
||||
photo=image_url,
|
||||
caption=text,
|
||||
parse_mode="HTML",
|
||||
disable_notification=PUBLISH_SILENTLY
|
||||
)
|
||||
except TelegramError as e:
|
||||
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
|
||||
message = await bot.send_message(
|
||||
chat_id=CHANNEL_ID,
|
||||
text=text,
|
||||
parse_mode="HTML",
|
||||
disable_notification=PUBLISH_SILENTLY,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
else:
|
||||
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
|
||||
message = await bot.send_message(
|
||||
chat_id=CHANNEL_ID,
|
||||
text=text,
|
||||
parse_mode="HTML",
|
||||
disable_notification=PUBLISH_SILENTLY,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
# Получаем ID сообщения для использования в ссылке подписки
|
||||
message_id = message.message_id
|
||||
|
||||
# Формируем финальный текст с ссылками
|
||||
if links:
|
||||
# Обновляем ссылку подписки с реальным message_id
|
||||
updated_links = []
|
||||
try:
|
||||
# Проверяем наличие организационного хештега
|
||||
is_org_message = has_org_tag(post['text'])
|
||||
|
||||
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
|
||||
placeholder_message_id = '0' * 20 # Заполнитель для message_id
|
||||
|
||||
# Формируем ссылки
|
||||
links = []
|
||||
|
||||
# Ссылка на оригинал в ВК
|
||||
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
||||
links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
||||
|
||||
# Ссылка на подписку (только для событий и если включен бот подписки)
|
||||
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||
updated_links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{message_id}">🔜 Подписка</a>')
|
||||
links.append(
|
||||
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", placeholder_message_id)}">🔜 Подписка</a>'
|
||||
)
|
||||
|
||||
links_line = " | ".join(updated_links)
|
||||
final_text = f"{text}\n\n{links_line}"
|
||||
# Формируем строку ссылок
|
||||
links_line = " | ".join(links) if links else ""
|
||||
links_line_length = len(links_line)
|
||||
|
||||
# Определяем максимальную длину в зависимости от типа сообщения
|
||||
# Если это организационное сообщение, используем текстовый лимит
|
||||
image_url = post['image_url']
|
||||
has_image = image_url and image_url.strip() and not is_org_message
|
||||
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
|
||||
|
||||
# Вычисляем доступную длину для текста
|
||||
available_length = max_length - links_line_length - 2 # -2 для символов переноса строки
|
||||
if available_length < 0:
|
||||
available_length = 0
|
||||
|
||||
# Получаем обработанный текст с учетом доступной длины
|
||||
text = get_event_text(post['text'], available_length) if post['text'] else ""
|
||||
|
||||
# Логирование отформатированного текста для отладки (только если включено)
|
||||
if LOG_DEBUG_DATA:
|
||||
logger.info(f"Начинаем форматирование текста для записи VK ID {vk_post_id}, доступная длина: {available_length}")
|
||||
logger.info(f"=== Отформатированный текст для записи VK ID {vk_post_id} ===")
|
||||
logger.info(f"Длина текста: {len(text)}")
|
||||
logger.info(f"Текст (repr): {repr(text)}")
|
||||
logger.info(f"Текст (содержимое):")
|
||||
# Выводим текст построчно для лучшей читаемости в логе
|
||||
for i, line in enumerate(text.split('\n'), 1):
|
||||
logger.info(f" Строка {i}: {repr(line)}")
|
||||
logger.info(f"=== Конец отформатированного текста ===")
|
||||
|
||||
# Публикация первоначального сообщения без ссылок
|
||||
if has_image and not is_org_message:
|
||||
try:
|
||||
# Логируем информацию об изображении перед отправкой
|
||||
logger.info(f"Отправка изображения для записи VK ID {vk_post_id}")
|
||||
logger.info(f"URL изображения: {image_url}")
|
||||
logger.info(f"Длина URL изображения: {len(image_url)} символов")
|
||||
logger.info(f"Длина подписи: {len(text)} символов")
|
||||
|
||||
# Скачиваем изображение
|
||||
image_data = None
|
||||
try:
|
||||
image_data = await download_image(image_url, httpx_client)
|
||||
except Exception as download_error:
|
||||
logger.error(f"Ошибка при скачивании изображения для записи VK ID {vk_post_id}: {download_error}")
|
||||
raise # Пробрасываем ошибку, чтобы перейти к fallback
|
||||
|
||||
file_size_mb = len(image_data) / (1024 * 1024)
|
||||
logger.info(f"Размер файла: {file_size_mb:.2f} МБ")
|
||||
|
||||
# Повторные попытки отправки фото при таймауте (до 10 раз)
|
||||
message = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
image_file = io.BytesIO(image_data)
|
||||
message = await bot.send_photo(
|
||||
chat_id=CHANNEL_ID,
|
||||
photo=image_file,
|
||||
caption=text,
|
||||
parse_mode='HTML',
|
||||
disable_notification=PUBLISH_SILENTLY
|
||||
)
|
||||
message_id = message.message_id
|
||||
message_has_image = True
|
||||
logger.info(f"Сообщение с изображением успешно опубликовано. Message ID: {message_id}")
|
||||
break # Успешная публикация, выходим из цикла
|
||||
except TimedOut as e:
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Таймаут при публикации изображения (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
|
||||
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
|
||||
await asyncio.sleep(retry_interval)
|
||||
else:
|
||||
logger.error(f"Таймаут при публикации изображения после {max_retries} попыток для записи VK ID {vk_post_id}")
|
||||
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise # Пробрасываем таймаут наверх
|
||||
except TelegramError as e:
|
||||
# Проверяем, является ли это ошибкой FloodWait (429)
|
||||
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||
retry_after = getattr(e, 'retry_after', 60)
|
||||
raise RetryAfterException(retry_after)
|
||||
logger.error(f"Ошибка Telegram при публикации изображения для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
logger.error(f"Детали ошибки: {repr(e)}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при публикации изображения для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
logger.error(f"Детали ошибки: {repr(e)}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
if message_id is None:
|
||||
raise Exception("Не удалось опубликовать изображение после всех попыток")
|
||||
|
||||
except RetryAfterException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Если не удалось отправить изображение, пробуем отправить текстовое сообщение
|
||||
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
|
||||
message_has_image = False # Отправляем текстовое сообщение вместо изображения
|
||||
|
||||
# Повторные попытки отправки текста (fallback) при таймауте
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
message = await bot.send_message(
|
||||
chat_id=CHANNEL_ID,
|
||||
text=text,
|
||||
parse_mode='HTML',
|
||||
disable_notification=PUBLISH_SILENTLY,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
message_id = message.message_id
|
||||
logger.info(f"Текстовое сообщение (fallback) успешно опубликовано. Message ID: {message_id}")
|
||||
break # Успешная публикация, выходим из цикла
|
||||
except TimedOut as e:
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Таймаут при отправке текста (fallback) (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
|
||||
await asyncio.sleep(retry_interval)
|
||||
else:
|
||||
logger.error(f"Таймаут при отправке текста (fallback) после {max_retries} попыток для записи VK ID {vk_post_id}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise
|
||||
except TelegramError as e:
|
||||
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||
retry_after = getattr(e, 'retry_after', 60)
|
||||
raise RetryAfterException(retry_after)
|
||||
logger.error(f"Ошибка Telegram при отправке текста (fallback) для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке текста (fallback) для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
raise
|
||||
else:
|
||||
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
|
||||
message_has_image = False # Отправляем текстовое сообщение
|
||||
|
||||
# Повторные попытки отправки текста при таймауте
|
||||
message = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
message = await bot.send_message(
|
||||
chat_id=CHANNEL_ID,
|
||||
text=text,
|
||||
parse_mode='HTML',
|
||||
disable_notification=PUBLISH_SILENTLY,
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
message_id = message.message_id
|
||||
logger.info(f"Текстовое сообщение успешно опубликовано. Message ID: {message_id}")
|
||||
break # Успешная публикация, выходим из цикла
|
||||
except TimedOut as e:
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Таймаут при публикации текста (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
|
||||
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
|
||||
await asyncio.sleep(retry_interval)
|
||||
else:
|
||||
logger.error(f"Таймаут при публикации текста после {max_retries} попыток для записи VK ID {vk_post_id}")
|
||||
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise # Пробрасываем таймаут наверх
|
||||
except TelegramError as e:
|
||||
# Проверяем, является ли это ошибкой FloodWait (429)
|
||||
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||
retry_after = getattr(e, 'retry_after', 60)
|
||||
raise RetryAfterException(retry_after)
|
||||
logger.error(f"Ошибка Telegram при публикации текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
logger.error(f"Детали ошибки: {repr(e)}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при публикации текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
logger.error(f"Детали ошибки: {repr(e)}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
if message_id is None:
|
||||
raise Exception("Не удалось опубликовать текстовое сообщение после всех попыток")
|
||||
|
||||
# Формируем финальный текст с ссылками
|
||||
if links:
|
||||
# Обновляем ссылку подписки с реальным message_id
|
||||
updated_links = []
|
||||
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
||||
|
||||
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||
updated_links.append(
|
||||
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)}">🔜 Подписка</a>'
|
||||
)
|
||||
|
||||
links_line = " | ".join(updated_links)
|
||||
final_text = f"{text}\n\n{links_line}"
|
||||
expected_urls = []
|
||||
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||
expected_urls.append(post['vk_post_url'])
|
||||
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||
expected_urls.append(
|
||||
subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
|
||||
)
|
||||
if not await _edit_post_add_links(
|
||||
bot, message_id, message_has_image, final_text, vk_post_id,
|
||||
expected_urls,
|
||||
):
|
||||
logger.error(
|
||||
f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
|
||||
f"но ссылки не добавлены — published_in_tg не обновляем"
|
||||
)
|
||||
return False
|
||||
|
||||
await asyncio.sleep(2) # задержка между обращениями к телеграм
|
||||
|
||||
# Редактируем сообщение, добавляя ссылки
|
||||
if has_image and not is_org_message:
|
||||
# Публикация опроса, если требуется
|
||||
poll_message_id = None
|
||||
if post['is_poll'] and post['poll_question'] and post['poll_options']:
|
||||
try:
|
||||
await bot.edit_message_caption(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
caption=final_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramError as e:
|
||||
logger.warning(f"Не удалось отредактировать подпись для записи VK ID {vk_post_id}: {e}. Пробуем отредактировать текстовое сообщение.")
|
||||
await bot.edit_message_text(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
text=final_text,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
else:
|
||||
await bot.edit_message_text(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
text=final_text,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
# Парсим варианты ответа
|
||||
options = json.loads(post['poll_options'])
|
||||
|
||||
# Подготавливаем параметры для опроса
|
||||
poll_kwargs = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'question': post['poll_question'],
|
||||
'options': options,
|
||||
'is_anonymous': True, # В каналах можно отправлять только анонимные опросы
|
||||
'allows_multiple_answers': post['poll_multiple'],
|
||||
'disable_notification': PUBLISH_SILENTLY
|
||||
}
|
||||
|
||||
# Добавляем close_date, если указан
|
||||
if post['poll_end_date']:
|
||||
# Преобразуем datetime в Unix timestamp
|
||||
if isinstance(post['poll_end_date'], datetime):
|
||||
poll_kwargs['close_date'] = int(post['poll_end_date'].timestamp())
|
||||
else:
|
||||
poll_kwargs['close_date'] = post['poll_end_date']
|
||||
|
||||
# Повторные попытки отправки опроса при таймауте
|
||||
poll_message = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
poll_message = await bot.send_poll(**poll_kwargs)
|
||||
poll_message_id = poll_message.message_id
|
||||
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
|
||||
break # Успешная публикация, выходим из цикла
|
||||
except TimedOut as e:
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Таймаут при отправке опроса (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
|
||||
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
|
||||
await asyncio.sleep(retry_interval)
|
||||
else:
|
||||
logger.error(f"Таймаут при отправке опроса после {max_retries} попыток для записи VK ID {vk_post_id}")
|
||||
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise # Пробрасываем таймаут наверх
|
||||
except TelegramError as e:
|
||||
# Проверяем, является ли это ошибкой FloodWait (429)
|
||||
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||
retry_after = getattr(e, 'retry_after', 60)
|
||||
raise RetryAfterException(retry_after)
|
||||
logger.error(f"Ошибка Telegram при публикации опроса для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
logger.error(f"Детали ошибки: {repr(e)}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при публикации опроса для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
logger.error(f"Детали ошибки: {repr(e)}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
except RetryAfterException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}")
|
||||
|
||||
await asyncio.sleep(2) # задержка между обращениями к телеграм
|
||||
# Проверяем, что message_id был получен (публикация успешна)
|
||||
if message_id is None:
|
||||
logger.error(f"Не удалось получить message_id для записи VK ID {vk_post_id}. Публикация не завершена.")
|
||||
return False
|
||||
|
||||
# Обновляем запись в базе данных только если публикация успешна
|
||||
publication_successful = True
|
||||
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute("""
|
||||
UPDATE posts
|
||||
SET published_in_tg = True,
|
||||
tg_publication_date = %s,
|
||||
tg_message_id = %s,
|
||||
tg_poll_id = %s
|
||||
WHERE vk_post_id = %s
|
||||
""", (current_time_utc, message_id, poll_message_id, vk_post_id))
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Запись VK ID {vk_post_id} успешно опубликована")
|
||||
return True
|
||||
finally:
|
||||
await httpx_client.aclose()
|
||||
|
||||
# Публикация опроса, если требуется
|
||||
poll_message_id = None
|
||||
if post['is_poll'] and post['poll_question'] and post['poll_options']:
|
||||
try:
|
||||
# Парсим варианты ответа
|
||||
options = json.loads(post['poll_options'])
|
||||
poll_options = [PollOption(option, 0) for option in options]
|
||||
|
||||
# Отправляем опрос (в каналах можно отправлять только анонимные опросы)
|
||||
poll_message = await bot.send_poll(
|
||||
chat_id=CHANNEL_ID,
|
||||
question=post['poll_question'],
|
||||
options=poll_options,
|
||||
is_anonymous=True, # В каналах можно отправлять только анонимные опросы
|
||||
allows_multiple_answers=post['poll_multiple'],
|
||||
close_date=post['poll_end_date'] if post['poll_end_date'] else None,
|
||||
disable_notification=PUBLISH_SILENTLY
|
||||
)
|
||||
|
||||
poll_message_id = poll_message.message_id
|
||||
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}")
|
||||
|
||||
# Обновляем запись в базе данных
|
||||
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
cursor.execute("""
|
||||
UPDATE posts
|
||||
SET published_in_tg = True,
|
||||
tg_publication_date = %s,
|
||||
tg_message_id = %s,
|
||||
tg_poll_id = %s
|
||||
WHERE vk_post_id = %s
|
||||
""", (current_time_utc, message_id, poll_message_id, vk_post_id))
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Запись VK ID {vk_post_id} успешно опубликована")
|
||||
return True
|
||||
|
||||
except RetryAfter as e:
|
||||
except RetryAfterException as e:
|
||||
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}: {e}")
|
||||
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
|
||||
raise
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {e}")
|
||||
except TimedOut as e:
|
||||
logger.error(f"ТАЙМАУТ при публикации записи VK ID {vk_post_id}")
|
||||
logger.error(f"Тип исключения: {type(e).__name__}")
|
||||
logger.error(f"Сообщение об ошибке: {str(e)}")
|
||||
logger.error(f"Полная информация об исключении: {repr(e)}")
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
# Убеждаемся, что БД не обновляется при таймауте
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при публикации записи VK ID {vk_post_id}: {e}")
|
||||
error_msg = str(e)
|
||||
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')):
|
||||
retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
|
||||
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}")
|
||||
logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
|
||||
raise RetryAfterException(retry_after)
|
||||
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||
logger.error(f"Детали ошибки: {repr(e)}")
|
||||
# Убеждаемся, что БД не обновляется при ошибке
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
@@ -310,7 +691,7 @@ async def publish_to_tg_all():
|
||||
if post_count < len(posts):
|
||||
await asyncio.sleep(2)
|
||||
|
||||
except RetryAfter as e:
|
||||
except RetryAfterException as e:
|
||||
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
|
||||
break
|
||||
except Exception as e:
|
||||
|
||||
@@ -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:
|
||||
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Тестовый модуль для проверки загрузки описания категории.
|
||||
|
||||
Использование (из корня проекта):
|
||||
python tools/dev/volk_category_testing.py <category_id>
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
import socket
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError, HTTPError
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||
load_dotenv()
|
||||
|
||||
# Параметры из .env
|
||||
VOLK_CATEGORY_DESC_PREFIX = os.getenv('VOLK_CATEGORY_DESC_PREFIX')
|
||||
VOLK_API_VERSION = os.getenv('VOLK_API_VERSION')
|
||||
|
||||
# Лог-файл для тестирования
|
||||
TEST_LOG_FILE = 'volk_category_testing.log'
|
||||
|
||||
def log_message(message, category="TEST"):
|
||||
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_entry = f"[{timestamp}] [{category}] {message}\n"
|
||||
|
||||
# До 5 попыток записи с увеличением задержки
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with open(TEST_LOG_FILE, 'a', encoding='utf-8') as f:
|
||||
f.write(log_entry)
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
if attempt < 4: # Не последняя попытка
|
||||
time.sleep(0.4) # Задержка 400ms между попытками
|
||||
else:
|
||||
return False
|
||||
return False
|
||||
|
||||
def safe_log_direct(message, category="TEST"):
|
||||
"""Безопасная функция для прямой записи в лог-файл без обработки исключений
|
||||
Используется в критических ситуациях, когда обычный log_message может не сработать"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_entry = f"[{timestamp}] [{category}] {message}\n"
|
||||
# Прямая запись без контекстного менеджера для максимальной надежности
|
||||
f = open(TEST_LOG_FILE, 'a', encoding='utf-8')
|
||||
try:
|
||||
f.write(log_entry)
|
||||
f.flush() # Принудительно сбрасываем буфер
|
||||
finally:
|
||||
f.close()
|
||||
except:
|
||||
# Игнорируем все исключения - эта функция должна быть максимально безопасной
|
||||
pass
|
||||
|
||||
def test_category_description_load(category_id, category_number=None, total_count=None):
|
||||
"""Тестирует загрузку описания категории
|
||||
|
||||
Args:
|
||||
category_id: ID категории для тестирования
|
||||
category_number: Номер категории в последовательности (опционально)
|
||||
total_count: Общее количество категорий (опционально)
|
||||
"""
|
||||
if category_number and total_count:
|
||||
header = f"НАЧАЛО ТЕСТИРОВАНИЯ [{category_number}/{total_count}] КАТЕГОРИИ: {category_id}"
|
||||
else:
|
||||
header = f"НАЧАЛО ТЕСТИРОВАНИЯ ЗАГРУЗКИ ОПИСАНИЯ КАТЕГОРИИ: {category_id}"
|
||||
|
||||
log_message("=" * 80)
|
||||
log_message(header)
|
||||
log_message("=" * 80)
|
||||
|
||||
# Проверка наличия префикса URL
|
||||
if not VOLK_CATEGORY_DESC_PREFIX:
|
||||
error_msg = "Переменная окружения VOLK_CATEGORY_DESC_PREFIX не установлена"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(f"ОШИБКА: {error_msg}")
|
||||
return False
|
||||
|
||||
# Формирование URL
|
||||
desc_url = VOLK_CATEGORY_DESC_PREFIX + category_id
|
||||
log_message(f"URL для запроса: {desc_url}")
|
||||
log_message(f"Префикс URL: {VOLK_CATEGORY_DESC_PREFIX}")
|
||||
log_message(f"ID категории: {category_id}")
|
||||
|
||||
desc_response = None
|
||||
|
||||
# Шаг 1: Запрос через urlopen
|
||||
log_message("ШАГ 1: Вызов urlopen()")
|
||||
try:
|
||||
req = Request(desc_url)
|
||||
log_message(f"Request объект создан: {req}")
|
||||
safe_log_direct("Выполняется urlopen с таймаутом 30 секунд", "DEBUG")
|
||||
response = urlopen(req, timeout=30)
|
||||
safe_log_direct("urlopen успешно выполнен", "DEBUG")
|
||||
log_message("urlopen успешно выполнен")
|
||||
except (URLError, HTTPError, OSError, socket.gaierror, socket.timeout, ConnectionError) as e:
|
||||
# Используем безопасное логирование для критических ошибок
|
||||
safe_log_direct("ПЕРЕХВАЧЕНА СЕТЕВАЯ ОШИБКА urlopen", "ERROR")
|
||||
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
|
||||
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
|
||||
try:
|
||||
traceback_str = traceback.format_exc()
|
||||
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
|
||||
log_message(f"Трассировка сетевой ошибки:\n{traceback_str}", "ERROR")
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
error_msg = f"Ошибка сетевого подключения при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
except:
|
||||
pass
|
||||
print(f"ОШИБКА: Сетевая ошибка при запросе - {type(e).__name__}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
# Перехватываем любые другие исключения, которые могут возникнуть при urlopen
|
||||
safe_log_direct("ПЕРЕХВАЧЕНА НЕОЖИДАННАЯ ОШИБКА urlopen", "ERROR")
|
||||
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
|
||||
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
|
||||
try:
|
||||
traceback_str = traceback.format_exc()
|
||||
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
|
||||
log_message(f"Трассировка неожиданной ошибки:\n{traceback_str}", "ERROR")
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
error_msg = f"Неожиданная ошибка при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
except:
|
||||
pass
|
||||
print(f"ОШИБКА: Неожиданная ошибка - {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
# Шаг 2: Чтение и декодирование данных
|
||||
log_message("ШАГ 2: Чтение и декодирование данных")
|
||||
try:
|
||||
with response:
|
||||
# Явно указываем кодировку UTF-8 при декодировании
|
||||
raw_data = response.read()
|
||||
log_message(f"raw data read, размер: {len(raw_data)} байт")
|
||||
safe_log_direct(f"Размер raw_data: {len(raw_data)} байт", "DEBUG")
|
||||
|
||||
try:
|
||||
decoded_data = raw_data.decode('utf-8')
|
||||
log_message("data decoded (UTF-8)")
|
||||
safe_log_direct("Декодирование UTF-8 успешно", "DEBUG")
|
||||
except UnicodeDecodeError as e:
|
||||
error_msg = f"Ошибка декодирования ответа для категории {category_id}: {e}. Попытка декодирования как latin-1."
|
||||
log_message(error_msg, "WARNING")
|
||||
decoded_data = raw_data.decode('latin-1')
|
||||
log_message("data decoded (latin-1 fallback)")
|
||||
|
||||
log_message(f"Длина декодированной строки: {len(decoded_data)} символов")
|
||||
log_message(f"Первые 200 символов ответа:\n{decoded_data[:200]}")
|
||||
|
||||
# Шаг 3: Парсинг JSON
|
||||
log_message("ШАГ 3: Парсинг JSON")
|
||||
try:
|
||||
desc_response = json.loads(decoded_data)
|
||||
log_message("json loaded успешно")
|
||||
safe_log_direct("JSON успешно распарсен", "DEBUG")
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Ошибка парсинга JSON для категории {category_id}: {e}. Первые 500 символов ответа: {decoded_data[:500]}"
|
||||
log_message(error_msg, "ERROR")
|
||||
log_message(f"Полный ответ (первые 1000 символов):\n{decoded_data[:1000]}", "ERROR")
|
||||
print(f"ОШИБКА: Не удалось распарсить JSON - {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при чтении ответа для категории {category_id}: {type(e).__name__}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
traceback_str = traceback.format_exc()
|
||||
log_message(f"Трассировка ошибки чтения:\n{traceback_str}", "ERROR")
|
||||
print(f"ОШИБКА: Ошибка чтения ответа - {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
# Проверяем, что desc_response был успешно получен
|
||||
if desc_response is None:
|
||||
error_msg = f"Не удалось получить ответ для категории {category_id}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(f"ОШИБКА: {error_msg}")
|
||||
return False
|
||||
|
||||
log_message(f"Получен JSON ответ: {json.dumps(desc_response, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# Шаг 4: Проверка версии API
|
||||
log_message("ШАГ 4: Проверка версии API")
|
||||
api_version = desc_response.get('api_version')
|
||||
log_message(f"Получена версия API: {api_version}")
|
||||
log_message(f"Ожидаемая версия API: {VOLK_API_VERSION}")
|
||||
|
||||
if api_version != VOLK_API_VERSION:
|
||||
error_msg = f"Несовпадение версии API при загрузке описания категории {category_id}: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'."
|
||||
log_message(error_msg, "ERROR")
|
||||
print(f"ОШИБКА: {error_msg}")
|
||||
return False
|
||||
|
||||
log_message("Версия API соответствует ожидаемой")
|
||||
|
||||
# Шаг 5: Проверка наличия блока data
|
||||
log_message("ШАГ 5: Проверка наличия блока data")
|
||||
if 'data' not in desc_response:
|
||||
error_msg = f"В JSON ответе для категории {category_id} отсутствует блок 'data'."
|
||||
log_message(error_msg, "ERROR")
|
||||
log_message(f"Структура JSON ответа: {list(desc_response.keys())}", "ERROR")
|
||||
print(f"ОШИБКА: {error_msg}")
|
||||
return False
|
||||
|
||||
log_message("Блок 'data' найден в ответе")
|
||||
|
||||
desc_data = desc_response['data']
|
||||
log_message(f"Тип desc_data: {type(desc_data)}")
|
||||
|
||||
# Шаг 6: Проверка типа desc_data
|
||||
log_message("ШАГ 6: Проверка типа desc_data")
|
||||
if not isinstance(desc_data, dict):
|
||||
error_msg = f"Блок 'data' для категории {category_id} не является словарём (тип: {type(desc_data)})."
|
||||
log_message(error_msg, "ERROR")
|
||||
print(f"ОШИБКА: {error_msg}")
|
||||
return False
|
||||
|
||||
log_message("desc_data является словарём")
|
||||
log_message(f"Ключи в desc_data: {list(desc_data.keys())}")
|
||||
|
||||
# Шаг 7: Получение description
|
||||
log_message("ШАГ 7: Получение description из блока data")
|
||||
description = desc_data.get('description')
|
||||
log_message(f"description получен, тип: {type(description)}")
|
||||
|
||||
if description:
|
||||
log_message(f"Длина description: {len(description)} символов")
|
||||
log_message(f"Первые 500 символов description:\n{description[:500]}")
|
||||
log_message("Описание успешно получено")
|
||||
else:
|
||||
log_message("description пустое или отсутствует (будет установлено в None)")
|
||||
description = None
|
||||
|
||||
# Итоговый результат
|
||||
if category_number and total_count:
|
||||
footer = f"ТЕСТИРОВАНИЕ ЗАВЕРШЕНО УСПЕШНО [{category_number}/{total_count}] КАТЕГОРИИ: {category_id}"
|
||||
else:
|
||||
footer = f"ТЕСТИРОВАНИЕ ЗАВЕРШЕНО УСПЕШНО ДЛЯ КАТЕГОРИИ: {category_id}"
|
||||
|
||||
log_message("=" * 80)
|
||||
log_message(footer)
|
||||
log_message("=" * 80)
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""Главная функция"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Тестирование загрузки описания категории из API VOLK',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Примеры использования:
|
||||
python tools/dev/volk_category_testing.py volk26_nri
|
||||
python tools/dev/volk_category_testing.py volk26_nri volk26_other volk26_third
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
'category_ids',
|
||||
type=str,
|
||||
nargs='+',
|
||||
help='ID категорий для тестирования (от 1 до 10 штук, например: volk26_nri volk26_other)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
category_ids = args.category_ids
|
||||
|
||||
# Проверка количества ID
|
||||
if len(category_ids) == 0:
|
||||
print("ОШИБКА: Не указан ни один ID категории")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if len(category_ids) > 10:
|
||||
print(f"ОШИБКА: Указано слишком много ID категорий ({len(category_ids)}). Максимум 10.")
|
||||
sys.exit(1)
|
||||
|
||||
# Проверка наличия необходимых переменных окружения
|
||||
if not VOLK_CATEGORY_DESC_PREFIX:
|
||||
print("ОШИБКА: Переменная окружения VOLK_CATEGORY_DESC_PREFIX не установлена")
|
||||
print("Проверьте файл .env")
|
||||
sys.exit(1)
|
||||
|
||||
if not VOLK_API_VERSION:
|
||||
print("ОШИБКА: Переменная окружения VOLK_API_VERSION не установлена")
|
||||
print("Проверьте файл .env")
|
||||
sys.exit(1)
|
||||
|
||||
log_message("=" * 80)
|
||||
log_message("ЗАПУСК ТЕСТИРОВАНИЯ ЗАГРУЗКИ ОПИСАНИЯ КАТЕГОРИЙ")
|
||||
log_message(f"Время запуска: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
log_message(f"Количество категорий для обработки: {len(category_ids)}")
|
||||
log_message(f"ID категорий: {', '.join(category_ids)}")
|
||||
log_message(f"Префикс URL: {VOLK_CATEGORY_DESC_PREFIX}")
|
||||
log_message(f"Версия API: {VOLK_API_VERSION}")
|
||||
log_message(f"Лог-файл: {TEST_LOG_FILE}")
|
||||
log_message("=" * 80)
|
||||
|
||||
# Обработка каждой категории последовательно
|
||||
total_count = len(category_ids)
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for index, category_id in enumerate(category_ids, start=1):
|
||||
log_message("")
|
||||
log_message("")
|
||||
log_message(f">>> ОБРАБОТКА КАТЕГОРИИ {index} из {total_count}: {category_id}")
|
||||
log_message("")
|
||||
|
||||
print(f"\n[{index}/{total_count}] Обработка категории: {category_id}")
|
||||
|
||||
success = test_category_description_load(category_id, category_number=index, total_count=total_count)
|
||||
|
||||
if success:
|
||||
success_count += 1
|
||||
print(f"[{index}/{total_count}] ✓ УСПЕХ: Категория {category_id} обработана успешно")
|
||||
else:
|
||||
fail_count += 1
|
||||
print(f"[{index}/{total_count}] ✗ ОШИБКА: Категория {category_id} не обработана")
|
||||
|
||||
# Итоговая статистика
|
||||
log_message("")
|
||||
log_message("")
|
||||
log_message("=" * 80)
|
||||
log_message("ИТОГОВАЯ СТАТИСТИКА")
|
||||
log_message("=" * 80)
|
||||
log_message(f"Всего категорий обработано: {total_count}")
|
||||
log_message(f"Успешно: {success_count}")
|
||||
log_message(f"С ошибками: {fail_count}")
|
||||
log_message(f"Время завершения: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
log_message("=" * 80)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"ИТОГИ:")
|
||||
print(f" Всего категорий: {total_count}")
|
||||
print(f" Успешно: {success_count}")
|
||||
print(f" С ошибками: {fail_count}")
|
||||
print(f" Лог сохранен в: {TEST_LOG_FILE}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Возвращаем код ошибки, если были неудачи
|
||||
if fail_count > 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,603 @@
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
import asyncio
|
||||
import time
|
||||
import sys
|
||||
import json
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||
load_dotenv()
|
||||
|
||||
from telegram_relay import bot_api_method_url, get_relay_url
|
||||
from season_links import subscription_start_link
|
||||
|
||||
# Настройки из переменных окружения
|
||||
POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
|
||||
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
|
||||
CHANNEL_ID = os.getenv('CHANNEL_ID')
|
||||
DESC_PREFIX = os.getenv('DESC_PREFIX', '')
|
||||
PZK_PREFIX = os.getenv('PZK_PREFIX', '')
|
||||
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
|
||||
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
|
||||
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
# Настройка логирования
|
||||
LOG_FILE = 'volk_telegram_testing.log'
|
||||
logger = logging.getLogger('VOLK_TG_Test')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Консольный обработчик
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# Файловый обработчик с повторными попытками
|
||||
class RetryFileHandler(logging.FileHandler):
|
||||
def emit(self, record):
|
||||
for _ in range(5):
|
||||
try:
|
||||
super().emit(record)
|
||||
return
|
||||
except (IOError, PermissionError):
|
||||
time.sleep(0.5)
|
||||
print(f"Failed to write to log file after 5 attempts: {record.msg}")
|
||||
|
||||
file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
async def test_telegram_publish():
|
||||
"""Тестовая публикация сообщения в Telegram"""
|
||||
logger.info("=" * 80)
|
||||
logger.info("НАЧАЛО ТЕСТОВОЙ ПУБЛИКАЦИИ В TELEGRAM")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Логируем загруженные параметры
|
||||
logger.info("Загруженные параметры из .env:")
|
||||
logger.info(f" POSTER_BOT_TOKEN: {'*' * 20 if POSTER_BOT_TOKEN else 'НЕ ЗАДАН'}")
|
||||
logger.info(f" RESPONDER_BOT_NAME: {RESPONDER_BOT_NAME}")
|
||||
logger.info(f" CHANNEL_ID: {CHANNEL_ID}")
|
||||
logger.info(f" DESC_PREFIX: {DESC_PREFIX}")
|
||||
logger.info(f" PZK_PREFIX: {PZK_PREFIX}")
|
||||
logger.info(f" MAX_CAPTION_LENGTH: {MAX_CAPTION_LENGTH}")
|
||||
logger.info(f" MAX_TEXT_LENGTH: {MAX_TEXT_LENGTH}")
|
||||
logger.info(f" PUBLISH_SILENTLY: {PUBLISH_SILENTLY}")
|
||||
logger.info(f" USE_SUBSCRIPTION_BOT: {USE_SUBSCRIPTION_BOT}")
|
||||
logger.info(f" RELAY_URL: {get_relay_url()}")
|
||||
|
||||
# Проверка обязательных параметров
|
||||
if not POSTER_BOT_TOKEN:
|
||||
logger.error("ОШИБКА: POSTER_BOT_TOKEN не задан в .env")
|
||||
return False
|
||||
|
||||
if not CHANNEL_ID:
|
||||
logger.error("ОШИБКА: CHANNEL_ID не задан в .env")
|
||||
return False
|
||||
|
||||
# Формируем тестовое сообщение
|
||||
test_number = "TEST001"
|
||||
test_tags = "#тест #volk_testing"
|
||||
test_unit_name = "Тестовая площадка"
|
||||
test_name = "Тестовое событие для проверки публикации"
|
||||
test_about = "Это тестовое сообщение для проверки работы публикации в Telegram. " * 5
|
||||
|
||||
# Формируем базовый текст
|
||||
base_text = f"{test_tags}\n{test_unit_name}\n<b>{test_name}</b>"
|
||||
base_text_length = len(base_text)
|
||||
|
||||
# Формируем строку ссылок
|
||||
placeholder_message_id = '1234567890'
|
||||
if USE_SUBSCRIPTION_BOT:
|
||||
links_line = (
|
||||
f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a> | '
|
||||
f'📌 <a href="{PZK_PREFIX}{test_number}/">Иду</a> | '
|
||||
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", placeholder_message_id)}">Подписка</a>'
|
||||
)
|
||||
else:
|
||||
links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a>'
|
||||
|
||||
links_line_length = len(links_line)
|
||||
|
||||
# Определяем максимальную длину
|
||||
max_length = MAX_CAPTION_LENGTH # Используем caption для теста
|
||||
|
||||
# Вычисляем доступную длину для текста
|
||||
available_length = max_length - base_text_length - links_line_length - 3
|
||||
if available_length < 0:
|
||||
available_length = 0
|
||||
|
||||
# Обрезаем about до доступной длины
|
||||
if len(test_about) > available_length:
|
||||
test_about = test_about[:available_length-3] + "..."
|
||||
|
||||
# Формируем финальный текст
|
||||
final_text = f"{base_text}\n\n{test_about}\n\n{links_line}"
|
||||
|
||||
# Логируем детали сообщения
|
||||
logger.info("=" * 80)
|
||||
logger.info("ДЕТАЛИ ТЕСТОВОГО СООБЩЕНИЯ:")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Длина base_text: {base_text_length} символов")
|
||||
logger.info(f"Длина test_about: {len(test_about)} символов")
|
||||
logger.info(f"Длина links_line: {links_line_length} символов")
|
||||
logger.info(f"Максимальная длина (max_length): {max_length} символов")
|
||||
logger.info(f"Доступная длина для about: {available_length} символов")
|
||||
logger.info(f"Длина final_text: {len(final_text)} символов")
|
||||
logger.info(f"Используется изображение: НЕТ")
|
||||
logger.info("=" * 80)
|
||||
logger.info("ТЕКСТ СООБЩЕНИЯ:")
|
||||
logger.info("=" * 80)
|
||||
logger.info(final_text)
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Создаем httpx клиент для прямых запросов к Telegram Bot API
|
||||
httpx_client = None
|
||||
try:
|
||||
# Создаём клиент БЕЗ HTTP/2 с указанными параметрами
|
||||
logger.info("Создание httpx клиента с отключенным HTTP/2...")
|
||||
httpx_client = httpx.AsyncClient(
|
||||
http2=False, # ← отключаем HTTP/2
|
||||
timeout=20.0, # явный таймаут
|
||||
follow_redirects=True
|
||||
)
|
||||
logger.info("httpx клиент успешно создан с http2=False, timeout=20.0")
|
||||
|
||||
# Получаем информацию о боте через Telegram Bot API
|
||||
try:
|
||||
logger.info("=" * 80)
|
||||
logger.info("ПОПЫТКА ПОЛУЧЕНИЯ ИНФОРМАЦИИ О БОТЕ")
|
||||
logger.info("=" * 80)
|
||||
get_me_url = bot_api_method_url(POSTER_BOT_TOKEN, "getMe")
|
||||
logger.info(f"URL запроса: {get_me_url}")
|
||||
logger.info(f"Метод: GET")
|
||||
|
||||
start_time = datetime.now()
|
||||
response = await httpx_client.get(get_me_url)
|
||||
end_time = datetime.now()
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
logger.info(f"Статус ответа: {response.status_code}")
|
||||
logger.info(f"Время запроса: {duration:.2f} секунд")
|
||||
|
||||
if response.status_code == 200:
|
||||
bot_info = response.json()
|
||||
if bot_info.get('ok'):
|
||||
result = bot_info.get('result', {})
|
||||
logger.info("=" * 80)
|
||||
logger.info("ИНФОРМАЦИЯ О БОТЕ УСПЕШНО ПОЛУЧЕНА!")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"ID: {result.get('id')}")
|
||||
logger.info(f"Username: @{result.get('username')}")
|
||||
logger.info(f"First name: {result.get('first_name')}")
|
||||
logger.info(f"Is bot: {result.get('is_bot')}")
|
||||
logger.info("=" * 80)
|
||||
else:
|
||||
logger.error(f"Ошибка в ответе API: {bot_info}")
|
||||
else:
|
||||
logger.error(f"HTTP ошибка: {response.status_code}")
|
||||
logger.error(f"Текст ответа: {response.text}")
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ТАЙМАУТ при получении информации о боте!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ОШИБКА HTTP при получении информации о боте!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("НЕОЖИДАННАЯ ОШИБКА при получении информации о боте!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
import traceback
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
|
||||
# Публикуем тестовое сообщение через Telegram Bot API
|
||||
try:
|
||||
logger.info("=" * 80)
|
||||
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ")
|
||||
logger.info("=" * 80)
|
||||
send_message_url = bot_api_method_url(POSTER_BOT_TOKEN, "sendMessage")
|
||||
logger.info(f"URL запроса: {send_message_url}")
|
||||
logger.info(f"Метод: POST")
|
||||
logger.info(f"Параметры запроса:")
|
||||
logger.info(f" chat_id: {CHANNEL_ID}")
|
||||
logger.info(f" text_length: {len(final_text)}")
|
||||
logger.info(f" parse_mode: HTML")
|
||||
logger.info(f" disable_notification: {PUBLISH_SILENTLY}")
|
||||
logger.info(f" disable_web_page_preview: True")
|
||||
|
||||
payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'text': final_text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_notification': PUBLISH_SILENTLY,
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
start_time = datetime.now()
|
||||
response = await httpx_client.post(send_message_url, json=payload)
|
||||
end_time = datetime.now()
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
logger.info(f"Статус ответа: {response.status_code}")
|
||||
logger.info(f"Время запроса: {duration:.2f} секунд")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result.get('ok'):
|
||||
message_data = result.get('result', {})
|
||||
message_id = message_data.get('message_id')
|
||||
chat_data = message_data.get('chat', {})
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("СООБЩЕНИЕ УСПЕШНО ОТПРАВЛЕНО!")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Message ID: {message_id}")
|
||||
logger.info(f"Chat ID: {chat_data.get('id')}")
|
||||
logger.info(f"Chat type: {chat_data.get('type')}")
|
||||
logger.info(f"Chat title: {chat_data.get('title', 'N/A')}")
|
||||
logger.info(f"Время отправки: {duration:.2f} секунд")
|
||||
logger.info(f"Дата сообщения: {message_data.get('date')}")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Редактируем сообщение, добавляя информацию о редактировании
|
||||
try:
|
||||
logger.info("=" * 80)
|
||||
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ")
|
||||
logger.info("=" * 80)
|
||||
edit_message_url = bot_api_method_url(POSTER_BOT_TOKEN, "editMessageText")
|
||||
logger.info(f"URL запроса: {edit_message_url}")
|
||||
logger.info(f"Метод: POST")
|
||||
logger.info(f"Параметры запроса:")
|
||||
logger.info(f" chat_id: {CHANNEL_ID}")
|
||||
logger.info(f" message_id: {message_id}")
|
||||
|
||||
# Добавляем информацию о редактировании в конец сообщения
|
||||
edited_text = f"{final_text}\n\n<i>Сообщение отредактировано через httpx клиент</i>"
|
||||
logger.info(f" text_length (после редактирования): {len(edited_text)}")
|
||||
logger.info(f" parse_mode: HTML")
|
||||
|
||||
edit_payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'message_id': message_id,
|
||||
'text': edited_text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
edit_start_time = datetime.now()
|
||||
edit_response = await httpx_client.post(edit_message_url, json=edit_payload)
|
||||
edit_end_time = datetime.now()
|
||||
edit_duration = (edit_end_time - edit_start_time).total_seconds()
|
||||
|
||||
logger.info(f"Статус ответа: {edit_response.status_code}")
|
||||
logger.info(f"Время запроса: {edit_duration:.2f} секунд")
|
||||
|
||||
if edit_response.status_code == 200:
|
||||
edit_result = edit_response.json()
|
||||
if edit_result.get('ok'):
|
||||
logger.info("=" * 80)
|
||||
logger.info("СООБЩЕНИЕ УСПЕШНО ОТРЕДАКТИРОВАНО!")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Время редактирования: {edit_duration:.2f} секунд")
|
||||
logger.info("=" * 80)
|
||||
else:
|
||||
logger.error(f"Ошибка в ответе API при редактировании: {edit_result}")
|
||||
else:
|
||||
logger.error(f"HTTP ошибка при редактировании: {edit_response.status_code}")
|
||||
logger.error(f"Текст ответа: {edit_response.text}")
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ТАЙМАУТ ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
logger.error(f"Параметры запроса:")
|
||||
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||
logger.error(f" message_id: {message_id}")
|
||||
logger.error(f" text_length: {len(edited_text)}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ОШИБКА HTTP ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
import traceback
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
|
||||
# Публикуем второе сообщение с картинкой
|
||||
try:
|
||||
logger.info("=" * 80)
|
||||
logger.info("ПОПЫТКА ОТПРАВКИ ТЕСТОВОГО СООБЩЕНИЯ С КАРТИНКОЙ")
|
||||
logger.info("=" * 80)
|
||||
send_photo_url = bot_api_method_url(POSTER_BOT_TOKEN, "sendPhoto")
|
||||
image_url = "https://base.volk-nn.ru/uploads/events/volk26/volk26_event/20251210_175059_0.jpg"
|
||||
|
||||
# Формируем текст для сообщения с картинкой
|
||||
photo_caption = f"{test_tags}\n{test_unit_name}\n<b>Тестовое сообщение с картинкой</b>\n\nЭто тестовое сообщение с изображением для проверки работы публикации фото в Telegram."
|
||||
|
||||
logger.info(f"URL запроса: {send_photo_url}")
|
||||
logger.info(f"Метод: POST")
|
||||
logger.info(f"Параметры запроса:")
|
||||
logger.info(f" chat_id: {CHANNEL_ID}")
|
||||
logger.info(f" photo_url: {image_url}")
|
||||
logger.info(f" photo_url_length: {len(image_url)}")
|
||||
logger.info(f" caption_length: {len(photo_caption)}")
|
||||
logger.info(f" parse_mode: HTML")
|
||||
logger.info(f" disable_notification: {PUBLISH_SILENTLY}")
|
||||
|
||||
photo_payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'photo': image_url,
|
||||
'caption': photo_caption,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_notification': PUBLISH_SILENTLY
|
||||
}
|
||||
|
||||
photo_start_time = datetime.now()
|
||||
photo_response = await httpx_client.post(send_photo_url, json=photo_payload)
|
||||
photo_end_time = datetime.now()
|
||||
photo_duration = (photo_end_time - photo_start_time).total_seconds()
|
||||
|
||||
logger.info(f"Статус ответа: {photo_response.status_code}")
|
||||
logger.info(f"Время запроса: {photo_duration:.2f} секунд")
|
||||
|
||||
if photo_response.status_code == 200:
|
||||
photo_result = photo_response.json()
|
||||
if photo_result.get('ok'):
|
||||
photo_message_data = photo_result.get('result', {})
|
||||
photo_message_id = photo_message_data.get('message_id')
|
||||
photo_chat_data = photo_message_data.get('chat', {})
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("СООБЩЕНИЕ С КАРТИНКОЙ УСПЕШНО ОТПРАВЛЕНО!")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Message ID: {photo_message_id}")
|
||||
logger.info(f"Chat ID: {photo_chat_data.get('id')}")
|
||||
logger.info(f"Chat type: {photo_chat_data.get('type')}")
|
||||
logger.info(f"Chat title: {photo_chat_data.get('title', 'N/A')}")
|
||||
logger.info(f"Время отправки: {photo_duration:.2f} секунд")
|
||||
logger.info(f"Дата сообщения: {photo_message_data.get('date')}")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Редактируем сообщение с картинкой, добавляя информацию о редактировании
|
||||
try:
|
||||
logger.info("=" * 80)
|
||||
logger.info("ПОПЫТКА РЕДАКТИРОВАНИЯ СООБЩЕНИЯ С КАРТИНКОЙ")
|
||||
logger.info("=" * 80)
|
||||
edit_photo_caption_url = bot_api_method_url(POSTER_BOT_TOKEN, "editMessageCaption")
|
||||
logger.info(f"URL запроса: {edit_photo_caption_url}")
|
||||
logger.info(f"Метод: POST")
|
||||
logger.info(f"Параметры запроса:")
|
||||
logger.info(f" chat_id: {CHANNEL_ID}")
|
||||
logger.info(f" message_id: {photo_message_id}")
|
||||
|
||||
# Добавляем информацию о редактировании в конец подписи
|
||||
edited_photo_caption = f"{photo_caption}\n\n<i>Сообщение с картинкой отредактировано через httpx клиент</i>"
|
||||
logger.info(f" caption_length (после редактирования): {len(edited_photo_caption)}")
|
||||
logger.info(f" parse_mode: HTML")
|
||||
|
||||
edit_photo_payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'message_id': photo_message_id,
|
||||
'caption': edited_photo_caption,
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
|
||||
edit_photo_start_time = datetime.now()
|
||||
edit_photo_response = await httpx_client.post(edit_photo_caption_url, json=edit_photo_payload)
|
||||
edit_photo_end_time = datetime.now()
|
||||
edit_photo_duration = (edit_photo_end_time - edit_photo_start_time).total_seconds()
|
||||
|
||||
logger.info(f"Статус ответа: {edit_photo_response.status_code}")
|
||||
logger.info(f"Время запроса: {edit_photo_duration:.2f} секунд")
|
||||
|
||||
if edit_photo_response.status_code == 200:
|
||||
edit_photo_result = edit_photo_response.json()
|
||||
if edit_photo_result.get('ok'):
|
||||
logger.info("=" * 80)
|
||||
logger.info("СООБЩЕНИЕ С КАРТИНКОЙ УСПЕШНО ОТРЕДАКТИРОВАНО!")
|
||||
logger.info("=" * 80)
|
||||
logger.info(f"Время редактирования: {edit_photo_duration:.2f} секунд")
|
||||
logger.info("=" * 80)
|
||||
else:
|
||||
logger.error(f"Ошибка в ответе API при редактировании подписи: {edit_photo_result}")
|
||||
logger.error(f"Описание ошибки: {edit_photo_result.get('description', 'N/A')}")
|
||||
logger.error(f"Код ошибки: {edit_photo_result.get('error_code', 'N/A')}")
|
||||
else:
|
||||
logger.error(f"HTTP ошибка при редактировании подписи: {edit_photo_response.status_code}")
|
||||
logger.error(f"Текст ответа: {edit_photo_response.text}")
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ТАЙМАУТ ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ С КАРТИНКОЙ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
logger.error(f"Параметры запроса:")
|
||||
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||
logger.error(f" message_id: {photo_message_id}")
|
||||
logger.error(f" caption_length: {len(edited_photo_caption)}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ОШИБКА HTTP ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ С КАРТИНКОЙ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ РЕДАКТИРОВАНИИ СООБЩЕНИЯ С КАРТИНКОЙ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
import traceback
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
|
||||
else:
|
||||
logger.error(f"Ошибка в ответе API при отправке фото: {photo_result}")
|
||||
logger.error(f"Описание ошибки: {photo_result.get('description', 'N/A')}")
|
||||
logger.error(f"Код ошибки: {photo_result.get('error_code', 'N/A')}")
|
||||
else:
|
||||
logger.error(f"HTTP ошибка при отправке фото: {photo_response.status_code}")
|
||||
logger.error(f"Текст ответа: {photo_response.text}")
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ТАЙМАУТ ПРИ ОТПРАВКЕ СООБЩЕНИЯ С КАРТИНКОЙ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
logger.error(f"Параметры запроса:")
|
||||
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||
logger.error(f" photo_url: {image_url}")
|
||||
logger.error(f" caption_length: {len(photo_caption)}")
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ОШИБКА HTTP ПРИ ОТПРАВКЕ СООБЩЕНИЯ С КАРТИНКОЙ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ ОТПРАВКЕ СООБЩЕНИЯ С КАРТИНКОЙ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
import traceback
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Ошибка в ответе API: {result}")
|
||||
logger.error(f"Описание ошибки: {result.get('description', 'N/A')}")
|
||||
logger.error(f"Код ошибки: {result.get('error_code', 'N/A')}")
|
||||
return False
|
||||
else:
|
||||
logger.error(f"HTTP ошибка: {response.status_code}")
|
||||
logger.error(f"Текст ответа: {response.text}")
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ТАЙМАУТ ПРИ ОТПРАВКЕ СООБЩЕНИЯ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
logger.error(f"Параметры запроса:")
|
||||
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||
logger.error(f" text_length: {len(final_text)}")
|
||||
logger.error(f" parse_mode: HTML")
|
||||
return False
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ОШИБКА HTTP ПРИ ОТПРАВКЕ СООБЩЕНИЯ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e)}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("=" * 80)
|
||||
logger.error("НЕОЖИДАННАЯ ОШИБКА ПРИ ОТПРАВКЕ СООБЩЕНИЯ!")
|
||||
logger.error("=" * 80)
|
||||
logger.error(f"Тип ошибки: {type(e).__name__}")
|
||||
logger.error(f"Сообщение: {str(e)}")
|
||||
logger.error(f"Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||
logger.error(f"Полное представление: {repr(e)}")
|
||||
import traceback
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
finally:
|
||||
# Закрываем httpx клиент
|
||||
if httpx_client is not None:
|
||||
try:
|
||||
logger.info("Закрытие httpx клиента...")
|
||||
await httpx_client.aclose()
|
||||
logger.info("httpx клиент успешно закрыт")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при закрытии httpx клиента: {e}")
|
||||
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||
import traceback
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
|
||||
async def main():
|
||||
"""Главная функция"""
|
||||
try:
|
||||
success = await test_telegram_publish()
|
||||
if success:
|
||||
logger.info("=" * 80)
|
||||
logger.info("ТЕСТ ЗАВЕРШЕН УСПЕШНО")
|
||||
logger.info("=" * 80)
|
||||
sys.exit(0)
|
||||
else:
|
||||
logger.error("=" * 80)
|
||||
logger.error("ТЕСТ ЗАВЕРШЕН С ОШИБКОЙ")
|
||||
logger.error("=" * 80)
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Прервано пользователем")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка: {e}")
|
||||
import traceback
|
||||
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,10 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LEGACY — не входит в пайплайн runner.sh с версии 1.0.0.
|
||||
|
||||
Проставляет marked_to_publication = 1 видимым событиям (is_visible = 1),
|
||||
у которых флаг ещё не установлен.
|
||||
|
||||
Запуск вручную из корня проекта:
|
||||
./venv/bin/python tools/legacy/evt_prefetch.py
|
||||
"""
|
||||
import os
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
from datetime import datetime, 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,40 +30,33 @@ def prefetch_all_events():
|
||||
- не имеют флага marked_to_publication = 1
|
||||
"""
|
||||
try:
|
||||
# Подключение к базе данных
|
||||
connection = mysql.connector.connect(
|
||||
host=MDB_HOST,
|
||||
user=MDB_USER,
|
||||
password=MDB_PW,
|
||||
database=MDBASE
|
||||
)
|
||||
|
||||
# Установка временной зоны соединения в UTC
|
||||
|
||||
cursor_temp = connection.cursor()
|
||||
cursor_temp.execute("SET time_zone = '+00:00'")
|
||||
cursor_temp.close()
|
||||
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
# SQL запрос для обновления записей
|
||||
|
||||
update_query = """
|
||||
UPDATE events
|
||||
SET marked_to_publication = 1
|
||||
WHERE is_visible = 1
|
||||
UPDATE events
|
||||
SET marked_to_publication = 1
|
||||
WHERE is_visible = 1
|
||||
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:
|
||||
print(f"Ошибка базы данных: {e}")
|
||||
if 'connection' in locals() and connection.is_connected():
|
||||
@@ -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 # Завершать выполнение при любой ошибке
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
+181
-56
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import traceback
|
||||
import socket
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
from dotenv import load_dotenv
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
@@ -20,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"):
|
||||
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
|
||||
@@ -36,11 +41,28 @@ def log_message(message, category="VOLK_loader"):
|
||||
if attempt < 4: # Не последняя попытка
|
||||
time.sleep(0.4) # Задержка 400ms между попытками
|
||||
else:
|
||||
# После 5 неудачных попыток выводим в stdout
|
||||
print(f"Не удалось записать в лог после 5 попыток: {log_entry.strip()}")
|
||||
# После 5 неудачных попыток просто возвращаем False
|
||||
# Не используем print() чтобы избежать реентерабельных вызовов с gunicorn
|
||||
return False
|
||||
return False
|
||||
|
||||
def safe_log_direct(message, category="VOLK_loader"):
|
||||
"""Безопасная функция для прямой записи в лог-файл без обработки исключений
|
||||
Используется в критических ситуациях, когда обычный log_message может не сработать"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_entry = f"[{timestamp}] [{category}] {message}\n"
|
||||
# Прямая запись без контекстного менеджера для максимальной надежности
|
||||
f = open(LOG_FILE, 'a', encoding='utf-8')
|
||||
try:
|
||||
f.write(log_entry)
|
||||
f.flush() # Принудительно сбрасываем буфер
|
||||
finally:
|
||||
f.close()
|
||||
except:
|
||||
# Игнорируем все исключения - эта функция должна быть максимально безопасной
|
||||
pass
|
||||
|
||||
def load_categories():
|
||||
"""Экспортируемая функция для загрузки категорий из JSON в базу данных"""
|
||||
inserted_count = 0
|
||||
@@ -53,7 +75,6 @@ def load_categories():
|
||||
if not VOLK_CATEGORY_LIST:
|
||||
error_msg = "Переменная окружения VOLK_CATEGORY_LIST не установлена"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
return
|
||||
|
||||
# Чтение JSON из URL
|
||||
@@ -66,7 +87,6 @@ def load_categories():
|
||||
if api_version != VOLK_API_VERSION:
|
||||
error_msg = f"Несовпадение версии API: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Обработка остановлена."
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
return
|
||||
|
||||
log_message(f"Версия API соответствует: {api_version}")
|
||||
@@ -75,7 +95,6 @@ def load_categories():
|
||||
if 'data' not in data:
|
||||
error_msg = "В JSON отсутствует блок 'data'"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
return
|
||||
|
||||
categories_data = data['data']
|
||||
@@ -123,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:
|
||||
@@ -155,39 +177,135 @@ def load_categories():
|
||||
try:
|
||||
desc_url = VOLK_CATEGORY_DESC_PREFIX + category_id
|
||||
log_message(f"Запрос описания для категории {category_id} из {desc_url}")
|
||||
|
||||
desc_response = None
|
||||
try:
|
||||
# Добавляем таймаут для избежания зависаний (30 секунд)
|
||||
from urllib.request import Request
|
||||
req = Request(desc_url, headers={'User-Agent': 'Mozilla/5.0 (Telefaust)'})
|
||||
log_message(f"request response = {req}", "DEBUG")
|
||||
response = urlopen(req, timeout=30)
|
||||
except (URLError, HTTPError, OSError, socket.gaierror, socket.timeout, ConnectionError) as e:
|
||||
# Используем безопасное логирование для критических ошибок
|
||||
safe_log_direct("ПЕРЕХВАЧЕНА СЕТЕВАЯ ОШИБКА urlopen", "ERROR")
|
||||
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
|
||||
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
|
||||
try:
|
||||
traceback_str = traceback.format_exc()
|
||||
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
|
||||
except:
|
||||
pass
|
||||
# Также пробуем обычное логирование
|
||||
try:
|
||||
error_msg = f"Ошибка сетевого подключения при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
except:
|
||||
pass
|
||||
# Пропускаем эту категорию и продолжаем обработку
|
||||
continue
|
||||
except Exception as e:
|
||||
# Перехватываем любые другие исключения, которые могут возникнуть при urlopen
|
||||
safe_log_direct("ПЕРЕХВАЧЕНА НЕОЖИДАННАЯ ОШИБКА urlopen", "ERROR")
|
||||
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
|
||||
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
|
||||
try:
|
||||
traceback_str = traceback.format_exc()
|
||||
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
|
||||
except:
|
||||
pass
|
||||
# Также пробуем обычное логирование
|
||||
try:
|
||||
error_msg = f"Неожиданная ошибка при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
except:
|
||||
pass
|
||||
# Пропускаем эту категорию и продолжаем обработку
|
||||
continue
|
||||
|
||||
with urlopen(desc_url) as response:
|
||||
desc_response = json.loads(response.read().decode())
|
||||
try:
|
||||
with response:
|
||||
# Явно указываем кодировку UTF-8 при декодировании
|
||||
raw_data = response.read()
|
||||
try:
|
||||
decoded_data = raw_data.decode('utf-8')
|
||||
except UnicodeDecodeError as e:
|
||||
error_msg = f"Ошибка декодирования ответа для категории {category_id}: {e}. Попытка декодирования как latin-1."
|
||||
log_message(error_msg, "WARNING")
|
||||
decoded_data = raw_data.decode('latin-1')
|
||||
|
||||
try:
|
||||
desc_response = json.loads(decoded_data)
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"Ошибка парсинга JSON для категории {category_id}: {e}. Первые 500 символов ответа: {decoded_data[:500]}"
|
||||
log_message(error_msg, "ERROR")
|
||||
# Пропускаем эту категорию и продолжаем обработку
|
||||
continue
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при чтении ответа для категории {category_id}: {type(e).__name__}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
traceback_str = traceback.format_exc()
|
||||
log_message(f"Трассировка ошибки чтения для категории {category_id}:\n{traceback_str}", "ERROR")
|
||||
# Пропускаем эту категорию и продолжаем обработку
|
||||
continue
|
||||
|
||||
# Проверяем, что desc_response был успешно получен
|
||||
if desc_response is None:
|
||||
error_msg = f"Не удалось получить ответ для категории {category_id}"
|
||||
log_message(error_msg, "ERROR")
|
||||
continue
|
||||
|
||||
# Проверка версии API
|
||||
api_version = desc_response.get('api_version')
|
||||
if api_version != VOLK_API_VERSION:
|
||||
error_msg = f"Несовпадение версии API при загрузке описания категории {category_id}: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Обработка остановлена."
|
||||
error_msg = f"Несовпадение версии API при загрузке описания категории {category_id}: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Пропуск категории."
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
# Останавливаем обработку
|
||||
break
|
||||
# Пропускаем эту категорию и продолжаем обработку
|
||||
continue
|
||||
|
||||
# Проверка наличия блока data
|
||||
if 'data' not in desc_response:
|
||||
error_msg = f"В JSON ответе для категории {category_id} отсутствует блок 'data'. Обработка остановлена."
|
||||
error_msg = f"В JSON ответе для категории {category_id} отсутствует блок 'data'. Пропуск категории."
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
# Останавливаем обработку
|
||||
break
|
||||
# Пропускаем эту категорию и продолжаем обработку
|
||||
continue
|
||||
|
||||
desc_data = desc_response['data']
|
||||
|
||||
# Проверка типа desc_data - должен быть словарём
|
||||
if not isinstance(desc_data, dict):
|
||||
error_msg = f"Блок 'data' для категории {category_id} не является словарём (тип: {type(desc_data)}). Пропуск категории."
|
||||
log_message(error_msg, "ERROR")
|
||||
# Пропускаем эту категорию и продолжаем обработку
|
||||
continue
|
||||
|
||||
# Получаем description из блока data
|
||||
description = desc_data.get('description')
|
||||
# Если description пустое, None или отсутствует, обнуляем поле в БД
|
||||
if not description:
|
||||
description = None
|
||||
|
||||
# Обновляем описание в базе данных
|
||||
# Обработка маркдаун-ссылок на изображения в конце описания
|
||||
image_url = None
|
||||
if description:
|
||||
# Паттерн для поиска маркдаун-ссылки вида  или 
|
||||
# Ищем в конце текста (с учетом пробелов и переносов строк)
|
||||
# Паттерн:  или 
|
||||
# URL может содержать различные символы, включая скобки в URL-кодированном виде
|
||||
markdown_image_pattern = r'!\[\]\(([^)]+?)(?:\s+"[^"]*")?\)\s*$'
|
||||
match = re.search(markdown_image_pattern, description, re.MULTILINE)
|
||||
if match:
|
||||
image_url = match.group(1).strip()
|
||||
# Удаляем маркдаун-ссылку из описания (включая пробелы и переносы строк перед ней)
|
||||
description = re.sub(r'\s*!\[\]\([^)]+?(?:\s+"[^"]*")?\)\s*$', '', description, flags=re.MULTILINE).rstrip()
|
||||
# Если после удаления описание стало пустым, обнуляем его
|
||||
if not description.strip():
|
||||
description = None
|
||||
log_message(f"Извлечен URL изображения для категории {category_id}: {image_url}")
|
||||
|
||||
# Обновляем описание и image_url в базе данных
|
||||
cursor.execute(
|
||||
"UPDATE categories SET description = %s WHERE ID = %s",
|
||||
(description, category_id)
|
||||
"UPDATE categories SET description = %s, image_url = %s WHERE ID = %s",
|
||||
(description, image_url, category_id)
|
||||
)
|
||||
connection.commit()
|
||||
desc_updated_count += 1
|
||||
@@ -198,11 +316,12 @@ def load_categories():
|
||||
log_message(f"Описание для категории {category_id} обнулено (пустое или отсутствует)")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при загрузке описания для категории {category_id}: {e}"
|
||||
error_msg = f"Ошибка при загрузке описания для категории {category_id}: {type(e).__name__}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
# Останавливаем обработку при ошибке
|
||||
break
|
||||
traceback_str = traceback.format_exc()
|
||||
log_message(f"Трассировка ошибки для категории {category_id}:\n{traceback_str}", "ERROR")
|
||||
# Пропускаем эту категорию и продолжаем обработку остальных
|
||||
continue
|
||||
|
||||
log_message(f"Завершение загрузки описаний. Обновлено описаний: {desc_updated_count}.")
|
||||
|
||||
@@ -211,18 +330,16 @@ def load_categories():
|
||||
except Error as e:
|
||||
error_msg = f"Ошибка базы данных: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
if 'connection' in locals() and connection.is_connected():
|
||||
connection.rollback()
|
||||
except Exception as e:
|
||||
error_msg = f"Общая ошибка: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
finally:
|
||||
if 'connection' in locals() and connection.is_connected():
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
|
||||
def load_events_by_category(category_id):
|
||||
"""Экспортируемая функция для загрузки событий категории из JSON в базу данных"""
|
||||
inserted_count = 0
|
||||
@@ -235,14 +352,12 @@ def load_events_by_category(category_id):
|
||||
if not VOLK_EVENT_LIST_PREFIX:
|
||||
error_msg = "Переменная окружения VOLK_EVENT_LIST_PREFIX не установлена"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
return
|
||||
|
||||
# Проверка наличия category_id
|
||||
if not category_id:
|
||||
error_msg = "ID категории не указан"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
return
|
||||
|
||||
# Формирование URL для запроса
|
||||
@@ -258,7 +373,6 @@ def load_events_by_category(category_id):
|
||||
if api_version != VOLK_API_VERSION:
|
||||
error_msg = f"Несовпадение версии API: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Обработка остановлена."
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
return
|
||||
|
||||
log_message(f"Версия API соответствует: {api_version}")
|
||||
@@ -267,7 +381,6 @@ def load_events_by_category(category_id):
|
||||
if 'data' not in data:
|
||||
error_msg = "В JSON отсутствует блок 'data'"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
return
|
||||
|
||||
events_data = data['data']
|
||||
@@ -292,52 +405,69 @@ def load_events_by_category(category_id):
|
||||
log_message("Проверка структуры базы данных")
|
||||
ensure_database_structure()
|
||||
|
||||
# Получаем максимальный number для данной категории, чтобы продолжить нумерацию
|
||||
# Получаем максимальный number для всей таблицы, чтобы продолжить нумерацию
|
||||
cursor.execute(
|
||||
"SELECT MAX(number) as max_number FROM events WHERE unit_name = %s",
|
||||
(category_id,)
|
||||
"SELECT MAX(number) as max_number FROM events"
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
start_number = (result['max_number'] or 0) + 1
|
||||
max_number = result['max_number'] or 0
|
||||
start_number = max_number + 1
|
||||
|
||||
# Обработка событий
|
||||
current_number = start_number
|
||||
for item in events_data:
|
||||
# Проверка обязательного поля id
|
||||
if 'id' not in item or item['id'] is None:
|
||||
log_message(f"Пропущено событие без id: {item}", "WARNING")
|
||||
continue
|
||||
|
||||
# Проверка обязательного поля title
|
||||
if 'title' not in item or not item['title']:
|
||||
log_message(f"Пропущено событие без title: {item}", "WARNING")
|
||||
log_message(f"Пропущено событие без title (id: {item.get('id', 'N/A')}): {item}", "WARNING")
|
||||
continue
|
||||
|
||||
# Подготовка значений с маппингом полей
|
||||
# Преобразуем id в число, если это необходимо
|
||||
try:
|
||||
id_event = int(item['id']) # Уникальный идентификатор события из JSON
|
||||
except (ValueError, TypeError):
|
||||
log_message(f"Пропущено событие с некорректным id (не число): {item.get('id', 'N/A')}", "WARNING")
|
||||
continue
|
||||
name = item.get('title', '')
|
||||
about = item.get('announce', '') or ''
|
||||
authors = item.get('authors', '') or ''
|
||||
about_social_picture = item.get('image', '') or ''
|
||||
unit_name = category_id
|
||||
|
||||
# Проверка существующей записи по name (title)
|
||||
# Проверка существующей записи по id_event (уникальный идентификатор)
|
||||
cursor.execute(
|
||||
"SELECT id, name, authors, about, about_social_picture FROM events WHERE name = %s",
|
||||
(name,)
|
||||
"SELECT id, id_event, name, authors, about, about_social_picture FROM events WHERE id_event = %s",
|
||||
(id_event,)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if not existing:
|
||||
# Вставка новой записи
|
||||
# Используем 0 для id_event, так как его нет в JSON
|
||||
# Используем 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)""",
|
||||
(0, 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}', номер: {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:
|
||||
# Обновление существующей записи
|
||||
needs_update = False
|
||||
update_fields = []
|
||||
|
||||
if existing['name'] != name:
|
||||
needs_update = True
|
||||
update_fields.append('name')
|
||||
if existing['authors'] != authors:
|
||||
needs_update = True
|
||||
update_fields.append('authors')
|
||||
@@ -350,14 +480,14 @@ def load_events_by_category(category_id):
|
||||
|
||||
if needs_update:
|
||||
cursor.execute(
|
||||
"""UPDATE events SET authors = %s, about = %s, about_social_picture = %s
|
||||
WHERE name = %s""",
|
||||
(authors, about, about_social_picture, name)
|
||||
"""UPDATE events SET name = %s, authors = %s, about = %s, about_social_picture = %s
|
||||
WHERE id_event = %s""",
|
||||
(name, authors, about, about_social_picture, id_event)
|
||||
)
|
||||
updated_count += 1
|
||||
log_message(f"Обновление: Событие '{name}', обновлены поля: {', '.join(update_fields)}")
|
||||
log_message(f"Обновление: Событие id_event={id_event} '{name}', обновлены поля: {', '.join(update_fields)}")
|
||||
else:
|
||||
log_message(f"Событие '{name}' без изменений")
|
||||
log_message(f"Событие id_event={id_event} '{name}' без изменений")
|
||||
|
||||
connection.commit()
|
||||
|
||||
@@ -366,13 +496,11 @@ def load_events_by_category(category_id):
|
||||
except Error as e:
|
||||
error_msg = f"Ошибка базы данных: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
if 'connection' in locals() and connection.is_connected():
|
||||
connection.rollback()
|
||||
except Exception as e:
|
||||
error_msg = f"Общая ошибка: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
finally:
|
||||
if 'connection' in locals() and connection.is_connected():
|
||||
cursor.close()
|
||||
@@ -421,7 +549,6 @@ def load_all_events():
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при загрузке событий для категории {category_id}: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
# Продолжаем обработку других категорий
|
||||
continue
|
||||
|
||||
@@ -430,12 +557,10 @@ def load_all_events():
|
||||
except Error as e:
|
||||
error_msg = f"Ошибка базы данных при загрузке всех событий: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
except Exception as e:
|
||||
error_msg = f"Общая ошибка при загрузке всех событий: {e}"
|
||||
log_message(error_msg, "ERROR")
|
||||
print(error_msg)
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_categories()
|
||||
load_all_events()
|
||||
|
||||
|
||||
+29
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user