Compare commits
250
Commits
324e28832e
..
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 | ||
|
|
34fd7c25e8 | ||
|
|
146e55bfa6 | ||
|
|
4a58a80401 | ||
|
|
2c84256634 | ||
|
|
651aee2b9a | ||
|
|
cd9cd739be | ||
|
|
41ccd110ba | ||
|
|
c6642f6cda | ||
|
|
345f8fd121 | ||
|
|
57d6d016a7 | ||
|
|
6488f40613 | ||
|
|
7e33db323a | ||
|
|
b19bd2cc84 | ||
|
|
b760035af9 | ||
|
|
faa5d6dd6f | ||
|
|
ca89e22506 | ||
|
|
40882045e4 | ||
|
|
7054c5fdd7 | ||
|
|
9feb131e98 | ||
|
|
68502ce2f0 | ||
|
|
ab3c32c773 | ||
|
|
5657ee5c9f | ||
|
|
c272a157ff | ||
|
|
046ff4c7e5 | ||
|
|
2411391d18 | ||
|
|
e8dd53a4e9 | ||
|
|
6ff1b980a5 | ||
|
|
2ec5438675 | ||
|
|
a50fd3823a | ||
|
|
1814d7b819 | ||
|
|
45c0671326 | ||
|
|
9ab01a5f74 | ||
|
|
9e70b1aea4 | ||
|
|
2df357a8a0 | ||
|
|
7f9c80849e | ||
|
|
e889717527 | ||
|
|
8c76d98748 | ||
|
|
aa5a1f03ad | ||
|
|
c13d4a9520 | ||
|
|
8777426756 | ||
|
|
a8c9d158aa | ||
|
|
69bf49f3a9 | ||
|
|
da304f6455 | ||
|
|
ad5e70c350 | ||
|
|
4d55698581 | ||
|
|
9f4cc757eb | ||
|
|
0905b21570 | ||
|
|
f1b8e5dbc8 | ||
|
|
ed408e9ba9 | ||
|
|
ae3fd8652d | ||
|
|
6e3a2fbdf7 | ||
|
|
9850477a6a | ||
|
|
28c37a3cec | ||
|
|
96a3f1ee7f | ||
|
|
50b1faef59 | ||
|
|
9a98cffe48 | ||
|
|
a5d3b82092 | ||
|
|
af755115c8 | ||
|
|
824a7978ac | ||
|
|
6bb675ea78 | ||
|
|
dc7713ce1f | ||
|
|
2a84f1ddf1 | ||
|
|
7602c60bd6 | ||
|
|
f40dbbf41d | ||
|
|
8c02770fe3 | ||
|
|
99c5a3b55a | ||
|
|
e3d941b589 | ||
|
|
2af194cfe6 | ||
|
|
f58eb00ba4 | ||
|
|
da73f650b8 | ||
|
|
25cd77ec92 | ||
|
|
8c8a8970d1 | ||
|
|
1b42ba1709 | ||
|
|
2fe47b7d2d | ||
|
|
784ef64971 | ||
|
|
c898c5b60d | ||
|
|
f0255fe21e | ||
|
|
bf728ea11b | ||
|
|
2901724e40 | ||
|
|
c91c0d6e1c | ||
|
|
b8189e9b12 | ||
|
|
d7c38e112a | ||
|
|
4ba82c3c7d | ||
|
|
f05047c6a0 | ||
|
|
421a484d2b | ||
|
|
6f3156a4ac | ||
|
|
3e0c79855f | ||
|
|
243051d3f5 | ||
|
|
9a6f39c7c4 | ||
|
|
7135c0c364 | ||
|
|
b4b4f055d9 | ||
|
|
4b42eac739 | ||
|
|
1c0f4cceb1 | ||
|
|
6da846af74 | ||
|
|
5e846b0984 | ||
|
|
c831e741c1 | ||
|
|
922635aa98 | ||
|
|
aff7b361cb | ||
|
|
d034d0466d | ||
|
|
89bed6a2e3 | ||
|
|
ef8fbd6b22 | ||
|
|
a1a8d779a9 | ||
|
|
956c6fc4c6 | ||
|
|
1c79316d1f | ||
|
|
6814ec0b6e | ||
|
|
5e635eb77e | ||
|
|
0b1e7d78b2 | ||
|
|
d8112d6677 | ||
|
|
e6e4cd7262 | ||
|
|
4ba1acf782 | ||
|
|
b73584ec25 | ||
|
|
82e43cbc31 | ||
|
|
9ee7ea98d6 | ||
|
|
3a1f400ae9 | ||
|
|
017fab0ce1 | ||
|
|
56ea8e744e |
@@ -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**.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Исключить все файлы .env из игнорирования Cursor
|
||||||
|
!.env
|
||||||
@@ -1,35 +1,162 @@
|
|||||||
# Общие настройки
|
# Общие настройки
|
||||||
SECRET_KEY=24af5f41dba9d8cc0dbc1841660da0b14d7df86ab5e0ed973cdd3bc2dbac23f6
|
SECRET_KEY=24af5f41dba9d8cc0dbc1841660da0b14d7df86ab5e0ed973cdd3bc2dbac23f6
|
||||||
LOG_FILE = /var/log/zilantposter.log
|
LOG_FILE = /opt/testbot-git/logs/testzilantposter.log
|
||||||
|
# LOG_FILE = /opt/volk/logs/volkposter.log
|
||||||
|
|
||||||
|
WORKMODE = "ZILANT"
|
||||||
|
# WORKMODE = "VOLK"
|
||||||
|
|
||||||
|
# Код сезона для deep link / callback подписок (zk2026_post_…, zk2026_event_…)
|
||||||
|
SEASON = "zk2026"
|
||||||
|
|
||||||
|
# Telegram API через internal_relay (retransbot) — обязательно
|
||||||
|
# Нужна схема http:// или https:// (если без схемы — подставится http://)
|
||||||
|
# Локально: http://127.0.0.1:5100 или http://192.168.0.109:5100
|
||||||
|
# Через nginx: https://rbin.aabpro.ru
|
||||||
|
RELAY_URL = "http://192.168.0.109:5100"
|
||||||
|
|
||||||
|
# Webhook Responder-bot
|
||||||
|
# WEBHOOK_URL — адрес локальной доставки апдейтов от internal_relay к gunicorn-боту
|
||||||
|
# (должен быть доступен с машины internal_relay). Не URL для Telegram.
|
||||||
|
# gunicorn слушает 192.168.0.109:5005 — указываем его напрямую.
|
||||||
|
# WEBHOOK_URL = "http://192.168.0.109:5010/volk_bot"
|
||||||
|
# WEBHOOK_PORT = 5010
|
||||||
|
# WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
|
||||||
|
|
||||||
|
# Webhook Responder-bot
|
||||||
|
WEBHOOK_URL = "http://192.168.0.109:5005/testbot"
|
||||||
|
WEBHOOK_PORT = 5005
|
||||||
|
WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
|
||||||
|
|
||||||
|
# Webhook Poster-bot
|
||||||
|
#POSTHOOK_URL = "https://bot.aabpro.ru/testbot2"
|
||||||
|
#POSTHOOK_PORT = 5008
|
||||||
|
#POSTHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
|
||||||
|
|
||||||
|
# Controller
|
||||||
|
WEBCTRL_URL = "https://bot.aabpro.ru/testedit"
|
||||||
|
WEBCTRL_PORT = 5006
|
||||||
|
WEBCTRL_PREFIX = "/testedit"
|
||||||
|
|
||||||
|
# WEBCTRL_URL = "https://bot.aabpro.ru/volk"
|
||||||
|
# WEBCTRL_PORT = 5009
|
||||||
|
# WEBCTRL_PREFIX = "/volk"
|
||||||
|
|
||||||
|
|
||||||
# логин и пароль по умолчанию для первого запуска веб-контроллера
|
# логин и пароль по умолчанию для первого запуска веб-контроллера
|
||||||
DEFNM=admin
|
DEFNM=admin
|
||||||
DEFPW=Z1l@ntAdm1n23
|
DEFPW=Z1l@ntAdm1n23
|
||||||
|
|
||||||
# VK access
|
# VK access
|
||||||
|
# Zilant group
|
||||||
VK_GROUP_ID = -3342146
|
VK_GROUP_ID = -3342146
|
||||||
|
# VOLK group
|
||||||
|
# VK_GROUP_ID = -58475527
|
||||||
VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012e946b952a8'
|
VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012e946b952a8'
|
||||||
VK_API_VERSION = '5.131'
|
VK_API_VERSION = '5.131'
|
||||||
|
|
||||||
# MySQL database
|
# MySQL database
|
||||||
MDBASE = 'zilant_posts'
|
MDBASE = 'testposts'
|
||||||
|
# MDBASE = 'tempzilant' # debug Zilant database
|
||||||
|
# Zilant 2026 database
|
||||||
|
# MDBASE = 'zilant_2026'
|
||||||
|
# MDBASE = 'volk_test' # debug VOLK database
|
||||||
|
# MDBASE = 'volkdata' # VOLK production database
|
||||||
MDB_HOST = 'localhost'
|
MDB_HOST = 'localhost'
|
||||||
MDB_USER = 'zilantposter'
|
# MDB_USER = 'zilantposter'
|
||||||
MDB_PW = 'Z1l@ntp08t3r'
|
# MDB_PW = 'Z1l@ntp08t3r'
|
||||||
|
MDB_USER = 'testuser'
|
||||||
|
MDB_PW = 'T38tU83r69'
|
||||||
|
# MDB_USER = 'volkuser'
|
||||||
|
# MDB_PW = 'V0lK@dM1n23'
|
||||||
|
|
||||||
# Poster bot
|
# Poster bot
|
||||||
BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
|
# Debug bot @posto_1638_bot
|
||||||
|
# BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
|
||||||
|
# @info_1638_bot
|
||||||
|
POSTER_BOT_TOKEN = "8215624618:AAF6VtqRtFr4twFRTiTjedSDE6txkbIy6rc"
|
||||||
|
# VOLK production bot @VOLK_informer_bot
|
||||||
|
# POSTER_BOT_TOKEN = "8279661544:AAEsJee6icCUn_AbWxhDpk2CLxQhzaartuI"
|
||||||
|
# VOLK backup production bot @VOLK_informer2_bot
|
||||||
|
# POSTER_BOT_TOKEN = "8001674071:AAFyGW4xibAFnuApEaSJvshqEyYHmD7RRts"
|
||||||
|
# VOLK responder bot
|
||||||
|
# RESPONDER_BOT_TOKEN = "8072579275:AAFiyKN1EPPnP5ZVZNTDGEriYJf1xADbfsY"
|
||||||
|
# RESPONDER_BOT_NAME = "VOLK_responder_bot"
|
||||||
|
# @posto_1638_bot
|
||||||
|
RESPONDER_BOT_TOKEN = "8301828103:AAHDuBkw7EUkmMvv5TPpVJaLeeBLu-EB21U"
|
||||||
|
RESPONDER_BOT_NAME = "posto_1638_bot"
|
||||||
|
# Zilant info bot
|
||||||
|
# BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
|
||||||
|
#
|
||||||
|
# Responder bot
|
||||||
|
# Zilant poster bot
|
||||||
|
# BOT_TOKEN = "8115998792:AAHamcNBlTgTXG0Birc1gNbZ5B7z4jMEdik"
|
||||||
|
#
|
||||||
|
# POSTER_BOT_TOKEN = "8115998792:AAHamcNBlTgTXG0Birc1gNbZ5B7z4jMEdik"
|
||||||
|
# RESPONDER_BOT_TOKEN = "7951721805:AAEW6Go9yvOOcJSWjgJZ7E2XLPtii2qG7UU"
|
||||||
|
# RESPONDER_BOT_NAME = "Zilant_info_bot"
|
||||||
|
# RESPONDER_BOT_NAME = "posto_1638_bot"
|
||||||
|
|
||||||
|
# Parameters
|
||||||
|
#
|
||||||
|
# Tag to detect org info
|
||||||
|
# Org info is normally long, publish without picture
|
||||||
|
ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
|
||||||
|
# tag to detect posts to not repost from VK
|
||||||
|
VK_NO_REPOST_TAG = "#окоЗиланта"
|
||||||
|
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
|
||||||
|
VK_POST_MIN_LETTERS = 5
|
||||||
|
|
||||||
|
|
||||||
|
# Minutes delay before publishing an event
|
||||||
|
# calculated as diff between modified field and current datetime
|
||||||
|
# !! DB datetime is UTC !!
|
||||||
|
#
|
||||||
|
# Set 1 hour when Zilant is near
|
||||||
|
# EVENT_POST_DELAY = 60
|
||||||
|
# keep 1 day when far from Zilant
|
||||||
|
EVENT_POST_DELAY = 1440
|
||||||
|
|
||||||
# Poster channel
|
# Poster channel
|
||||||
|
# Debug channel
|
||||||
CHANNEL_ID = "@posto1638"
|
CHANNEL_ID = "@posto1638"
|
||||||
# DB_NAME = "vk_posts.db"
|
# Zilant production channel
|
||||||
|
# CHANNEL_ID = "@zilantkon"
|
||||||
|
# VOLK production channel
|
||||||
|
# CHANNEL_ID = "@VOLKinformer"
|
||||||
|
|
||||||
MAX_CAPTION_LENGTH = 1000
|
MAX_CAPTION_LENGTH = 1000
|
||||||
MAX_TEXT_LENGTH = 4000
|
MAX_TEXT_LENGTH = 4000
|
||||||
|
|
||||||
# Button scripts
|
|
||||||
RESCAN_SCRIPT = "vk_load.py"
|
# Zilant API call - План Захвата
|
||||||
PUBLISH_SCRIPT = "dummy.py"
|
#
|
||||||
|
# add event number and trailing /
|
||||||
|
#
|
||||||
|
PZK_PREFIX = "https://reg.konvent.ru/zk2026/draft/plan/api/add/"
|
||||||
|
EVT_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/api/"
|
||||||
|
DESC_PREFIX = "https://reg.konvent.ru/zk2026/events/actions/"
|
||||||
|
|
||||||
|
# VOLK mode API links
|
||||||
|
# add category ID (volk24_larp for ex.)
|
||||||
|
# VOLK_API_VERSION = "1.0"
|
||||||
|
# VOLK_CATEGORY_LIST = "https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event"
|
||||||
|
# VOLK_CATEGORY_DESC_PREFIX = "https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category="
|
||||||
|
# VOLK_EVENT_LIST_PREFIX = "https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category="
|
||||||
|
|
||||||
# Event status
|
# Event status
|
||||||
IS_EVENT = false
|
IS_EVENT = true
|
||||||
AUTO_PUBLISH = false
|
AUTO_PUBLISH = true
|
||||||
|
AUTO_PUB_EVT = true
|
||||||
|
PROCESS_EVENT_UPDATES = false
|
||||||
|
PUBLISH_SILENTLY = true
|
||||||
|
USE_SUBSCRIPTION_BOT = true
|
||||||
|
LOG_DEBUG_DATA = false
|
||||||
|
|
||||||
|
# RouterAI (генерация shortname)
|
||||||
|
RA_KEY = "sk-7pJxRlbYWUGMNX9y2mHzi2soKDIsZPzp"
|
||||||
|
RA_MODEL = "moonshotai/kimi-k2-thinking"
|
||||||
|
|
||||||
|
# OpenRouter (не используется, оставлен для справки)
|
||||||
|
# OR_KEY = "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"
|
||||||
|
# OR_MODEL_NAME = "anthropic/claude-sonnet-5"
|
||||||
|
|||||||
+128
@@ -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/
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
steps:
|
||||||
|
- name: deploy
|
||||||
|
image: alpine:latest
|
||||||
|
environment:
|
||||||
|
SSH_PRIVATE_KEY:
|
||||||
|
from_secret: SSH_PRIVATE_KEY
|
||||||
|
commands:
|
||||||
|
# Установка SSH-клиента
|
||||||
|
- apk add --no-cache openssh-client rsync
|
||||||
|
# Настройка SSH-ключа из секрета
|
||||||
|
- mkdir -p ~/.ssh
|
||||||
|
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||||
|
- chmod 600 ~/.ssh/id_rsa
|
||||||
|
|
||||||
|
- ssh-keyscan 192.168.0.109 >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
# Копирование файлов
|
||||||
|
- |
|
||||||
|
rsync -av \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.woodpecker.yml' \
|
||||||
|
--exclude='.cursorignore' \
|
||||||
|
./ deployer@192.168.0.109:/opt/testbot-git/
|
||||||
|
|
||||||
|
- |
|
||||||
|
ssh -i ~/.ssh/id_rsa deployer@192.168.0.109 \
|
||||||
|
"cd /opt/testbot-git/services && chmod +x post-deploy.sh && ./post-deploy.sh"
|
||||||
|
|
||||||
|
# Выполнение команд на целевом сервере
|
||||||
|
# - ssh -i ~/.ssh/id_rsa deployer@целевой-сервер "
|
||||||
|
# cd /var/www/myapp &&
|
||||||
|
# systemctl --user daemon-reload || sudo systemctl daemon-reload &&
|
||||||
|
# sudo systemctl restart myservice &&
|
||||||
|
# echo '✅ Deploy completed'
|
||||||
|
# "
|
||||||
|
|
||||||
|
when:
|
||||||
|
event: [push, manual]
|
||||||
|
branch: main
|
||||||
@@ -1,3 +1,68 @@
|
|||||||
# Zilant2025
|
# Zilant / VOLK — Telegram poster и подписки
|
||||||
|
|
||||||
Zilantkon bot testarea
|
**Версия:** 1.1.4 ([`VERSION`](VERSION))
|
||||||
|
|
||||||
|
Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link.
|
||||||
|
|
||||||
|
## Режимы
|
||||||
|
|
||||||
|
| `WORKMODE` | Описание |
|
||||||
|
|------------|----------|
|
||||||
|
| `ZILANT` | VK + Zilant JSON → канал (основной пайплайн) |
|
||||||
|
| `VOLK` | VOLK API → категории и события → канал |
|
||||||
|
|
||||||
|
Подробнее: [`docs/volk-mode.md`](docs/volk-mode.md).
|
||||||
|
|
||||||
|
## Быстрый старт (сервер)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy через Woodpecker → rsync + post-deploy.sh
|
||||||
|
# Сервисы:
|
||||||
|
sudo systemctl status testbot-git.service # бот :5005
|
||||||
|
sudo systemctl status testedit-git.service # UI :5006
|
||||||
|
sudo systemctl status runner.timer # hourly pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Конфигурация — скопируйте [`.env.example`](.env.example) в `.env` и заполните значения
|
||||||
|
(файл `.env` в git не коммитится). Ключевые переменные: `WORKMODE`, `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
|
||||||
|
Генерация секретов: `python tools/ops/genkey.py`.
|
||||||
|
|
||||||
|
## Пайплайн (ZILANT)
|
||||||
|
|
||||||
|
`runner.sh` по расписанию:
|
||||||
|
|
||||||
|
`vk_load` → `zk_load` → `db_update_shortname` → `tg_publish` → `evtg_publish`
|
||||||
|
|
||||||
|
Настройка timer: [`docs/runner-setup.md`](docs/runner-setup.md).
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
| Документ | О чём |
|
||||||
|
|----------|--------|
|
||||||
|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Компоненты, карта файлов, deploy |
|
||||||
|
| [`docs/volk-mode.md`](docs/volk-mode.md) | Режим VOLK |
|
||||||
|
| [`docs/telegram-messaging.md`](docs/telegram-messaging.md) | API отправки сообщений пользователям |
|
||||||
|
| [`docs/runner-setup.md`](docs/runner-setup.md) | Systemd timer / cron для runner |
|
||||||
|
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | История версий |
|
||||||
|
| [`tools/README.md`](tools/README.md) | Вспомогательные и legacy-скрипты |
|
||||||
|
|
||||||
|
## Версионирование
|
||||||
|
|
||||||
|
SemVer в [`VERSION`](VERSION). При изменениях обновлять `VERSION` и [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
|
||||||
|
|
||||||
|
## Структура каталогов
|
||||||
|
|
||||||
|
```
|
||||||
|
├── tg_mainbot.py, wsgi_bot.py # responder-бот
|
||||||
|
├── db_edit.py, wsgi_edit.py # веб-UI
|
||||||
|
├── tg_publish.py, evtg_publish.py # публикация (ZILANT)
|
||||||
|
├── volk_load.py, volk_cat_publish.py # VOLK
|
||||||
|
├── runner.sh, backupdb.sh
|
||||||
|
├── services/ # systemd units, post-deploy
|
||||||
|
├── templates/ # HTML UI
|
||||||
|
├── docs/ # документация
|
||||||
|
└── tools/
|
||||||
|
├── ops/ # genkey, register_tg_webhook, copy_databases
|
||||||
|
├── dev/ # send_telegram_example, volk_*_testing
|
||||||
|
└── legacy/ # evt_prefetch
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Перенесено
|
||||||
|
|
||||||
|
Документация: [`docs/telegram-messaging.md`](docs/telegram-messaging.md)
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import os
|
|
||||||
import mysql.connector
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Загрузка переменных окружения из .env файла
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Конфигурация базы данных из переменных окружения
|
|
||||||
DB_CONFIG = {
|
|
||||||
'host': os.getenv('MDB_HOST'),
|
|
||||||
'user': os.getenv('MDB_USER'),
|
|
||||||
'password': os.getenv('MDB_PW'),
|
|
||||||
'database': os.getenv('MDBASE')
|
|
||||||
}
|
|
||||||
|
|
||||||
def alter_column_type():
|
|
||||||
try:
|
|
||||||
# Устанавливаем соединение с базой данных
|
|
||||||
connection = mysql.connector.connect(**DB_CONFIG)
|
|
||||||
cursor = connection.cursor()
|
|
||||||
|
|
||||||
# Выполняем ALTER TABLE запрос
|
|
||||||
alter_query = """
|
|
||||||
ALTER TABLE events
|
|
||||||
MODIFY COLUMN about TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
|
||||||
"""
|
|
||||||
cursor.execute(alter_query)
|
|
||||||
|
|
||||||
# Подтверждаем изменения
|
|
||||||
connection.commit()
|
|
||||||
|
|
||||||
print("Тип поля 'about' успешно изменен на TEXT")
|
|
||||||
|
|
||||||
except mysql.connector.Error as error:
|
|
||||||
print(f"Ошибка при изменении типа поля: {error}")
|
|
||||||
if connection:
|
|
||||||
connection.rollback()
|
|
||||||
finally:
|
|
||||||
if cursor:
|
|
||||||
cursor.close()
|
|
||||||
if connection:
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
alter_column_type()
|
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Настройки
|
||||||
|
DB_NAME="testposts"
|
||||||
|
BACKUP_DIR="/opt/testbot-git/dbbackup"
|
||||||
|
DATE=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${DATE}.sql"
|
||||||
|
|
||||||
|
# Создаем каталог для бэкапов если не существует
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Делаем дамп базы данных
|
||||||
|
sudo mysqldump --databases "$DB_NAME" > "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# Проверяем успешность выполнения
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Бэкап успешно создан: $BACKUP_FILE"
|
||||||
|
else
|
||||||
|
echo "Ошибка при создании бэкапа!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#! /bin/bash
|
|
||||||
# Backup database
|
|
||||||
mysqldump -utestuser -pT38tU83r69 testposts > test_posts_backup.sql
|
|
||||||
+758
-58
@@ -24,6 +24,9 @@ from tg_publish import publish_to_tg_all, publish_to_tg
|
|||||||
from db_update_shortname import ai_shortname_all, generate_ai_shortname
|
from db_update_shortname import ai_shortname_all, generate_ai_shortname
|
||||||
from evtg_publish import tg_post_event_by_id, tg_post_all_events
|
from evtg_publish import tg_post_event_by_id, tg_post_all_events
|
||||||
from zk_load import load_json_all
|
from zk_load import load_json_all
|
||||||
|
from volk_load import load_categories, load_all_events
|
||||||
|
from volk_cat_publish import tg_post_category_by_id, tg_post_all_categories
|
||||||
|
from test_tg_poster import make_test_post
|
||||||
|
|
||||||
# Загружаем настройки из .env
|
# Загружаем настройки из .env
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -113,7 +116,7 @@ class DatabaseManager:
|
|||||||
with self.conn.cursor() as cursor:
|
with self.conn.cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, vk_post_id, shortname, text, published_at,
|
SELECT id, vk_post_id, shortname, text, published_at,
|
||||||
marked_for_publication, published_in_tg, is_event, is_poll, action_number
|
marked_for_publication, published_in_tg, is_event, is_poll, action_number, auto_unit
|
||||||
FROM posts
|
FROM posts
|
||||||
ORDER BY published_at DESC
|
ORDER BY published_at DESC
|
||||||
""")
|
""")
|
||||||
@@ -305,8 +308,8 @@ class DatabaseManager:
|
|||||||
vk_post_id, shortname, text, image_url, vk_post_url, published_at,
|
vk_post_id, shortname, text, image_url, vk_post_url, published_at,
|
||||||
is_event, is_poll, poll_question, poll_options, poll_multiple, poll_end_date,
|
is_event, is_poll, poll_question, poll_options, poll_multiple, poll_end_date,
|
||||||
marked_for_publication, published_in_tg, tg_publication_date, tg_message_id,
|
marked_for_publication, published_in_tg, tg_publication_date, tg_message_id,
|
||||||
tg_poll_id, tg_markpost_id, tg_poll_results, action_number
|
tg_poll_id, tg_markpost_id, tg_poll_results, action_number, auto_unit
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
"""
|
"""
|
||||||
values = (
|
values = (
|
||||||
clean_int(data['vk_post_id']),
|
clean_int(data['vk_post_id']),
|
||||||
@@ -328,7 +331,8 @@ class DatabaseManager:
|
|||||||
clean_int(data.get('tg_poll_id', '')),
|
clean_int(data.get('tg_poll_id', '')),
|
||||||
clean_int(data.get('tg_markpost_id', '')),
|
clean_int(data.get('tg_markpost_id', '')),
|
||||||
data.get('tg_poll_results', ''),
|
data.get('tg_poll_results', ''),
|
||||||
clean_int(data.get('action_number', ''))
|
clean_int(data.get('action_number', '')),
|
||||||
|
data.get('auto_unit', '')
|
||||||
)
|
)
|
||||||
cursor.execute(query, values)
|
cursor.execute(query, values)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
@@ -354,7 +358,7 @@ class DatabaseManager:
|
|||||||
published_at = %s, is_event = %s, is_poll = %s, poll_question = %s, poll_options = %s,
|
published_at = %s, is_event = %s, is_poll = %s, poll_question = %s, poll_options = %s,
|
||||||
poll_multiple = %s, poll_end_date = %s, marked_for_publication = %s,
|
poll_multiple = %s, poll_end_date = %s, marked_for_publication = %s,
|
||||||
published_in_tg = %s, tg_publication_date = %s, tg_message_id = %s,
|
published_in_tg = %s, tg_publication_date = %s, tg_message_id = %s,
|
||||||
tg_poll_id = %s, tg_markpost_id = %s, tg_poll_results = %s, action_number = %s
|
tg_poll_id = %s, tg_markpost_id = %s, tg_poll_results = %s, action_number = %s, auto_unit = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
"""
|
"""
|
||||||
values = (
|
values = (
|
||||||
@@ -378,6 +382,7 @@ class DatabaseManager:
|
|||||||
clean_int(data.get('tg_markpost_id', '')),
|
clean_int(data.get('tg_markpost_id', '')),
|
||||||
data.get('tg_poll_results', ''),
|
data.get('tg_poll_results', ''),
|
||||||
clean_int(data.get('action_number', '')),
|
clean_int(data.get('action_number', '')),
|
||||||
|
data.get('auto_unit', ''),
|
||||||
post_id
|
post_id
|
||||||
)
|
)
|
||||||
cursor.execute(query, values)
|
cursor.execute(query, values)
|
||||||
@@ -509,6 +514,10 @@ def load_user(user_id):
|
|||||||
@bp.before_request
|
@bp.before_request
|
||||||
def before_request():
|
def before_request():
|
||||||
g.prefix = app.config['PREFIX']
|
g.prefix = app.config['PREFIX']
|
||||||
|
g.bot_port = os.getenv('WEBHOOK_PORT', '5005')
|
||||||
|
g.webhook_secret = os.getenv('WEBHOOK_SECRET')
|
||||||
|
g.webhook_url = os.getenv('WEBHOOK_URL')
|
||||||
|
g.workmode = os.getenv('WORKMODE', 'ZILANT')
|
||||||
|
|
||||||
# Маршруты Blueprint
|
# Маршруты Blueprint
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
@@ -612,7 +621,8 @@ def api_posts():
|
|||||||
'published_in_tg': bool(post['published_in_tg']),
|
'published_in_tg': bool(post['published_in_tg']),
|
||||||
'is_event': bool(post['is_event']),
|
'is_event': bool(post['is_event']),
|
||||||
'is_poll': bool(post['is_poll']),
|
'is_poll': bool(post['is_poll']),
|
||||||
'action_number': post['action_number']
|
'action_number': post['action_number'],
|
||||||
|
'auto_unit': post['auto_unit']
|
||||||
})
|
})
|
||||||
return jsonify(posts_list)
|
return jsonify(posts_list)
|
||||||
finally:
|
finally:
|
||||||
@@ -668,6 +678,28 @@ def api_toggle_event_publication(event_id):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/events/mark_all_for_publication', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_mark_all_events_for_publication():
|
||||||
|
"""Отмечает все события для публикации"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE events
|
||||||
|
SET marked_to_publication = 1
|
||||||
|
WHERE marked_to_publication = 0
|
||||||
|
""")
|
||||||
|
count = cursor.rowcount
|
||||||
|
db.conn.commit()
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Отмечено для публикации: {count} событий, IP {client_ip}")
|
||||||
|
return jsonify({'success': True, 'count': count})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
@bp.route('/api/events', methods=['POST'])
|
@bp.route('/api/events', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_add_event():
|
def api_add_event():
|
||||||
@@ -795,6 +827,28 @@ def api_toggle_publication(post_id):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/posts/mark_all_for_publication', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_mark_all_posts_for_publication():
|
||||||
|
"""Отмечает все посты для публикации"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE posts
|
||||||
|
SET marked_for_publication = 1
|
||||||
|
WHERE marked_for_publication = 0
|
||||||
|
""")
|
||||||
|
count = cursor.rowcount
|
||||||
|
db.conn.commit()
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Отмечено для публикации: {count} постов, IP {client_ip}")
|
||||||
|
return jsonify({'success': True, 'count': count})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
@bp.route('/api/posts', methods=['POST'])
|
@bp.route('/api/posts', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_add_post():
|
def api_add_post():
|
||||||
@@ -932,6 +986,24 @@ def api_generate_all_shortnames():
|
|||||||
log_event(error_msg)
|
log_event(error_msg)
|
||||||
return jsonify({'success': False, 'error': error_msg}), 500
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
|
@bp.route('/api/test_post', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_test_post():
|
||||||
|
try:
|
||||||
|
log_event("Запуск публикации тестового сообщения...")
|
||||||
|
result = make_test_post()
|
||||||
|
if result:
|
||||||
|
log_event("Тестовое сообщение успешно опубликовано")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
else:
|
||||||
|
error_msg = "Не удалось опубликовать тестовое сообщение"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при публикации тестового сообщения: {str(e)}"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
@bp.route('/api/generate_shortname/<int:post_id>', methods=['POST'])
|
@bp.route('/api/generate_shortname/<int:post_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_generate_shortname(post_id):
|
def api_generate_shortname(post_id):
|
||||||
@@ -939,12 +1011,23 @@ def api_generate_shortname(post_id):
|
|||||||
try:
|
try:
|
||||||
post = db.get_post(post_id)
|
post = db.get_post(post_id)
|
||||||
if not post:
|
if not post:
|
||||||
return jsonify({'error': 'Post not found'}), 404
|
return jsonify({'success': False, 'error': 'Post not found'}), 404
|
||||||
|
|
||||||
log_event(f"Генерация названия для поста ID: {post_id}")
|
log_event(f"Генерация названия для поста ID: {post_id}")
|
||||||
shortname = generate_ai_shortname(post['text'])
|
shortname = generate_ai_shortname(post['text'])
|
||||||
|
if not shortname:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Не удалось сгенерировать название (пустой ответ AI)',
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# После долгого AI-запроса MySQL-соединение могло протухнуть
|
||||||
|
try:
|
||||||
|
db.conn.ping(reconnect=True)
|
||||||
|
except Exception:
|
||||||
|
db.close()
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
# Обновляем shortname в базе данных
|
|
||||||
with db.conn.cursor() as cursor:
|
with db.conn.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE posts SET shortname = %s WHERE id = %s",
|
"UPDATE posts SET shortname = %s WHERE id = %s",
|
||||||
@@ -959,7 +1042,10 @@ def api_generate_shortname(post_id):
|
|||||||
log_event(error_msg)
|
log_event(error_msg)
|
||||||
return jsonify({'success': False, 'error': error_msg}), 500
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
try:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@bp.route('/api/publish_post/<int:post_id>', methods=['POST'])
|
@bp.route('/api/publish_post/<int:post_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -979,11 +1065,49 @@ def api_publish_post(post_id):
|
|||||||
# Функция для запуска в отдельном потоке
|
# Функция для запуска в отдельном потоке
|
||||||
def run_async_func():
|
def run_async_func():
|
||||||
try:
|
try:
|
||||||
|
# Устанавливаем правильную политику event loop для работы с anyio
|
||||||
|
# Это критично для правильной работы httpx в отдельном потоке
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
# На Windows используем ProactorEventLoop
|
||||||
|
policy = asyncio.WindowsProactorEventLoopPolicy()
|
||||||
|
else:
|
||||||
|
# На Linux используем DefaultEventLoopPolicy
|
||||||
|
policy = asyncio.DefaultEventLoopPolicy()
|
||||||
|
|
||||||
|
asyncio.set_event_loop_policy(policy)
|
||||||
|
|
||||||
|
# Создаем новый event loop для этого потока
|
||||||
|
# Это важно при работе с gunicorn, который может иметь свой event loop
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
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:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
log_event(f"Ошибка в run_async_func: {str(e)}\n{traceback.format_exc()}")
|
||||||
return e
|
return e
|
||||||
|
|
||||||
# Создаем очередь для передачи результата
|
# Создаем очередь для передачи результата
|
||||||
@@ -997,6 +1121,12 @@ def api_publish_post(post_id):
|
|||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
raise result
|
raise result
|
||||||
|
|
||||||
|
# Проверяем, что публикация действительно успешна (результат True)
|
||||||
|
if result is not True:
|
||||||
|
error_msg = f"Публикация поста ID: {post_id} не удалась (результат: {result})"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
log_event(f"Публикация поста ID: {post_id} выполнена успешно")
|
log_event(f"Публикация поста ID: {post_id} выполнена успешно")
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1011,6 +1141,13 @@ def api_publish_post(post_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def api_run_zk_rescan():
|
def api_run_zk_rescan():
|
||||||
try:
|
try:
|
||||||
|
# Проверка режима работы
|
||||||
|
workmode = os.getenv('WORKMODE', 'ZILANT')
|
||||||
|
if workmode != 'ZILANT':
|
||||||
|
error_msg = "Рескан Зилант-сервера доступен только в режиме ZILANT"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 403
|
||||||
|
|
||||||
load_json_all()
|
load_json_all()
|
||||||
log_event("Рескан Зилант-сервера выполнен успешно")
|
log_event("Рескан Зилант-сервера выполнен успешно")
|
||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
@@ -1057,6 +1194,422 @@ def api_publish_all_events():
|
|||||||
log_event(error_msg)
|
log_event(error_msg)
|
||||||
return jsonify({'success': False, 'error': error_msg}), 500
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
|
@bp.route('/api/publish_all_categories', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_publish_all_categories():
|
||||||
|
try:
|
||||||
|
log_event("Запуск публикации всех площадок...")
|
||||||
|
|
||||||
|
# Создаем асинхронную обертку для запуска в отдельном потоке
|
||||||
|
import asyncio
|
||||||
|
from threading import Thread
|
||||||
|
import queue
|
||||||
|
|
||||||
|
def run_async_func():
|
||||||
|
try:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
result = loop.run_until_complete(tg_post_all_categories())
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return e
|
||||||
|
|
||||||
|
# Создаем очередь для передачи результата
|
||||||
|
q = queue.Queue()
|
||||||
|
# Запускаем в отдельном потоке
|
||||||
|
thread = Thread(target=lambda: q.put(run_async_func()))
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
result = q.get()
|
||||||
|
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
|
||||||
|
log_event("Публикация всех площадок выполнена успешно")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при публикации всех площадок: {str(e)}"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
|
@bp.route('/api/publish_category/<category_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_publish_category(category_id):
|
||||||
|
try:
|
||||||
|
# Сначала проверяем существование категории
|
||||||
|
db = get_db()
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT ID, TITLE FROM categories WHERE ID = %s", (category_id,))
|
||||||
|
category = cursor.fetchone()
|
||||||
|
if not category:
|
||||||
|
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
log_event(f"Запуск публикации площадки ID: {category_id}")
|
||||||
|
|
||||||
|
# Создаем асинхронную обертку для запуска в отдельном потоке
|
||||||
|
import asyncio
|
||||||
|
from threading import Thread
|
||||||
|
import queue
|
||||||
|
|
||||||
|
def run_async_func():
|
||||||
|
try:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
result = loop.run_until_complete(tg_post_category_by_id(category_id))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return e
|
||||||
|
|
||||||
|
# Создаем очередь для передачи результата
|
||||||
|
q = queue.Queue()
|
||||||
|
# Запускаем в отдельном потоке
|
||||||
|
thread = Thread(target=lambda: q.put(run_async_func()))
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
result = q.get()
|
||||||
|
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
|
||||||
|
log_event(f"Публикация площадки ID {category_id} выполнена успешно")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при публикации площадки: {str(e)}"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
|
@bp.route('/api/load_categories', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_load_categories():
|
||||||
|
try:
|
||||||
|
# Проверка режима работы
|
||||||
|
workmode = os.getenv('WORKMODE', 'ZILANT')
|
||||||
|
if workmode != 'VOLK':
|
||||||
|
error_msg = "Загрузка категорий доступна только в режиме VOLK"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 403
|
||||||
|
|
||||||
|
log_event("Запуск загрузки категорий...")
|
||||||
|
load_categories()
|
||||||
|
log_event("Загрузка категорий выполнена успешно")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при загрузке категорий: {str(e)}"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
|
@bp.route('/api/categories')
|
||||||
|
@login_required
|
||||||
|
def api_categories():
|
||||||
|
"""Получает список всех категорий (площадок) из таблицы categories"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT AUTO_ID, ID, TITLE, description, marked_for_publication, is_tg_published
|
||||||
|
FROM categories
|
||||||
|
ORDER BY TITLE, ID
|
||||||
|
""")
|
||||||
|
categories = cursor.fetchall()
|
||||||
|
|
||||||
|
categories_list = []
|
||||||
|
for cat in categories:
|
||||||
|
categories_list.append({
|
||||||
|
'auto_id': cat['AUTO_ID'],
|
||||||
|
'id': cat['ID'],
|
||||||
|
'title': cat['TITLE'] or '',
|
||||||
|
'description': cat['description'] or '',
|
||||||
|
'marked_for_publication': bool(cat['marked_for_publication']) if cat['marked_for_publication'] is not None else False,
|
||||||
|
'is_tg_published': bool(cat['is_tg_published']) if cat['is_tg_published'] is not None else False
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(categories_list)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/categories/<category_id>')
|
||||||
|
@login_required
|
||||||
|
def api_category_details(category_id):
|
||||||
|
"""Получает детали категории по ID"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT AUTO_ID, ID, TITLE, description, image_url, tg_id, is_tg_published, tg_published_date, marked_for_publication
|
||||||
|
FROM categories
|
||||||
|
WHERE ID = %s
|
||||||
|
""", (category_id,))
|
||||||
|
category = cursor.fetchone()
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
return jsonify({'error': 'Category not found'}), 404
|
||||||
|
|
||||||
|
# Получаем количество событий для этой категории
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) as event_count
|
||||||
|
FROM events
|
||||||
|
WHERE unit_name = %s
|
||||||
|
""", (category_id,))
|
||||||
|
event_count_result = cursor.fetchone()
|
||||||
|
event_count = event_count_result['event_count'] if event_count_result else 0
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'auto_id': category['AUTO_ID'],
|
||||||
|
'id': category['ID'],
|
||||||
|
'title': category['TITLE'] or '',
|
||||||
|
'description': category['description'] or '',
|
||||||
|
'image_url': category['image_url'] or '',
|
||||||
|
'tg_id': category['tg_id'],
|
||||||
|
'is_tg_published': bool(category['is_tg_published']) if category['is_tg_published'] is not None else False,
|
||||||
|
'tg_published_date': category['tg_published_date'].isoformat() if category['tg_published_date'] else None,
|
||||||
|
'marked_for_publication': bool(category['marked_for_publication']) if category['marked_for_publication'] is not None else False,
|
||||||
|
'event_count': event_count
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/categories', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_create_category():
|
||||||
|
"""Создает новую категорию"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO categories (ID, TITLE, description, image_url, tg_id, marked_for_publication, is_tg_published, tg_published_date)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
data.get('id'),
|
||||||
|
data.get('title', ''),
|
||||||
|
data.get('description', ''),
|
||||||
|
data.get('image_url', ''),
|
||||||
|
data.get('tg_id'),
|
||||||
|
bool(data.get('marked_for_publication', False)),
|
||||||
|
bool(data.get('is_tg_published', False)),
|
||||||
|
data.get('tg_published_date') or None
|
||||||
|
))
|
||||||
|
db.conn.commit()
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Создана площадка: {data.get('id')}, IP {client_ip}")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/categories/<category_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
def api_update_category(category_id):
|
||||||
|
"""Обновляет категорию"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
new_id = data.get('id', '').strip()
|
||||||
|
if not new_id:
|
||||||
|
return jsonify({'success': False, 'error': 'ID is required'}), 400
|
||||||
|
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
# Сначала проверяем существование категории по старому ID
|
||||||
|
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (category_id,))
|
||||||
|
category = cursor.fetchone()
|
||||||
|
|
||||||
|
if not category:
|
||||||
|
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||||
|
|
||||||
|
auto_id = category['AUTO_ID']
|
||||||
|
|
||||||
|
# Если новый ID отличается от старого, проверяем, не занят ли он
|
||||||
|
if new_id != category_id:
|
||||||
|
cursor.execute("SELECT AUTO_ID FROM categories WHERE ID = %s", (new_id,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
if existing and existing['AUTO_ID'] != auto_id:
|
||||||
|
return jsonify({'success': False, 'error': f'Category with ID "{new_id}" already exists'}), 400
|
||||||
|
|
||||||
|
# Обновляем категорию, включая ID если он изменился
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE categories
|
||||||
|
SET ID = %s, TITLE = %s, description = %s, image_url = %s, tg_id = %s,
|
||||||
|
marked_for_publication = %s, is_tg_published = %s, tg_published_date = %s
|
||||||
|
WHERE AUTO_ID = %s
|
||||||
|
""", (
|
||||||
|
new_id,
|
||||||
|
data.get('title', ''),
|
||||||
|
data.get('description', ''),
|
||||||
|
data.get('image_url', ''),
|
||||||
|
data.get('tg_id'),
|
||||||
|
bool(data.get('marked_for_publication', False)),
|
||||||
|
bool(data.get('is_tg_published', False)),
|
||||||
|
data.get('tg_published_date') or None,
|
||||||
|
auto_id
|
||||||
|
))
|
||||||
|
db.conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Обновлена площадка: {category_id} -> {new_id}, IP {client_ip}")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/categories/<category_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
def api_delete_category(category_id):
|
||||||
|
"""Удаляет категорию"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
# Проверяем наличие привязанных событий
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) as event_count
|
||||||
|
FROM events
|
||||||
|
WHERE unit_name = %s
|
||||||
|
""", (category_id,))
|
||||||
|
event_count_result = cursor.fetchone()
|
||||||
|
event_count = event_count_result['event_count'] if event_count_result else 0
|
||||||
|
|
||||||
|
if event_count > 0:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Cannot delete category with linked events',
|
||||||
|
'event_count': event_count
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Получаем название категории для лога
|
||||||
|
cursor.execute("SELECT TITLE FROM categories WHERE ID = %s", (category_id,))
|
||||||
|
category = cursor.fetchone()
|
||||||
|
category_title = category['TITLE'] if category else category_id
|
||||||
|
|
||||||
|
cursor.execute("DELETE FROM categories WHERE ID = %s", (category_id,))
|
||||||
|
db.conn.commit()
|
||||||
|
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Удалена площадка: {category_title}, ID: {category_id}, IP {client_ip}")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/categories/<category_id>/toggle_publication', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_toggle_category_publication(category_id):
|
||||||
|
"""Переключает статус публикации категории"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
# Получаем текущее значение
|
||||||
|
cursor.execute("SELECT marked_for_publication FROM categories WHERE ID = %s", (category_id,))
|
||||||
|
category = cursor.fetchone()
|
||||||
|
if not category:
|
||||||
|
return jsonify({'success': False, 'error': 'Category not found'}), 404
|
||||||
|
|
||||||
|
# Переключаем значение
|
||||||
|
new_value = not bool(category['marked_for_publication'])
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE categories
|
||||||
|
SET marked_for_publication = %s
|
||||||
|
WHERE ID = %s
|
||||||
|
""", (new_value, category_id))
|
||||||
|
db.conn.commit()
|
||||||
|
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Изменен статус публикации площадки: {category_id}, новый статус: {new_value}, IP {client_ip}")
|
||||||
|
return jsonify({'success': True, 'marked_for_publication': new_value})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/categories/mark_all_for_publication', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_mark_all_categories_for_publication():
|
||||||
|
"""Отмечает все категории для публикации"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE categories
|
||||||
|
SET marked_for_publication = 1
|
||||||
|
WHERE marked_for_publication = 0
|
||||||
|
""")
|
||||||
|
count = cursor.rowcount
|
||||||
|
db.conn.commit()
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Отмечено для публикации: {count} площадок, IP {client_ip}")
|
||||||
|
return jsonify({'success': True, 'count': count})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/categories/<category_id>/events_count')
|
||||||
|
@login_required
|
||||||
|
def api_category_events_count(category_id):
|
||||||
|
"""Получает количество событий, привязанных к категории"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) as event_count
|
||||||
|
FROM events
|
||||||
|
WHERE unit_name = %s
|
||||||
|
""", (category_id,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
return jsonify({'count': result['event_count'] if result else 0})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/events/count')
|
||||||
|
@login_required
|
||||||
|
def api_events_count():
|
||||||
|
"""Получает количество событий в таблице events"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM events")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
return jsonify({'count': result['count'] if result else 0})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/run_volk_rescan', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_run_volk_rescan():
|
||||||
|
try:
|
||||||
|
# Проверка режима работы
|
||||||
|
workmode = os.getenv('WORKMODE', 'ZILANT')
|
||||||
|
if workmode != 'VOLK':
|
||||||
|
error_msg = "Рескан ВОЛК доступен только в режиме VOLK"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 403
|
||||||
|
|
||||||
|
log_event("Запуск рескана ВОЛК...")
|
||||||
|
load_all_events()
|
||||||
|
log_event("Рескан ВОЛК выполнен успешно")
|
||||||
|
return jsonify({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при рескане ВОЛК: {str(e)}"
|
||||||
|
log_event(error_msg)
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
|
||||||
@bp.route('/api/publish_event/<int:event_id>', methods=['POST'])
|
@bp.route('/api/publish_event/<int:event_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_publish_event(event_id):
|
def api_publish_event(event_id):
|
||||||
@@ -1171,11 +1724,21 @@ def api_get_linked_events(post_id):
|
|||||||
events = db.get_linked_events(post_id)
|
events = db.get_linked_events(post_id)
|
||||||
events_list = []
|
events_list = []
|
||||||
for event in events:
|
for event in events:
|
||||||
|
# Определяем статус события
|
||||||
|
status = "Не видим"
|
||||||
|
if event.get('is_posted_tg'):
|
||||||
|
status = "Опубликован"
|
||||||
|
elif event.get('marked_to_publication'):
|
||||||
|
status = "Для публикации"
|
||||||
|
elif event.get('is_visible'):
|
||||||
|
status = "Видим"
|
||||||
|
|
||||||
events_list.append({
|
events_list.append({
|
||||||
'id': event['id'],
|
'id': event['id'],
|
||||||
'name': event['name'],
|
'name': event['name'],
|
||||||
'number': event['number'],
|
'number': event['number'],
|
||||||
'unit_name': event['unit_name']
|
'unit_name': event['unit_name'],
|
||||||
|
'status': status
|
||||||
})
|
})
|
||||||
return jsonify(events_list)
|
return jsonify(events_list)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1191,11 +1754,19 @@ def api_get_linked_posts(event_id):
|
|||||||
posts = db.get_linked_posts(event_id)
|
posts = db.get_linked_posts(event_id)
|
||||||
posts_list = []
|
posts_list = []
|
||||||
for post in posts:
|
for post in posts:
|
||||||
|
# Определяем статус поста
|
||||||
|
status = "Не для публикации"
|
||||||
|
if post.get('published_in_tg'):
|
||||||
|
status = "Опубликован"
|
||||||
|
elif post.get('marked_for_publication'):
|
||||||
|
status = "Для публикации"
|
||||||
|
|
||||||
posts_list.append({
|
posts_list.append({
|
||||||
'id': post['id'],
|
'id': post['id'],
|
||||||
'vk_post_id': post['vk_post_id'],
|
'vk_post_id': post['vk_post_id'],
|
||||||
'shortname': post['shortname'],
|
'shortname': post['shortname'],
|
||||||
'text_preview': post['text'][:50] + '...' if post['text'] else ''
|
'text_preview': post['text'][:50] + '...' if post['text'] else '',
|
||||||
|
'status': status
|
||||||
})
|
})
|
||||||
return jsonify(posts_list)
|
return jsonify(posts_list)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1215,6 +1786,157 @@ def api_get_all_interlinks():
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# Новые API endpoints для работы с площадками
|
||||||
|
@bp.route('/api/venues')
|
||||||
|
@login_required
|
||||||
|
def api_venues():
|
||||||
|
"""Получает список всех уникальных площадок с количеством событий"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT unit_name as name, COUNT(*) as event_count
|
||||||
|
FROM events
|
||||||
|
WHERE unit_name IS NOT NULL AND unit_name != ''
|
||||||
|
GROUP BY unit_name
|
||||||
|
ORDER BY unit_name
|
||||||
|
""")
|
||||||
|
venues = cursor.fetchall()
|
||||||
|
|
||||||
|
venues_list = []
|
||||||
|
for venue in venues:
|
||||||
|
venues_list.append({
|
||||||
|
'name': venue['name'],
|
||||||
|
'event_count': venue['event_count']
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(venues_list)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/venues/<venue_name>/events')
|
||||||
|
@login_required
|
||||||
|
def api_venue_events(venue_name):
|
||||||
|
"""Получает список событий для указанной площадки с информацией о привязке к посту"""
|
||||||
|
post_id = request.args.get('post_id')
|
||||||
|
if not post_id:
|
||||||
|
return jsonify({'error': 'post_id parameter is required'}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
# Получаем события площадки с информацией о привязке к посту
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT e.id, e.name, e.number, e.unit_name,
|
||||||
|
CASE WHEN i.post_id IS NOT NULL THEN 1 ELSE 0 END as linked
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN interlinks i ON e.id = i.event_id AND i.post_id = %s
|
||||||
|
WHERE e.unit_name = %s
|
||||||
|
ORDER BY e.name
|
||||||
|
""", (post_id, venue_name))
|
||||||
|
events = cursor.fetchall()
|
||||||
|
|
||||||
|
events_list = []
|
||||||
|
for event in events:
|
||||||
|
events_list.append({
|
||||||
|
'id': event['id'],
|
||||||
|
'name': event['name'],
|
||||||
|
'number': event['number'],
|
||||||
|
'unit_name': event['unit_name'],
|
||||||
|
'linked': bool(event['linked'])
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(events_list)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# API endpoints для работы с тэгами
|
||||||
|
@bp.route('/api/tags')
|
||||||
|
@login_required
|
||||||
|
def api_tags():
|
||||||
|
"""Получает список всех уникальных тэгов с количеством событий"""
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
# Получаем все тэги и разбиваем их по пробелам
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT tags
|
||||||
|
FROM events
|
||||||
|
WHERE tags IS NOT NULL AND tags != ''
|
||||||
|
""")
|
||||||
|
events = cursor.fetchall()
|
||||||
|
|
||||||
|
# Собираем все уникальные тэги
|
||||||
|
tag_counts = {}
|
||||||
|
for event in events:
|
||||||
|
tags = event['tags'].split()
|
||||||
|
for tag in tags:
|
||||||
|
tag = tag.strip()
|
||||||
|
if tag:
|
||||||
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||||
|
|
||||||
|
# Сортируем по количеству событий (убывание), затем по названию
|
||||||
|
sorted_tags = sorted(tag_counts.items(), key=lambda x: (-x[1], x[0]))
|
||||||
|
|
||||||
|
tags_list = []
|
||||||
|
for tag, count in sorted_tags:
|
||||||
|
tags_list.append({
|
||||||
|
'name': tag,
|
||||||
|
'event_count': count
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(tags_list)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@bp.route('/api/tags/<tag_name>/events')
|
||||||
|
@login_required
|
||||||
|
def api_tag_events(tag_name):
|
||||||
|
"""Получает список событий для указанного тэга с информацией о привязке к посту"""
|
||||||
|
post_id = request.args.get('post_id')
|
||||||
|
if not post_id:
|
||||||
|
return jsonify({'error': 'post_id parameter is required'}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
# Получаем события с указанным тэгом с информацией о привязке к посту
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT e.id, e.name, e.number, e.unit_name, e.tags,
|
||||||
|
CASE WHEN i.post_id IS NOT NULL THEN 1 ELSE 0 END as linked
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN interlinks i ON e.id = i.event_id AND i.post_id = %s
|
||||||
|
WHERE e.tags LIKE %s
|
||||||
|
ORDER BY e.name
|
||||||
|
""", (post_id, f'%{tag_name}%'))
|
||||||
|
events = cursor.fetchall()
|
||||||
|
|
||||||
|
events_list = []
|
||||||
|
for event in events:
|
||||||
|
# Проверяем, что тэг действительно присутствует (не подстрока другого тэга)
|
||||||
|
tags = event['tags'].split() if event['tags'] else []
|
||||||
|
if tag_name in tags:
|
||||||
|
events_list.append({
|
||||||
|
'id': event['id'],
|
||||||
|
'name': event['name'],
|
||||||
|
'number': event['number'],
|
||||||
|
'unit_name': event['unit_name'],
|
||||||
|
'tags': event['tags'],
|
||||||
|
'linked': bool(event['linked'])
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(events_list)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
@bp.route('/image_proxy')
|
@bp.route('/image_proxy')
|
||||||
@login_required
|
@login_required
|
||||||
def image_proxy():
|
def image_proxy():
|
||||||
@@ -1286,6 +2008,29 @@ def edit_event_page(event_id):
|
|||||||
def add_event_page():
|
def add_event_page():
|
||||||
return render_template('edit_event.html', event_id=None)
|
return render_template('edit_event.html', event_id=None)
|
||||||
|
|
||||||
|
@bp.route('/edit_category/<category_id>')
|
||||||
|
@login_required
|
||||||
|
def edit_category_page(category_id):
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.conn.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT ID, TITLE FROM categories WHERE ID = %s", (category_id,))
|
||||||
|
category = cursor.fetchone()
|
||||||
|
if not category:
|
||||||
|
flash('Площадка не найдена', 'danger')
|
||||||
|
return redirect(url_for('zilant.index'))
|
||||||
|
|
||||||
|
client_ip = get_client_ip()
|
||||||
|
log_event(f"Открыта площадка на редактирование: {category['TITLE']}, ID: {category_id}, IP {client_ip}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return render_template('edit_category.html', category_id=category_id)
|
||||||
|
|
||||||
|
@bp.route('/add_category')
|
||||||
|
@login_required
|
||||||
|
def add_category_page():
|
||||||
|
return render_template('edit_category.html', category_id=None)
|
||||||
|
|
||||||
# Регистрируем Blueprint в приложении
|
# Регистрируем Blueprint в приложении
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
@@ -1297,51 +2042,6 @@ def init_db():
|
|||||||
ensure_database_structure()
|
ensure_database_structure()
|
||||||
print("Структура базы данных проверена и создана при необходимости")
|
print("Структура базы данных проверена и создана при необходимости")
|
||||||
|
|
||||||
# Проверяем и создаем таблицу events, если она не существует
|
|
||||||
# with db.conn.cursor() as cursor:
|
|
||||||
# cursor.execute("""
|
|
||||||
# CREATE TABLE IF NOT EXISTS events (
|
|
||||||
# id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
# id_event BIGINT NOT NULL,
|
|
||||||
# number BIGINT NOT NULL,
|
|
||||||
# unit_name VARCHAR(255),
|
|
||||||
# name VARCHAR(255),
|
|
||||||
# about VARCHAR(2048),
|
|
||||||
# about_social_picture VARCHAR(2048),
|
|
||||||
# modified DATETIME NULL,
|
|
||||||
# added DATETIME NULL,
|
|
||||||
# tags VARCHAR(255),
|
|
||||||
# is_canceled BOOLEAN DEFAULT 0,
|
|
||||||
# accepted BOOLEAN DEFAULT 0,
|
|
||||||
# denied BOOLEAN DEFAULT 0,
|
|
||||||
# is_visible BOOLEAN DEFAULT 0,
|
|
||||||
# marked_to_publication BOOLEAN DEFAULT 0,
|
|
||||||
# is_posted_tg BOOLEAN DEFAULT 0,
|
|
||||||
# tg_message_id BIGINT NULL DEFAULT NULL,
|
|
||||||
# tg_posted_date DATETIME NULL,
|
|
||||||
# announcement_link BIGINT NULL DEFAULT NULL
|
|
||||||
# ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
# """)
|
|
||||||
# db.conn.commit()
|
|
||||||
# print("Таблица events проверена и создана при необходимости")
|
|
||||||
|
|
||||||
# Создаем администратора по умолчанию
|
|
||||||
with db.conn.cursor() as cursor:
|
|
||||||
cursor.execute("SELECT COUNT(*) as count FROM users")
|
|
||||||
if cursor.fetchone()['count'] == 0:
|
|
||||||
username = os.getenv('DEFNM')
|
|
||||||
password = os.getenv('DEFPW')
|
|
||||||
if username and password:
|
|
||||||
hashed_password = generate_password_hash(password)
|
|
||||||
cursor.execute(
|
|
||||||
"INSERT INTO users (username, password) VALUES (%s, %s)",
|
|
||||||
(username, hashed_password)
|
|
||||||
)
|
|
||||||
print(f"Created default user: {username}")
|
|
||||||
else:
|
|
||||||
print("Environment variables DEFNM/DEFPW not set. Admin not created.")
|
|
||||||
|
|
||||||
db.conn.commit()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Database initialization error: {str(e)}")
|
print(f"Database initialization error: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
+149
-22
@@ -21,14 +21,88 @@ DB_CONFIG = {
|
|||||||
'charset': 'utf8mb4'
|
'charset': 'utf8mb4'
|
||||||
}
|
}
|
||||||
|
|
||||||
OPENROUTER_CONFIG = {
|
ROUTERAI_CONFIG = {
|
||||||
'api_key': os.getenv('OR_KEY'),
|
'api_key': os.getenv('RA_KEY'),
|
||||||
'model': os.getenv('OR_MODEL_NAME')
|
'model': os.getenv('RA_MODEL'),
|
||||||
|
'base_url': 'https://routerai.ru/api/v1',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Настройки журналирования
|
# Настройки журналирования
|
||||||
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
|
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
|
||||||
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
|
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
|
||||||
|
LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_for_log(value, max_len=None):
|
||||||
|
"""Обрезает длинные значения для журнала, не теряя информацию о размере."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||||
|
limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX
|
||||||
|
if len(value) <= limit:
|
||||||
|
return value
|
||||||
|
return f"{value[:limit]}... [обрезано, всего {len(value)} символов]"
|
||||||
|
|
||||||
|
|
||||||
|
def _message_fields_for_log(message):
|
||||||
|
"""Все значимые поля message из ответа chat/completions."""
|
||||||
|
if not message:
|
||||||
|
return {}
|
||||||
|
logged = {}
|
||||||
|
for key in (
|
||||||
|
'role', 'content', 'reasoning_content', 'reasoning',
|
||||||
|
'refusal', 'tool_calls', 'function_call', 'audio',
|
||||||
|
):
|
||||||
|
if key not in message:
|
||||||
|
continue
|
||||||
|
value = message.get(key)
|
||||||
|
if value in (None, '', [], {}):
|
||||||
|
logged[key] = value
|
||||||
|
elif isinstance(value, str):
|
||||||
|
logged[key] = _truncate_for_log(value)
|
||||||
|
else:
|
||||||
|
logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str))
|
||||||
|
return logged
|
||||||
|
|
||||||
|
|
||||||
|
def _log_routerai_request(text, prompt):
|
||||||
|
"""Журнал параметров исходящего запроса (без API-ключа)."""
|
||||||
|
log_message(
|
||||||
|
"Запрос RouterAI: "
|
||||||
|
f"model={ROUTERAI_CONFIG['model']}, "
|
||||||
|
f"text_len={len(text)}, prompt_len={len(prompt)}, "
|
||||||
|
f"text_preview={_truncate_for_log(text.strip(), 300)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_routerai_response(response, result=None, error_body=None):
|
||||||
|
"""Подробный журнал HTTP-ответа RouterAI."""
|
||||||
|
payload = {
|
||||||
|
"http_status": getattr(response, 'status_code', None),
|
||||||
|
"model": ROUTERAI_CONFIG.get('model'),
|
||||||
|
}
|
||||||
|
if result is not None:
|
||||||
|
payload["response_id"] = result.get("id")
|
||||||
|
payload["object"] = result.get("object")
|
||||||
|
payload["created"] = result.get("created")
|
||||||
|
payload["usage"] = result.get("usage")
|
||||||
|
payload["system_fingerprint"] = result.get("system_fingerprint")
|
||||||
|
choices = result.get("choices") or []
|
||||||
|
payload["choices_count"] = len(choices)
|
||||||
|
payload["choices"] = []
|
||||||
|
for i, choice in enumerate(choices):
|
||||||
|
payload["choices"].append({
|
||||||
|
"index": choice.get("index", i),
|
||||||
|
"finish_reason": choice.get("finish_reason"),
|
||||||
|
"message": _message_fields_for_log(choice.get("message") or {}),
|
||||||
|
"logprobs": choice.get("logprobs"),
|
||||||
|
})
|
||||||
|
if result.get("error"):
|
||||||
|
payload["api_error"] = result.get("error")
|
||||||
|
if error_body is not None:
|
||||||
|
payload["error_body"] = _truncate_for_log(error_body)
|
||||||
|
log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}")
|
||||||
|
|
||||||
def log_message(message, max_retries=5, retry_delay=0.1):
|
def log_message(message, max_retries=5, retry_delay=0.1):
|
||||||
"""
|
"""
|
||||||
@@ -59,11 +133,16 @@ def log_message(message, max_retries=5, retry_delay=0.1):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _generate_shortname_by_api(text):
|
def _generate_shortname_by_api(text):
|
||||||
"""Внутренняя функция для генерации краткого названия через OpenRouter API"""
|
"""Внутренняя функция для генерации краткого названия через RouterAI API"""
|
||||||
url = "https://openrouter.ai/api/v1/chat/completions"
|
if not ROUTERAI_CONFIG['api_key'] or not ROUTERAI_CONFIG['model']:
|
||||||
|
log_message("RA_KEY или RA_MODEL не заданы в .env")
|
||||||
|
print("Ошибка: RA_KEY или RA_MODEL не заданы в .env")
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{ROUTERAI_CONFIG['base_url']}/chat/completions"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {OPENROUTER_CONFIG['api_key']}",
|
"Authorization": f"Bearer {ROUTERAI_CONFIG['api_key']}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
@@ -78,14 +157,16 @@ def _generate_shortname_by_api(text):
|
|||||||
Название:"""
|
Название:"""
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"model": OPENROUTER_CONFIG['model'],
|
"model": ROUTERAI_CONFIG['model'],
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
"max_tokens": 5000,
|
"max_tokens": 5000,
|
||||||
"temperature": 0.3
|
"temperature": 0.3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_log_routerai_request(text, prompt)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(url, headers=headers, json=data, timeout=60)
|
response = requests.post(url, headers=headers, json=data, timeout=180)
|
||||||
|
|
||||||
# Обработка ошибки 429 (Too Many Requests)
|
# Обработка ошибки 429 (Too Many Requests)
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
@@ -98,27 +179,55 @@ def _generate_shortname_by_api(text):
|
|||||||
# Пытаемся распарсить JSON ответ
|
# Пытаемся распарсить JSON ответ
|
||||||
try:
|
try:
|
||||||
error_info["body"] = response.json()
|
error_info["body"] = response.json()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
_log_routerai_response(response, error_body=error_info.get("body"))
|
||||||
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
||||||
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
||||||
sys.exit(1)
|
# Не sys.exit — эта функция вызывается и из веб-API Flask
|
||||||
|
raise RuntimeError("RouterAI 429: превышен лимит запросов")
|
||||||
|
|
||||||
|
if not response.ok:
|
||||||
|
_log_routerai_response(response, error_body=response.text)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
|
_log_routerai_response(response, result=result)
|
||||||
|
|
||||||
# Извлекаем только текст ответа
|
# Извлекаем только текст ответа (у thinking-моделей ответ в content,
|
||||||
shortname = result['choices'][0]['message']['content'].strip()
|
# рассуждения — в reasoning_content / reasoning)
|
||||||
|
choices = result.get('choices') or []
|
||||||
|
if not choices:
|
||||||
|
log_message("RouterAI вернул пустой choices — название не извлечь")
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = choices[0].get('message') or {}
|
||||||
|
shortname = (message.get('content') or '').strip()
|
||||||
|
if not shortname:
|
||||||
|
reasoning = (message.get('reasoning_content') or message.get('reasoning') or '').strip()
|
||||||
|
log_message(
|
||||||
|
"Пустой content в ответе RouterAI; "
|
||||||
|
f"finish_reason={choices[0].get('finish_reason')!r}, "
|
||||||
|
f"message_keys={list(message.keys())}, "
|
||||||
|
f"reasoning_len={len(reasoning)}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# Удаляем возможные кавычки и лишние символы
|
# Удаляем возможные кавычки и лишние символы
|
||||||
shortname = re.sub(r'^["\']|["\']$', '', shortname)
|
shortname = re.sub(r'^["\']|["\']$', '', shortname)
|
||||||
|
# Если модель вернула многострочный ответ — берём первую непустую строку
|
||||||
|
for line in shortname.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
shortname = line
|
||||||
|
break
|
||||||
|
|
||||||
# Обрезаем до 80 символов
|
# Обрезаем до 80 символов
|
||||||
return shortname[:80]
|
return shortname[:80]
|
||||||
|
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response is not None and e.response.status_code == 429:
|
||||||
error_info = {
|
error_info = {
|
||||||
"status_code": e.response.status_code,
|
"status_code": e.response.status_code,
|
||||||
"headers": dict(e.response.headers),
|
"headers": dict(e.response.headers),
|
||||||
@@ -128,17 +237,35 @@ def _generate_shortname_by_api(text):
|
|||||||
# Пытаемся распарсить JSON ответ
|
# Пытаемся распарсить JSON ответ
|
||||||
try:
|
try:
|
||||||
error_info["body"] = e.response.json()
|
error_info["body"] = e.response.json()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
_log_routerai_response(e.response, error_body=error_info.get("body"))
|
||||||
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
||||||
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
||||||
sys.exit(1)
|
raise RuntimeError("RouterAI 429: превышен лимит запросов")
|
||||||
else:
|
else:
|
||||||
|
if e.response is not None:
|
||||||
|
try:
|
||||||
|
_log_routerai_response(e.response, error_body=e.response.text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
error_msg = f"HTTP ошибка при генерации названия: {e}"
|
error_msg = f"HTTP ошибка при генерации названия: {e}"
|
||||||
log_message(error_msg)
|
log_message(error_msg)
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
return None
|
return None
|
||||||
|
except requests.exceptions.Timeout as e:
|
||||||
|
error_msg = f"Таймаут запроса RouterAI ({e})"
|
||||||
|
log_message(error_msg)
|
||||||
|
print(error_msg)
|
||||||
|
return None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
error_msg = f"Сетевая ошибка RouterAI: {e}"
|
||||||
|
log_message(error_msg)
|
||||||
|
print(error_msg)
|
||||||
|
return None
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Ошибка при генерации названия: {e}"
|
error_msg = f"Ошибка при генерации названия: {e}"
|
||||||
log_message(error_msg)
|
log_message(error_msg)
|
||||||
@@ -228,10 +355,10 @@ def ai_shortname_all():
|
|||||||
|
|
||||||
log_message("Обработка всех записей завершена")
|
log_message("Обработка всех записей завершена")
|
||||||
print("Обработка завершена")
|
print("Обработка завершена")
|
||||||
except SystemExit:
|
except RuntimeError as e:
|
||||||
# Перехватываем системный выход для корректного закрытия соединения
|
# 429 и прочие явные ошибки RouterAI
|
||||||
log_message("Завершение работы из-за ошибки 429")
|
log_message(f"Завершение работы: {e}")
|
||||||
print("Завершение работы из-за ошибки 429")
|
print(f"Завершение работы: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Произошла ошибка при обработке записей: {e}"
|
error_msg = f"Произошла ошибка при обработке записей: {e}"
|
||||||
log_message(error_msg)
|
log_message(error_msg)
|
||||||
|
|||||||
@@ -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`).
|
||||||
+111
-9
@@ -6,6 +6,7 @@ import random
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from mysql.connector import errorcode
|
from mysql.connector import errorcode
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
# Загрузка переменных окружения
|
# Загрузка переменных окружения
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -45,6 +46,10 @@ def ensure_database_structure():
|
|||||||
conn = None
|
conn = None
|
||||||
cursor = None
|
cursor = None
|
||||||
|
|
||||||
|
# Проверка режима отладки
|
||||||
|
log_debug = os.getenv('LOG_DEBUG_DATA', 'false').lower() == 'true'
|
||||||
|
changes_made = False # Флаг для отслеживания изменений
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Подключение без указания базы данных
|
# Подключение без указания базы данных
|
||||||
conn = mysql.connector.connect(
|
conn = mysql.connector.connect(
|
||||||
@@ -55,9 +60,24 @@ def ensure_database_structure():
|
|||||||
cursor = conn.cursor(buffered=True) # Используем буферизованный курсор
|
cursor = conn.cursor(buffered=True) # Используем буферизованный курсор
|
||||||
|
|
||||||
# Создание базы данных если не существует
|
# Создание базы данных если не существует
|
||||||
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
# Проверяем существование базы данных
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM information_schema.schemata
|
||||||
|
WHERE schema_name = %s
|
||||||
|
""", (DB_CONFIG['database'],))
|
||||||
|
|
||||||
|
db_exists = cursor.fetchone()[0] > 0
|
||||||
|
|
||||||
|
if not db_exists:
|
||||||
|
cursor.execute(f"CREATE DATABASE {DB_CONFIG['database']} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
changes_made = True
|
||||||
|
log_message(f"Database {DB_CONFIG['database']} created")
|
||||||
|
else:
|
||||||
|
if log_debug:
|
||||||
|
log_message(f"Database {DB_CONFIG['database']} already exists")
|
||||||
|
|
||||||
cursor.execute(f"USE {DB_CONFIG['database']}")
|
cursor.execute(f"USE {DB_CONFIG['database']}")
|
||||||
log_message("Database checked/created successfully")
|
|
||||||
|
|
||||||
# Список таблиц и их структур
|
# Список таблиц и их структур
|
||||||
tables = {
|
tables = {
|
||||||
@@ -83,7 +103,8 @@ def ensure_database_structure():
|
|||||||
tg_poll_results TEXT DEFAULT '',
|
tg_poll_results TEXT DEFAULT '',
|
||||||
is_event BOOLEAN DEFAULT 0,
|
is_event BOOLEAN DEFAULT 0,
|
||||||
shortname VARCHAR(100),
|
shortname VARCHAR(100),
|
||||||
action_number BIGINT NULL
|
action_number BIGINT NULL,
|
||||||
|
auto_unit VARCHAR(255)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
''',
|
''',
|
||||||
'marks': '''
|
'marks': '''
|
||||||
@@ -122,8 +143,9 @@ def ensure_database_structure():
|
|||||||
number BIGINT NOT NULL,
|
number BIGINT NOT NULL,
|
||||||
unit_name VARCHAR(255),
|
unit_name VARCHAR(255),
|
||||||
name VARCHAR(255),
|
name VARCHAR(255),
|
||||||
about VARCHAR(2048),
|
about VARCHAR(4096),
|
||||||
about_social_picture VARCHAR(2048),
|
about_social_picture VARCHAR(2048),
|
||||||
|
authors VARCHAR(255),
|
||||||
modified DATETIME NULL,
|
modified DATETIME NULL,
|
||||||
added DATETIME NULL,
|
added DATETIME NULL,
|
||||||
tags VARCHAR(255),
|
tags VARCHAR(255),
|
||||||
@@ -145,6 +167,7 @@ def ensure_database_structure():
|
|||||||
post_id INT NOT NULL,
|
post_id INT NOT NULL,
|
||||||
event_id INT NOT NULL,
|
event_id INT NOT NULL,
|
||||||
created_at DATETIME NULL,
|
created_at DATETIME NULL,
|
||||||
|
auto_added BOOLEAN DEFAULT 0,
|
||||||
FOREIGN KEY (post_id)
|
FOREIGN KEY (post_id)
|
||||||
REFERENCES posts(id)
|
REFERENCES posts(id)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE,
|
||||||
@@ -152,6 +175,19 @@ def ensure_database_structure():
|
|||||||
REFERENCES events(id)
|
REFERENCES events(id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
''',
|
||||||
|
'categories': '''
|
||||||
|
CREATE TABLE categories (
|
||||||
|
AUTO_ID INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ID VARCHAR(255),
|
||||||
|
TITLE VARCHAR(2048),
|
||||||
|
description TEXT,
|
||||||
|
tg_id BIGINT NULL,
|
||||||
|
image_url VARCHAR(2048),
|
||||||
|
is_tg_published BOOLEAN DEFAULT 0,
|
||||||
|
tg_published_date DATETIME NULL,
|
||||||
|
marked_for_publication BOOLEAN DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,16 +204,42 @@ def ensure_database_structure():
|
|||||||
if cursor.fetchone()[0] == 0:
|
if cursor.fetchone()[0] == 0:
|
||||||
# Таблица не существует, создаем её
|
# Таблица не существует, создаем её
|
||||||
cursor.execute(table_sql)
|
cursor.execute(table_sql)
|
||||||
|
changes_made = True
|
||||||
log_message(f"Table {table_name} created")
|
log_message(f"Table {table_name} created")
|
||||||
else:
|
else:
|
||||||
log_message(f"Table {table_name} exists")
|
if log_debug:
|
||||||
|
log_message(f"Table {table_name} exists")
|
||||||
|
|
||||||
except mysql.connector.Error as err:
|
except mysql.connector.Error as err:
|
||||||
log_message(f"Error checking/creating table {table_name}: {err}")
|
log_message(f"Error checking/creating table {table_name}: {err}")
|
||||||
raise err
|
raise err
|
||||||
|
|
||||||
|
# Создание дефолтного админа, если таблица users пуста
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT COUNT(*) as count FROM users")
|
||||||
|
user_count = cursor.fetchone()[0]
|
||||||
|
if user_count == 0:
|
||||||
|
username = os.getenv('DEFNM')
|
||||||
|
password = os.getenv('DEFPW')
|
||||||
|
if username and password:
|
||||||
|
hashed_password = generate_password_hash(password)
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO users (username, password) VALUES (%s, %s)",
|
||||||
|
(username, hashed_password)
|
||||||
|
)
|
||||||
|
changes_made = True
|
||||||
|
log_message(f"Created default admin user: {username}")
|
||||||
|
else:
|
||||||
|
# Важное предупреждение - всегда логируем
|
||||||
|
log_message("Environment variables DEFNM/DEFPW not set. Admin not created.")
|
||||||
|
except mysql.connector.Error as err:
|
||||||
|
log_message(f"Error creating default admin: {err}")
|
||||||
|
|
||||||
# Проверка и добавление новых полей в существующие таблицы
|
# Проверка и добавление новых полей в существующие таблицы
|
||||||
table_alterations = {
|
table_alterations = {
|
||||||
|
'posts': [
|
||||||
|
{'column': 'auto_unit', 'type': 'VARCHAR(255)'}
|
||||||
|
],
|
||||||
'marks': [
|
'marks': [
|
||||||
{'column': 'autolink', 'type': 'BOOLEAN DEFAULT 0'},
|
{'column': 'autolink', 'type': 'BOOLEAN DEFAULT 0'},
|
||||||
{'column': 'reminded_at', 'type': 'DATETIME NULL'}
|
{'column': 'reminded_at', 'type': 'DATETIME NULL'}
|
||||||
@@ -185,6 +247,20 @@ def ensure_database_structure():
|
|||||||
'marks_evt': [
|
'marks_evt': [
|
||||||
{'column': 'autolink', 'type': 'BOOLEAN DEFAULT 0'},
|
{'column': 'autolink', 'type': 'BOOLEAN DEFAULT 0'},
|
||||||
{'column': 'reminded_at', 'type': 'DATETIME NULL'}
|
{'column': 'reminded_at', 'type': 'DATETIME NULL'}
|
||||||
|
],
|
||||||
|
'interlinks': [
|
||||||
|
{'column': 'auto_added', 'type': 'BOOLEAN DEFAULT 0'}
|
||||||
|
],
|
||||||
|
'categories': [
|
||||||
|
{'column': 'description', 'type': 'TEXT'},
|
||||||
|
{'column': 'tg_id', 'type': 'BIGINT NULL'},
|
||||||
|
{'column': 'image_url', 'type': 'VARCHAR(2048)'},
|
||||||
|
{'column': 'is_tg_published', 'type': 'BOOLEAN DEFAULT 0'},
|
||||||
|
{'column': 'tg_published_date', 'type': 'DATETIME NULL'},
|
||||||
|
{'column': 'marked_for_publication', 'type': 'BOOLEAN DEFAULT 0'}
|
||||||
|
],
|
||||||
|
'events': [
|
||||||
|
{'column': 'authors', 'type': 'VARCHAR(255)'}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +280,11 @@ def ensure_database_structure():
|
|||||||
if cursor.fetchone()[0] == 0:
|
if cursor.fetchone()[0] == 0:
|
||||||
# Столбец не существует, добавляем его
|
# Столбец не существует, добавляем его
|
||||||
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column} {column_type}")
|
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {column} {column_type}")
|
||||||
|
changes_made = True
|
||||||
log_message(f"Added column {column} to table {table_name}")
|
log_message(f"Added column {column} to table {table_name}")
|
||||||
else:
|
else:
|
||||||
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:
|
except mysql.connector.Error as err:
|
||||||
log_message(f"Error checking/adding column {column} to table {table_name}: {err}")
|
log_message(f"Error checking/adding column {column} to table {table_name}: {err}")
|
||||||
@@ -246,18 +324,42 @@ def ensure_database_structure():
|
|||||||
'CREATE UNIQUE INDEX IF NOT EXISTS uniq_post_event ON interlinks (post_id, event_id)'
|
'CREATE UNIQUE INDEX IF NOT EXISTS uniq_post_event ON interlinks (post_id, event_id)'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Создание индексов для таблицы categories
|
||||||
|
categories_indexes = [
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_categories_id ON categories (ID)'
|
||||||
|
]
|
||||||
|
|
||||||
# Объединяем все индексы
|
# Объединяем все индексы
|
||||||
all_indexes = marks_indexes + marks_evt_indexes + events_indexes + interlinks_indexes
|
all_indexes = marks_indexes + marks_evt_indexes + events_indexes + interlinks_indexes + categories_indexes
|
||||||
|
|
||||||
for index_sql in all_indexes:
|
for index_sql in all_indexes:
|
||||||
try:
|
try:
|
||||||
cursor.execute(index_sql)
|
# Извлекаем имя индекса и таблицы из SQL
|
||||||
index_name = index_sql.split('IF NOT EXISTS')[1].split(' ON ')[0].strip()
|
index_name = index_sql.split('IF NOT EXISTS')[1].split(' ON ')[0].strip()
|
||||||
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:
|
except mysql.connector.Error as err:
|
||||||
log_message(f"Error creating index: {err}")
|
log_message(f"Error creating index: {err}")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
# Финальное сообщение выводится всегда
|
||||||
log_message("Database structure ensured successfully")
|
log_message("Database structure ensured successfully")
|
||||||
|
|
||||||
except mysql.connector.Error as err:
|
except mysql.connector.Error as err:
|
||||||
|
|||||||
+640
-118
@@ -4,11 +4,20 @@ import asyncio
|
|||||||
import pymysql
|
import pymysql
|
||||||
import time
|
import time
|
||||||
import html
|
import html
|
||||||
|
import httpx
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from telegram import Bot, InlineKeyboardMarkup, InlineKeyboardButton
|
from urllib.parse import urlparse, unquote
|
||||||
from telegram.error import TelegramError, RetryAfter
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from formatter import get_event_text
|
from formatter import get_event_text
|
||||||
|
from telegram_relay import (
|
||||||
|
bot_api_method_url,
|
||||||
|
EDIT_LINKS_INITIAL_DELAY_SEC,
|
||||||
|
EDIT_LINKS_MAX_ATTEMPTS,
|
||||||
|
EDIT_LINKS_RETRY_INTERVAL_SEC,
|
||||||
|
published_message_has_expected_links,
|
||||||
|
extract_text_link_urls,
|
||||||
|
)
|
||||||
|
from season_links import subscription_start_link
|
||||||
|
|
||||||
# Загрузка переменных окружения
|
# Загрузка переменных окружения
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -25,11 +34,18 @@ DESC_PREFIX = os.getenv('DESC_PREFIX')
|
|||||||
PZK_PREFIX = os.getenv('PZK_PREFIX')
|
PZK_PREFIX = os.getenv('PZK_PREFIX')
|
||||||
LOG_FILE = os.getenv('LOG_FILE', 'evtg_publisher.log')
|
LOG_FILE = os.getenv('LOG_FILE', 'evtg_publisher.log')
|
||||||
EVENT_POST_DELAY = int(os.getenv('EVENT_POST_DELAY', 0)) # Задержка в минутах
|
EVENT_POST_DELAY = int(os.getenv('EVENT_POST_DELAY', 0)) # Задержка в минутах
|
||||||
|
WORKMODE = os.getenv('WORKMODE', 'ZILANT') # Режим работы: ZILANT или VOLK
|
||||||
|
|
||||||
# Параметры длины сообщений
|
# Параметры длины сообщений
|
||||||
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
|
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
|
||||||
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
|
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
|
||||||
|
|
||||||
|
# Режим публикации без звука
|
||||||
|
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
# Использование бота подписки
|
||||||
|
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logger = logging.getLogger('TG_EV_post')
|
logger = logging.getLogger('TG_EV_post')
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -54,6 +70,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
|
|||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Класс для обработки RetryAfter (FloodWait 429)
|
||||||
|
class RetryAfterException(Exception):
|
||||||
|
"""Исключение для обработки FloodWait (429) от Telegram API"""
|
||||||
|
def __init__(self, retry_after):
|
||||||
|
self.retry_after = retry_after
|
||||||
|
super().__init__(f"RetryAfter: {retry_after}")
|
||||||
|
|
||||||
def format_tags(tags_str):
|
def format_tags(tags_str):
|
||||||
"""Форматирование тегов с добавлением #"""
|
"""Форматирование тегов с добавлением #"""
|
||||||
if not tags_str:
|
if not tags_str:
|
||||||
@@ -77,14 +100,188 @@ def prepare_text(text):
|
|||||||
|
|
||||||
return text
|
return text
|
||||||
|
|
||||||
async def tg_post_event(bot, event_data):
|
|
||||||
|
def _filename_from_image_url(image_url):
|
||||||
|
path = unquote(urlparse(image_url).path)
|
||||||
|
name = os.path.basename(path) or "image.jpg"
|
||||||
|
if "." not in name:
|
||||||
|
name = f"{name}.jpg"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
async def download_image(image_url, httpx_client):
|
||||||
|
"""Скачивает картинку самим сервером (как tg_publish), не отдаёт URL Telegram.
|
||||||
|
|
||||||
|
sendPhoto с photo=http://... Telegram качает сам и часто отвечает
|
||||||
|
«failed to get HTTP URL content» (http, недоступность с DC Telegram).
|
||||||
|
"""
|
||||||
|
logger.info(f"Скачивание изображения: {image_url}")
|
||||||
|
started = time.time()
|
||||||
|
response = await httpx_client.get(
|
||||||
|
image_url,
|
||||||
|
timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
image_data = response.content
|
||||||
|
content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
|
||||||
|
if not content_type.startswith("image/"):
|
||||||
|
content_type = "image/jpeg"
|
||||||
|
logger.info(
|
||||||
|
f"Изображение скачано за {time.time() - started:.2f} с, "
|
||||||
|
f"размер: {len(image_data)} байт, type={content_type}"
|
||||||
|
)
|
||||||
|
return image_data, content_type, _filename_from_image_url(image_url)
|
||||||
|
|
||||||
|
|
||||||
|
async def _edit_event_add_links(
|
||||||
|
httpx_client, has_image, message_id, final_text, entity_label, expected_urls
|
||||||
|
):
|
||||||
|
"""Edit caption/text со ссылками; повтор до подтверждения всех expected_urls в text_link."""
|
||||||
|
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
|
||||||
|
method = "editMessageCaption" if has_image else "editMessageText"
|
||||||
|
field = "caption" if has_image else "text"
|
||||||
|
entities_field = "caption_entities" if has_image else "entities"
|
||||||
|
api_url = bot_api_method_url(BOT_TOKEN, method)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Попытка редактирования {entity_label}: message_id={message_id}, "
|
||||||
|
f"final_text_length={len(final_text)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
|
||||||
|
payload = {
|
||||||
|
'chat_id': CHANNEL_ID,
|
||||||
|
'message_id': message_id,
|
||||||
|
field: final_text,
|
||||||
|
'parse_mode': 'HTML',
|
||||||
|
}
|
||||||
|
if not has_image:
|
||||||
|
payload['disable_web_page_preview'] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await httpx_client.post(api_url, json=payload)
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
|
||||||
|
logger.error(
|
||||||
|
f"Таймаут edit ссылок для {entity_label} после "
|
||||||
|
f"{EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logger.warning(
|
||||||
|
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get('ok'):
|
||||||
|
result = data.get('result') or {}
|
||||||
|
result_text = result.get(field) or ""
|
||||||
|
entities = result.get(entities_field) or []
|
||||||
|
if published_message_has_expected_links(entities, expected_urls):
|
||||||
|
logger.info(
|
||||||
|
f"Ссылки добавлены для {entity_label} "
|
||||||
|
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
found = extract_text_link_urls(entities)
|
||||||
|
logger.warning(
|
||||||
|
f"edit ok, но footer-ссылки не подтверждены "
|
||||||
|
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}; "
|
||||||
|
f"ожидались {expected_urls}, найдены text_link: {found}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_code = data.get('error_code')
|
||||||
|
error_description = (data.get('description') or '').lower()
|
||||||
|
if error_code == 429:
|
||||||
|
retry_after = data.get('parameters', {}).get('retry_after', 60)
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
if error_code == 400 and "not modified" in error_description:
|
||||||
|
logger.info(
|
||||||
|
f"Caption уже содержит ссылки для {entity_label} "
|
||||||
|
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
f"API error edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||||
|
f"для {entity_label}: {data.get('description')}"
|
||||||
|
)
|
||||||
|
elif response.status_code == 429:
|
||||||
|
retry_after = 60
|
||||||
|
try:
|
||||||
|
retry_after = response.json().get('parameters', {}).get('retry_after', 60)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
elif response.status_code == 400:
|
||||||
|
try:
|
||||||
|
error_description = (response.json().get('description') or '').lower()
|
||||||
|
if "not modified" in error_description:
|
||||||
|
logger.info(
|
||||||
|
f"Caption уже содержит ссылки для {entity_label} "
|
||||||
|
f"(message is not modified HTTP 400, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||||
|
f"для {entity_label}: {response.text[:300]}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||||
|
f"для {entity_label}: {response.text[:300]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"HTTP {response.status_code} edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||||
|
f"для {entity_label}: {response.text[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
|
||||||
|
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось добавить строку ссылок для {entity_label} "
|
||||||
|
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def tg_post_event(httpx_client, event_data):
|
||||||
"""Публикация одного события"""
|
"""Публикация одного события"""
|
||||||
try:
|
try:
|
||||||
# Форматирование текста
|
# Форматирование текста
|
||||||
tags = format_tags(event_data['tags'])
|
tags = format_tags(event_data['tags'])
|
||||||
unit_name = prepare_text(event_data['unit_name'] or "")
|
unit_name_raw = event_data.get('unit_name') or ""
|
||||||
name = prepare_text(event_data['name'] or "")
|
name = prepare_text(event_data['name'] or "")
|
||||||
|
|
||||||
|
# Формируем название площадки с возможной ссылкой
|
||||||
|
unit_name = ""
|
||||||
|
if unit_name_raw:
|
||||||
|
unit_tg_id = event_data.get('unit_tg_id')
|
||||||
|
unit_title = event_data.get('unit_title') or unit_name_raw
|
||||||
|
unit_title_escaped = prepare_text(unit_title)
|
||||||
|
|
||||||
|
if unit_tg_id:
|
||||||
|
# Если площадка опубликована, делаем ссылку на сообщение
|
||||||
|
# Используем логику из format_channel_link из tg_mainbot.py
|
||||||
|
if CHANNEL_ID.startswith('@'):
|
||||||
|
# Публичный канал
|
||||||
|
channel_link = f"https://t.me/{CHANNEL_ID[1:]}/{unit_tg_id}"
|
||||||
|
elif CHANNEL_ID.startswith('-100'):
|
||||||
|
# Приватный канал с префиксом -100
|
||||||
|
channel_link = f"https://t.me/c/{CHANNEL_ID[4:]}/{unit_tg_id}"
|
||||||
|
else:
|
||||||
|
# Приватный канал без префикса или другой формат
|
||||||
|
channel_link = f"https://t.me/c/{CHANNEL_ID}/{unit_tg_id}"
|
||||||
|
|
||||||
|
# Формируем ссылку на сообщение
|
||||||
|
unit_name = f'<a href="{channel_link}">{unit_title_escaped}</a>'
|
||||||
|
else:
|
||||||
|
# Если площадка не опубликована, просто текст
|
||||||
|
unit_name = unit_title_escaped
|
||||||
|
|
||||||
# Получаем и подготавливаем описание события
|
# Получаем и подготавливаем описание события
|
||||||
about_raw = event_data['about']
|
about_raw = event_data['about']
|
||||||
|
|
||||||
@@ -102,8 +299,23 @@ async def tg_post_event(bot, event_data):
|
|||||||
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
|
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
|
||||||
number = event_data['number']
|
number = event_data['number']
|
||||||
placeholder_message_id = '0' * 20 # Заполнитель для message_id
|
placeholder_message_id = '0' * 20 # Заполнитель для message_id
|
||||||
links_line_placeholder = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{placeholder_message_id}">Подписка</a>'
|
|
||||||
links_line_length = len(links_line_placeholder)
|
# Формируем ссылки в зависимости от USE_SUBSCRIPTION_BOT и наличия DESC_PREFIX
|
||||||
|
links_parts = []
|
||||||
|
if DESC_PREFIX:
|
||||||
|
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
|
||||||
|
if USE_SUBSCRIPTION_BOT:
|
||||||
|
links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
|
||||||
|
links_parts.append(
|
||||||
|
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", placeholder_message_id)}">Подписка</a>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if links_parts:
|
||||||
|
links_line_placeholder = ' | '.join(links_parts)
|
||||||
|
links_line_length = len(links_line_placeholder)
|
||||||
|
else:
|
||||||
|
links_line_placeholder = ""
|
||||||
|
links_line_length = 0
|
||||||
|
|
||||||
# Определяем максимальную длину в зависимости от типа сообщения
|
# Определяем максимальную длину в зависимости от типа сообщения
|
||||||
image_url = event_data['about_social_picture']
|
image_url = event_data['about_social_picture']
|
||||||
@@ -121,82 +333,275 @@ async def tg_post_event(bot, event_data):
|
|||||||
# Формируем первоначальный текст
|
# Формируем первоначальный текст
|
||||||
initial_text = f"{base_text}\n\n{about}" if about else base_text
|
initial_text = f"{base_text}\n\n{about}" if about else base_text
|
||||||
|
|
||||||
# Логируем текст для отладки
|
# Логируем детальную информацию о данных для публикации
|
||||||
logger.info(f"Текст для публикации события номер {event_data['number']}: {initial_text}")
|
logger.info(f"=== Детали публикации события номер {event_data['number']} ===")
|
||||||
|
logger.info(f"Длина initial_text: {len(initial_text)} символов")
|
||||||
|
logger.info(f"Длина base_text: {base_text_length} символов")
|
||||||
|
logger.info(f"Длина about: {len(about)} символов")
|
||||||
|
logger.info(f"Длина links_line_placeholder: {links_line_length} символов")
|
||||||
|
logger.info(f"Максимальная длина (max_length): {max_length} символов")
|
||||||
|
logger.info(f"Доступная длина для about: {available_length} символов")
|
||||||
|
logger.info(f"Есть изображение: {has_image}")
|
||||||
|
if has_image:
|
||||||
|
logger.info(f"URL изображения: {image_url}")
|
||||||
|
logger.info(f"Длина URL изображения: {len(image_url)} символов")
|
||||||
|
logger.info(f"Текст для публикации: {initial_text[:200]}..." if len(initial_text) > 200 else f"Текст для публикации: {initial_text}")
|
||||||
|
|
||||||
# Публикация первоначального сообщения без ссылок
|
# Публикация первоначального сообщения без ссылок
|
||||||
|
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
|
||||||
|
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
|
||||||
|
message_is_photo = False
|
||||||
|
|
||||||
if has_image:
|
if has_image:
|
||||||
try:
|
try:
|
||||||
message = await bot.send_photo(
|
logger.info(
|
||||||
chat_id=CHANNEL_ID,
|
f"Попытка отправки фото для события {event_data['number']}: "
|
||||||
photo=image_url,
|
f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
|
||||||
caption=initial_text,
|
f"image_url={image_url}"
|
||||||
parse_mode="HTML",
|
|
||||||
disable_notification=True
|
|
||||||
)
|
)
|
||||||
except TelegramError as e:
|
image_data, content_type, filename = await download_image(image_url, httpx_client)
|
||||||
logger.warning(f"Не удалось отправить изображение для события номер {event_data['number']}: {e}. Отправляем текстовое сообщение.")
|
photo_data = {
|
||||||
message = await bot.send_message(
|
'chat_id': CHANNEL_ID,
|
||||||
chat_id=CHANNEL_ID,
|
'caption': initial_text,
|
||||||
text=initial_text,
|
'parse_mode': 'HTML',
|
||||||
parse_mode="HTML",
|
'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
|
||||||
disable_notification=True,
|
}
|
||||||
disable_web_page_preview=True # Отключаем предпросмотр ссылок
|
photo_files = {
|
||||||
|
'photo': (filename, image_data, content_type),
|
||||||
|
}
|
||||||
|
photo_response = await httpx_client.post(
|
||||||
|
send_photo_url, data=photo_data, files=photo_files
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
message = await bot.send_message(
|
|
||||||
chat_id=CHANNEL_ID,
|
|
||||||
text=initial_text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
disable_notification=True,
|
|
||||||
disable_web_page_preview=True # Отключаем предпросмотр ссылок
|
|
||||||
)
|
|
||||||
|
|
||||||
# Получаем ID сообщения для использования в ссылке подписки
|
if photo_response.status_code == 200:
|
||||||
message_id = message.message_id
|
photo_result = photo_response.json()
|
||||||
|
if photo_result.get('ok'):
|
||||||
|
message_data = photo_result.get('result', {})
|
||||||
|
message_id = message_data.get('message_id')
|
||||||
|
message_is_photo = True
|
||||||
|
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
|
||||||
|
else:
|
||||||
|
# Ошибка в ответе API - пробуем отправить текстовое сообщение
|
||||||
|
error_code = photo_result.get('error_code')
|
||||||
|
if error_code == 429:
|
||||||
|
# FloodWait - пробрасываем как RetryAfter
|
||||||
|
retry_after = photo_result.get('parameters', {}).get('retry_after', 60)
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
raise Exception(f"API error: {photo_result.get('description', 'Unknown error')}")
|
||||||
|
else:
|
||||||
|
if photo_response.status_code == 429:
|
||||||
|
# FloodWait
|
||||||
|
retry_after = 60
|
||||||
|
try:
|
||||||
|
error_data = photo_response.json()
|
||||||
|
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
raise Exception(f"HTTP {photo_response.status_code}: {photo_response.text}")
|
||||||
|
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
logger.error(f"ТАЙМАУТ при отправке изображения для события номер {event_data['number']}")
|
||||||
|
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
|
logger.error(f" Параметры запроса:")
|
||||||
|
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||||
|
logger.error(f" image_url: {image_url}")
|
||||||
|
logger.error(f" caption_length: {len(initial_text)}")
|
||||||
|
logger.error(f" parse_mode: HTML")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if error_msg.startswith("RetryAfter:"):
|
||||||
|
retry_after = int(error_msg.split(":")[1])
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
logger.warning(f"Не удалось отправить изображение для события номер {event_data['number']}: {e}. Отправляем текстовое сообщение.")
|
||||||
|
logger.error(f"Полная информация об ошибке Telegram при отправке изображения для события {event_data['number']}:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
|
logger.info(f"Попытка отправки текстового сообщения вместо фото для события {event_data['number']}")
|
||||||
|
try:
|
||||||
|
text_payload = {
|
||||||
|
'chat_id': CHANNEL_ID,
|
||||||
|
'text': initial_text,
|
||||||
|
'parse_mode': 'HTML',
|
||||||
|
'disable_notification': PUBLISH_SILENTLY,
|
||||||
|
'disable_web_page_preview': True
|
||||||
|
}
|
||||||
|
text_response = await httpx_client.post(send_message_url, json=text_payload)
|
||||||
|
|
||||||
|
if text_response.status_code == 200:
|
||||||
|
text_result = text_response.json()
|
||||||
|
if text_result.get('ok'):
|
||||||
|
message_data = text_result.get('result', {})
|
||||||
|
message_id = message_data.get('message_id')
|
||||||
|
logger.info(f"Текстовое сообщение успешно отправлено для события {event_data['number']}, message_id={message_id}")
|
||||||
|
else:
|
||||||
|
error_code = text_result.get('error_code')
|
||||||
|
if error_code == 429:
|
||||||
|
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
|
||||||
|
else:
|
||||||
|
if text_response.status_code == 429:
|
||||||
|
retry_after = 60
|
||||||
|
try:
|
||||||
|
error_data = text_response.json()
|
||||||
|
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для события номер {event_data['number']}")
|
||||||
|
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
|
logger.error(f" Параметры запроса:")
|
||||||
|
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||||
|
logger.error(f" text_length: {len(initial_text)}")
|
||||||
|
logger.error(f" parse_mode: HTML")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if error_msg.startswith("RetryAfter:"):
|
||||||
|
raise
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
logger.info(f"Попытка отправки текстового сообщения для события {event_data['number']}: chat_id={CHANNEL_ID}, text_length={len(initial_text)}")
|
||||||
|
try:
|
||||||
|
text_payload = {
|
||||||
|
'chat_id': CHANNEL_ID,
|
||||||
|
'text': initial_text,
|
||||||
|
'parse_mode': 'HTML',
|
||||||
|
'disable_notification': PUBLISH_SILENTLY,
|
||||||
|
'disable_web_page_preview': True
|
||||||
|
}
|
||||||
|
text_response = await httpx_client.post(send_message_url, json=text_payload)
|
||||||
|
|
||||||
|
if text_response.status_code == 200:
|
||||||
|
text_result = text_response.json()
|
||||||
|
if text_result.get('ok'):
|
||||||
|
message_data = text_result.get('result', {})
|
||||||
|
message_id = message_data.get('message_id')
|
||||||
|
logger.info(f"Текстовое сообщение успешно отправлено для события {event_data['number']}, message_id={message_id}")
|
||||||
|
else:
|
||||||
|
error_code = text_result.get('error_code')
|
||||||
|
if error_code == 429:
|
||||||
|
retry_after = text_result.get('parameters', {}).get('retry_after', 60)
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
raise Exception(f"API error: {text_result.get('description', 'Unknown error')}")
|
||||||
|
else:
|
||||||
|
if text_response.status_code == 429:
|
||||||
|
retry_after = 60
|
||||||
|
try:
|
||||||
|
error_data = text_response.json()
|
||||||
|
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
raise Exception(f"RetryAfter:{retry_after}")
|
||||||
|
raise Exception(f"HTTP {text_response.status_code}: {text_response.text}")
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
logger.error(f"ТАЙМАУТ при отправке текстового сообщения для события номер {event_data['number']}")
|
||||||
|
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
|
logger.error(f" Параметры запроса:")
|
||||||
|
logger.error(f" chat_id: {CHANNEL_ID}")
|
||||||
|
logger.error(f" text_length: {len(initial_text)}")
|
||||||
|
logger.error(f" parse_mode: HTML")
|
||||||
|
raise
|
||||||
|
|
||||||
# Формируем финальный текст с ссылками
|
# Формируем финальный текст с ссылками
|
||||||
links_line = f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{message_id}">Подписка</a>'
|
links_parts = []
|
||||||
final_text = f"{initial_text}\n\n{links_line}"
|
if DESC_PREFIX:
|
||||||
|
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
|
||||||
await asyncio.sleep(2)
|
if USE_SUBSCRIPTION_BOT:
|
||||||
|
links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
|
||||||
# Редактируем сообщение, добавляя ссылки
|
links_parts.append(
|
||||||
if has_image:
|
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)}">Подписка</a>'
|
||||||
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 # Отключаем предпросмотр ссылок
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
return message_id # Возвращаем только message_id
|
||||||
|
|
||||||
except RetryAfter as e:
|
except httpx.TimeoutException as e:
|
||||||
# Обработка ошибки FloodWait (429)
|
logger.error(f"ТАЙМАУТ при публикации события номер {event_data['number']}")
|
||||||
logger.error(f"Получена ошибка FloodWait (429) при публикации события номер {event_data['number']}: {e}")
|
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
|
||||||
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
raise # Пробрасываем исключение выше для обработки на уровне цикла
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
except TelegramError as 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']}: {e}")
|
||||||
|
logger.error(f"Полная информация об ошибке Telegram для события {event_data['number']}:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Неожиданная ошибка при публикации события номер {event_data['number']}: {e}")
|
logger.error(f"Неожиданная ошибка при публикации события номер {event_data['number']}: {e}")
|
||||||
@@ -213,60 +618,147 @@ async def tg_post_event_by_id(id_event):
|
|||||||
password=MDB_PW,
|
password=MDB_PW,
|
||||||
database=MDBASE,
|
database=MDBASE,
|
||||||
charset='utf8mb4',
|
charset='utf8mb4',
|
||||||
cursorclass=pymysql.cursors.DictCursor
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
|
||||||
)
|
)
|
||||||
|
|
||||||
# Вычисляем время, до которого должно быть изменено событие для публикации
|
# Проверка времени изменения только в режиме ZILANT
|
||||||
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
|
||||||
|
|
||||||
with conn.cursor() as cursor:
|
with conn.cursor() as cursor:
|
||||||
cursor.execute("""
|
if WORKMODE == "ZILANT":
|
||||||
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
|
# Вычисляем время, до которого должно быть изменено событие для публикации
|
||||||
FROM events
|
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
||||||
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:
|
cursor.execute("""
|
||||||
logger.info(f"Событие ID {id_event} не найдено, уже опубликовано или изменено менее чем {EVENT_POST_DELAY} минут назад")
|
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
|
||||||
return
|
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
|
||||||
|
else:
|
||||||
|
# В режиме VOLK проверка времени не выполняется
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id_event, number, tags, unit_name, name, about, about_social_picture, tg_message_id
|
||||||
|
FROM events
|
||||||
|
WHERE id_event = %s
|
||||||
|
AND marked_to_publication = True
|
||||||
|
AND is_posted_tg = False
|
||||||
|
""", (id_event,))
|
||||||
|
event = cursor.fetchone()
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
logger.info(f"Событие ID {id_event} не найдено или уже опубликовано")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем информацию о категории (площадке), если указана unit_name
|
||||||
|
unit_tg_id = None
|
||||||
|
unit_title = None
|
||||||
|
if event.get('unit_name'):
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT TITLE, tg_id
|
||||||
|
FROM categories
|
||||||
|
WHERE ID = %s
|
||||||
|
""", (event['unit_name'],))
|
||||||
|
category = cursor.fetchone()
|
||||||
|
if category:
|
||||||
|
unit_title = category.get('TITLE')
|
||||||
|
unit_tg_id = category.get('tg_id')
|
||||||
|
|
||||||
|
# Добавляем информацию о категории в данные события
|
||||||
|
event['unit_tg_id'] = unit_tg_id
|
||||||
|
event['unit_title'] = unit_title
|
||||||
|
|
||||||
old_message_id = event.get('tg_message_id') # Сохраняем старый ID сообщения
|
old_message_id = event.get('tg_message_id') # Сохраняем старый ID сообщения
|
||||||
bot = Bot(token=BOT_TOKEN)
|
# Создаем httpx клиент с отключенным HTTP/2
|
||||||
tg_message_id = await tg_post_event(bot, event) # Получаем новый message_id
|
httpx_client = httpx.AsyncClient(
|
||||||
|
http2=False,
|
||||||
|
timeout=20.0,
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
tg_message_id = None
|
||||||
|
try:
|
||||||
|
tg_message_id = await tg_post_event(httpx_client, event) # Получаем новый message_id
|
||||||
|
finally:
|
||||||
|
await httpx_client.aclose()
|
||||||
|
|
||||||
if tg_message_id:
|
if tg_message_id:
|
||||||
# Используем UTC время для записи в базу данных
|
# Используем UTC время для записи в базу данных
|
||||||
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
# Обновляем подписки если был предыдущий message_id
|
# Обновляем базу данных в отдельном блоке try-except для гарантии сохранения
|
||||||
if old_message_id:
|
try:
|
||||||
cursor.execute("""
|
# Обновляем подписки если был предыдущий message_id
|
||||||
UPDATE marks_evt
|
if old_message_id:
|
||||||
SET tg_event_id = %s
|
cursor.execute("""
|
||||||
WHERE tg_event_id = %s
|
UPDATE marks_evt
|
||||||
""", (tg_message_id, old_message_id))
|
SET tg_event_id = %s
|
||||||
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
|
WHERE tg_event_id = %s
|
||||||
|
""", (tg_message_id, old_message_id))
|
||||||
|
logger.info(f"Обновлены подписки для события {id_event}: {cursor.rowcount} записей")
|
||||||
|
|
||||||
cursor.execute("""
|
# Обновляем данные о публикации события
|
||||||
UPDATE events
|
cursor.execute("""
|
||||||
SET is_posted_tg = True,
|
UPDATE events
|
||||||
tg_message_id = %s,
|
SET is_posted_tg = True,
|
||||||
tg_posted_date = %s
|
tg_message_id = %s,
|
||||||
WHERE id_event = %s
|
tg_posted_date = %s
|
||||||
""", (tg_message_id, current_time_utc, id_event))
|
WHERE id_event = %s
|
||||||
conn.commit()
|
""", (tg_message_id, current_time_utc, id_event))
|
||||||
logger.info(f"Событие номер {event['number']} успешно опубликовано")
|
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:
|
else:
|
||||||
logger.error(f"Не удалось опубликовать событие ID {id_event}")
|
logger.error(f"Не удалось опубликовать событие ID {id_event}")
|
||||||
|
|
||||||
except RetryAfter as e:
|
except RetryAfterException as e:
|
||||||
raise e
|
# Обработка RetryAfter (FloodWait)
|
||||||
|
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
|
||||||
|
logger.error(f" Тип ошибки: RetryAfter")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" retry_after: {e.retry_after}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Проверяем, не RetryAfter ли это в строковом виде
|
||||||
|
error_msg = str(e)
|
||||||
|
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')):
|
||||||
|
retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
|
||||||
|
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_event_by_id для события ID {id_event}:")
|
||||||
|
logger.error(f" Тип ошибки: RetryAfter")
|
||||||
|
logger.error(f" Сообщение: {error_msg}")
|
||||||
|
logger.error(f" retry_after: {retry_after}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
|
raise RetryAfterException(retry_after)
|
||||||
|
# Если это не RetryAfter, обрабатываем как обычную ошибку
|
||||||
logger.error(f"Ошибка при публикации: {e}")
|
logger.error(f"Ошибка при публикации: {e}")
|
||||||
|
logger.error(f"Полная информация об ошибке в tg_post_event_by_id для события ID {id_event}:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -282,21 +774,33 @@ async def tg_post_all_events():
|
|||||||
password=MDB_PW,
|
password=MDB_PW,
|
||||||
database=MDBASE,
|
database=MDBASE,
|
||||||
charset='utf8mb4',
|
charset='utf8mb4',
|
||||||
cursorclass=pymysql.cursors.DictCursor
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
init_command="SET time_zone='+00:00'" # Устанавливаем часовой пояс UTC
|
||||||
)
|
)
|
||||||
|
|
||||||
# Вычисляем время, до которого должно быть изменено событие для публикации
|
|
||||||
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
|
||||||
|
|
||||||
with conn.cursor() as cursor:
|
with conn.cursor() as cursor:
|
||||||
cursor.execute("""
|
# Проверка времени изменения только в режиме ZILANT
|
||||||
SELECT id_event
|
if WORKMODE == "ZILANT":
|
||||||
FROM events
|
# Вычисляем время, до которого должно быть изменено событие для публикации
|
||||||
WHERE marked_to_publication = True
|
modified_before = datetime.now(timezone.utc) - timedelta(minutes=EVENT_POST_DELAY)
|
||||||
AND is_posted_tg = False
|
|
||||||
AND modified <= %s
|
cursor.execute("""
|
||||||
LIMIT 5
|
SELECT id_event
|
||||||
""", (modified_before,))
|
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()
|
events = cursor.fetchall()
|
||||||
|
|
||||||
event_count = 0
|
event_count = 0
|
||||||
@@ -309,11 +813,29 @@ async def tg_post_all_events():
|
|||||||
if event_count < len(events):
|
if event_count < len(events):
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
except RetryAfter as e:
|
except RetryAfterException as e:
|
||||||
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
|
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
|
||||||
|
logger.error(f"Полная информация об ошибке RetryAfter в tg_post_all_events для события ID {event['id_event']}:")
|
||||||
|
logger.error(f" Тип ошибки: RetryAfter")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" retry_after: {e.retry_after}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
|
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
|
||||||
|
logger.error(f"Полная информация об ошибке в tg_post_all_events для события ID {event['id_event']}:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
|
# Продолжаем публикацию следующих событий, несмотря на ошибку
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при публикации события ID {event['id_event']}: {e}")
|
||||||
|
logger.error(f"Полная информация об ошибке в tg_post_all_events для события ID {event['id_event']}:")
|
||||||
|
logger.error(f" Тип ошибки: {type(e).__name__}")
|
||||||
|
logger.error(f" Сообщение: {str(e)}")
|
||||||
|
logger.error(f" Все атрибуты ошибки: {vars(e)}")
|
||||||
|
logger.error(f" Полное представление: {repr(e)}")
|
||||||
# Продолжаем публикацию следующих событий, несмотря на ошибку
|
# Продолжаем публикацию следующих событий, несмотря на ошибку
|
||||||
|
|
||||||
logger.info(f"Опубликовано событий в этом запуске: {event_count}")
|
logger.info(f"Опубликовано событий в этом запуске: {event_count}")
|
||||||
|
|||||||
+63
-5
@@ -1,7 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
def truncate_text(text, max_length):
|
def truncate_text(text, max_length):
|
||||||
"""Обрезает текст до максимальной длины с учетом границ слов"""
|
"""Обрезает текст до максимальной длины с учетом границ слов и HTML-тегов"""
|
||||||
if text is None or not text:
|
if text is None or not text:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -12,6 +12,18 @@ def truncate_text(text, max_length):
|
|||||||
truncated = text[:max_length]
|
truncated = text[:max_length]
|
||||||
last_space = truncated.rfind(' ')
|
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:
|
if last_space > 0:
|
||||||
return truncated[:last_space] + "..."
|
return truncated[:last_space] + "..."
|
||||||
return truncated[:max_length-3] + "..."
|
return truncated[:max_length-3] + "..."
|
||||||
@@ -25,15 +37,61 @@ def replace_vk_links(text):
|
|||||||
Заменяет ссылки на сообщества ВК в форматах:
|
Заменяет ссылки на сообщества ВК в форматах:
|
||||||
[club123|Название сообщества] -> Название сообщества
|
[club123|Название сообщества] -> Название сообщества
|
||||||
[id456|Имя пользователя] -> Имя пользователя
|
[id456|Имя пользователя] -> Имя пользователя
|
||||||
|
[текст](url) -> <a href="url">текст</a> (для Telegram)
|
||||||
|
[url|текст] -> <a href="url">текст</a> (для Telegram)
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
# Регулярное выражение для поиска ссылок формата [типID|Текст]
|
# Сначала обрабатываем ВК-специфичные ссылки [club*|текст] и [id*|текст]
|
||||||
pattern = r'\[(?:club|id)\d+\|([^\]]+)\]'
|
# Это нужно сделать до обработки обычных URL, чтобы не перехватывать их
|
||||||
|
vk_pattern = r'\[(?:club|id)\d+\|([^\]]+)\]'
|
||||||
|
text = re.sub(vk_pattern, r'\1', text)
|
||||||
|
|
||||||
# Заменяем все вхождения на текст внутри ссылки
|
# Затем обрабатываем Markdown-ссылки [текст](url) и преобразуем их в HTML-ссылки
|
||||||
return re.sub(pattern, r'\1', text)
|
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).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 должен содержать хотя бы один символ, не быть пустым
|
||||||
|
url_pattern = r'\[([^\|\]]+)\|([^\]]+)\]'
|
||||||
|
text = re.sub(url_pattern, replace_url_link, text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
def remove_html_tags(text):
|
def remove_html_tags(text):
|
||||||
"""Удаляет HTML-теги с учетом специальной обработки <p> и <a>"""
|
"""Удаляет HTML-теги с учетом специальной обработки <p> и <a>"""
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Конфиг gunicorn для responder-бота.
|
||||||
|
|
||||||
|
Регистрация webhook выполняется в wsgi_bot.py (с file lock), чтобы сработала
|
||||||
|
даже если unit забыли обновить с --config.
|
||||||
|
"""
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Директория со скриптами
|
# Директория со скриптами
|
||||||
SCRIPTS_DIR="/opt/testbot"
|
SCRIPTS_DIR="/opt/testbot-git"
|
||||||
# Активация виртуального окружения
|
# Активация виртуального окружения
|
||||||
VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate"
|
VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate"
|
||||||
|
|
||||||
# Список скриптов для запуска в порядке выполнения
|
# Список скриптов для запуска в порядке выполнения
|
||||||
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
|
# Legacy prefetch (не в пайплайне): tools/legacy/evt_prefetch.py
|
||||||
|
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tools/legacy/evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
|
||||||
|
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py")
|
||||||
|
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py")
|
||||||
|
|
||||||
# Интервалы между скриптами (в секундах)
|
# Интервалы между скриптами (в секундах)
|
||||||
DELAYS=(2 2 2 2 30)
|
# DELAYS=(2 2 2 2 30)
|
||||||
|
DELAYS=(2 2 2)
|
||||||
|
|
||||||
# Лог-файл
|
# Лог-файл
|
||||||
LOG_FILE="/opt/testbot/runner.log"
|
LOG_DIR="$SCRIPTS_DIR/logs"
|
||||||
|
LOG_FILE="$LOG_DIR/runner.log"
|
||||||
|
|
||||||
# Функция для логирования
|
# Функция для логирования
|
||||||
log_message() {
|
log_message() {
|
||||||
@@ -22,11 +27,20 @@ log_message() {
|
|||||||
|
|
||||||
# Проверка и настройка лог-файла
|
# Проверка и настройка лог-файла
|
||||||
setup_logfile() {
|
setup_logfile() {
|
||||||
# Проверяем, существует ли файл и можно ли в него писать
|
if [ ! -d "$LOG_DIR" ]; then
|
||||||
|
if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
|
||||||
|
if sudo mkdir -p "$LOG_DIR" 2>/dev/null; then
|
||||||
|
sudo chown "$(whoami)" "$LOG_DIR"
|
||||||
|
sudo chmod 755 "$LOG_DIR"
|
||||||
|
else
|
||||||
|
echo "ОШИБКА: Не удалось создать каталог $LOG_DIR!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -f "$LOG_FILE" ]; then
|
if [ ! -f "$LOG_FILE" ]; then
|
||||||
# Пытаемся создать файл
|
|
||||||
if ! touch "$LOG_FILE" 2>/dev/null; then
|
if ! touch "$LOG_FILE" 2>/dev/null; then
|
||||||
# Если не получается, пробуем с sudo
|
|
||||||
if sudo touch "$LOG_FILE" 2>/dev/null; then
|
if sudo touch "$LOG_FILE" 2>/dev/null; then
|
||||||
sudo chown $(whoami) "$LOG_FILE"
|
sudo chown $(whoami) "$LOG_FILE"
|
||||||
sudo chmod 644 "$LOG_FILE"
|
sudo chmod 644 "$LOG_FILE"
|
||||||
@@ -38,9 +52,7 @@ setup_logfile() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Проверяем права на запись
|
|
||||||
if [ ! -w "$LOG_FILE" ]; then
|
if [ ! -w "$LOG_FILE" ]; then
|
||||||
# Пытаемся изменить права
|
|
||||||
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
|
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
|
||||||
log_message "Изменены права на лог-файл: $LOG_FILE"
|
log_message "Изменены права на лог-файл: $LOG_FILE"
|
||||||
else
|
else
|
||||||
@@ -110,4 +122,3 @@ done
|
|||||||
|
|
||||||
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
|
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
|
||||||
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,273 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт post-deploy.sh для автоматического развертывания проекта
|
||||||
|
# Выполняется после развертывания проекта
|
||||||
|
|
||||||
|
set -e # Остановка при любой ошибке
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Функция для записи в лог-файл
|
||||||
|
write_to_log() {
|
||||||
|
local message="$1"
|
||||||
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
echo "[$timestamp] [PostDeploy] $message" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция для логирования
|
||||||
|
log() {
|
||||||
|
local message="$1"
|
||||||
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
echo -e "${GREEN}[$timestamp] $1${NC}"
|
||||||
|
write_to_log "$message"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
local message="$1"
|
||||||
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
echo -e "${RED}[$timestamp] ERROR: $1${NC}"
|
||||||
|
write_to_log "ERROR: $message"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
local message="$1"
|
||||||
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
echo -e "${YELLOW}[$timestamp] WARNING: $1${NC}"
|
||||||
|
write_to_log "WARNING: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Определяем корневую директорию проекта
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
VENV_DIR="$PROJECT_ROOT/venv"
|
||||||
|
REQUIREMENTS_FILE="$PROJECT_ROOT/requirements.txt"
|
||||||
|
BACKUP_SCRIPT="$PROJECT_ROOT/backupdb.sh"
|
||||||
|
ENSURE_DB_SCRIPT="$PROJECT_ROOT/ensure_db.py"
|
||||||
|
SERVICES_DIR="$PROJECT_ROOT/services"
|
||||||
|
SERVICES_BACKUP_DIR="$SERVICES_DIR/backups"
|
||||||
|
SYSTEMD_DIR="/etc/systemd/system"
|
||||||
|
|
||||||
|
# Загружаем переменные окружения из .env файла
|
||||||
|
if [ -f "$PROJECT_ROOT/.env" ]; then
|
||||||
|
# Безопасная загрузка переменных из .env файла
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
# Пропускаем пустые строки и комментарии
|
||||||
|
if [[ -n "$line" && ! "$line" =~ ^[[:space:]]*# ]]; then
|
||||||
|
# Проверяем, что строка содержит знак равенства
|
||||||
|
if [[ "$line" =~ ^[^=]+=.* ]]; then
|
||||||
|
# Убираем пробелы вокруг знака равенства и экспортируем переменную
|
||||||
|
cleaned_line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*=[[:space:]]*/=/')
|
||||||
|
export "$cleaned_line"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < "$PROJECT_ROOT/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Определяем лог-файл из переменной окружения или используем значение по умолчанию
|
||||||
|
LOG_FILE="${LOG_FILE:-$PROJECT_ROOT/post-deploy.log}"
|
||||||
|
|
||||||
|
# Выводим информацию о лог-файле (только в консоль, чтобы не создавать циклическое логирование)
|
||||||
|
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] Лог-файл: $LOG_FILE${NC}"
|
||||||
|
|
||||||
|
|
||||||
|
log "Начинаем post-deploy процесс для проекта: $PROJECT_ROOT"
|
||||||
|
|
||||||
|
# 0. Установка прав на исполнение для всех .sh файлов в основном каталоге проекта
|
||||||
|
log "Устанавливаем права на исполнение для всех .sh файлов в проекте..."
|
||||||
|
|
||||||
|
# Находим все .sh файлы в основном каталоге проекта (не в подкаталогах)
|
||||||
|
for sh_file in "$PROJECT_ROOT"/*.sh; do
|
||||||
|
if [ -f "$sh_file" ]; then
|
||||||
|
chmod +x "$sh_file"
|
||||||
|
log "Установлены права на исполнение для: $(basename "$sh_file")"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 1. Проверка и создание виртуального окружения Python
|
||||||
|
log "Проверяем наличие виртуального окружения Python..."
|
||||||
|
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
log "Виртуальное окружение не найдено. Создаем новое..."
|
||||||
|
|
||||||
|
# Проверяем наличие python3
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
error "Python3 не найден в системе. Установите Python3 перед продолжением."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Создаем виртуальное окружение
|
||||||
|
python3 -m venv "$VENV_DIR"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log "Виртуальное окружение успешно создано"
|
||||||
|
else
|
||||||
|
error "Ошибка при создании виртуального окружения"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "Виртуальное окружение уже существует"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Активируем виртуальное окружение
|
||||||
|
log "Активируем виртуальное окружение..."
|
||||||
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
|
# Проверяем наличие requirements.txt
|
||||||
|
if [ ! -f "$REQUIREMENTS_FILE" ]; then
|
||||||
|
error "Файл requirements.txt не найден в $REQUIREMENTS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Обновляем pip и устанавливаем зависимости
|
||||||
|
log "Обновляем pip и устанавливаем зависимости из requirements.txt..."
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r "$REQUIREMENTS_FILE"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log "Зависимости успешно установлены"
|
||||||
|
else
|
||||||
|
error "Ошибка при установке зависимостей"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Запуск backupdb.sh для бэкапа базы данных
|
||||||
|
log "Запускаем бэкап базы данных..."
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP_SCRIPT" ]; then
|
||||||
|
error "Скрипт backupdb.sh не найден в $BACKUP_SCRIPT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Делаем скрипт исполняемым
|
||||||
|
chmod +x "$BACKUP_SCRIPT"
|
||||||
|
|
||||||
|
# Запускаем бэкап
|
||||||
|
bash "$BACKUP_SCRIPT"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log "Бэкап базы данных выполнен успешно"
|
||||||
|
else
|
||||||
|
error "Ошибка при выполнении бэкапа базы данных"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Проверка и создание структуры базы данных пропущена
|
||||||
|
# Будет выполнена в другом месте
|
||||||
|
log "Проверка структуры базы данных пропущена - будет выполнена отдельно"
|
||||||
|
|
||||||
|
# 3. Проверка и обновление systemd сервисов
|
||||||
|
log "Проверяем и обновляем systemd сервисы..."
|
||||||
|
|
||||||
|
# Создаем каталог для резервных копий сервисов
|
||||||
|
mkdir -p "$SERVICES_BACKUP_DIR"
|
||||||
|
|
||||||
|
# Список сервисов для проверки
|
||||||
|
SERVICES=("testbot-git.service" "testedit-git.service")
|
||||||
|
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
log "Обрабатываем сервис: $service"
|
||||||
|
|
||||||
|
SERVICE_FILE="$SERVICES_DIR/$service"
|
||||||
|
SYSTEMD_SERVICE="$SYSTEMD_DIR/$service"
|
||||||
|
|
||||||
|
# Проверяем наличие файла сервиса в проекте
|
||||||
|
if [ ! -f "$SERVICE_FILE" ]; then
|
||||||
|
warning "Файл сервиса $SERVICE_FILE не найден, пропускаем"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем, нужно ли обновлять сервис
|
||||||
|
UPDATE_NEEDED=false
|
||||||
|
|
||||||
|
if [ ! -f "$SYSTEMD_SERVICE" ]; then
|
||||||
|
log "Сервис $service не найден в systemd, будет создан"
|
||||||
|
UPDATE_NEEDED=true
|
||||||
|
else
|
||||||
|
# Сравниваем даты модификации файлов
|
||||||
|
if [ "$SERVICE_FILE" -nt "$SYSTEMD_SERVICE" ]; then
|
||||||
|
log "Файл сервиса $service в проекте новее, обновляем"
|
||||||
|
UPDATE_NEEDED=true
|
||||||
|
else
|
||||||
|
log "Сервис $service актуален, пропускаем"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$UPDATE_NEEDED" = true ]; then
|
||||||
|
log "Копируем файл сервиса $service в systemd..."
|
||||||
|
|
||||||
|
# Создаем резервную копию существующего файла в каталог проекта
|
||||||
|
if [ -f "$SYSTEMD_SERVICE" ]; then
|
||||||
|
BACKUP_FILE="$SERVICES_BACKUP_DIR/${service}.backup.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
sudo cp "$SYSTEMD_SERVICE" "$BACKUP_FILE"
|
||||||
|
log "Создана резервная копия: $BACKUP_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Копируем новый файл сервиса
|
||||||
|
sudo cp "$SERVICE_FILE" "$SYSTEMD_SERVICE"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log "Файл сервиса $service успешно скопирован"
|
||||||
|
else
|
||||||
|
error "Ошибка при копировании файла сервиса $service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Устанавливаем правильные права доступа
|
||||||
|
sudo chmod 644 "$SYSTEMD_SERVICE"
|
||||||
|
sudo chown root:root "$SYSTEMD_SERVICE"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. Перезапуск сервисов
|
||||||
|
log "Перезапускаем systemd сервисы..."
|
||||||
|
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
SYSTEMD_SERVICE="$SYSTEMD_DIR/$service"
|
||||||
|
|
||||||
|
if [ -f "$SYSTEMD_SERVICE" ]; then
|
||||||
|
log "Останавливаем сервис $service..."
|
||||||
|
sudo systemctl stop "$service" || warning "Не удалось остановить сервис $service (возможно, не запущен)"
|
||||||
|
|
||||||
|
log "Перезагружаем конфигурацию systemd..."
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
log "Запускаем сервис $service..."
|
||||||
|
sudo systemctl start "$service"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log "Сервис $service успешно запущен"
|
||||||
|
|
||||||
|
# Проверяем статус сервиса
|
||||||
|
sleep 2
|
||||||
|
if sudo systemctl is-active --quiet "$service"; then
|
||||||
|
log "Сервис $service активен и работает"
|
||||||
|
else
|
||||||
|
warning "Сервис $service запущен, но может работать некорректно"
|
||||||
|
sudo systemctl status "$service" --no-pager -l
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
error "Ошибка при запуске сервиса $service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Включаем автозапуск сервиса
|
||||||
|
sudo systemctl enable "$service"
|
||||||
|
log "Автозапуск для сервиса $service включен"
|
||||||
|
else
|
||||||
|
warning "Файл сервиса $SYSTEMD_SERVICE не найден, пропускаем перезапуск"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Post-deploy процесс завершен успешно!"
|
||||||
|
log "Все сервисы обновлены и перезапущены"
|
||||||
|
|
||||||
|
# Показываем статус всех сервисов
|
||||||
|
log "Текущий статус сервисов:"
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
if sudo systemctl list-unit-files | grep -q "$service"; then
|
||||||
|
echo -n " $service: "
|
||||||
|
if sudo systemctl is-active --quiet "$service"; then
|
||||||
|
echo -e "${GREEN}АКТИВЕН${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}НЕАКТИВЕН${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e " $service: ${YELLOW}НЕ НАЙДЕН${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# /etc/systemd/system/testbot-git.service
|
||||||
|
[Unit]
|
||||||
|
Description=AUTOTEST Zilant Bot service group - Channel Bot
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/testbot-git
|
||||||
|
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5005 --workers 4 --config gunicorn_bot.conf.py wsgi_bot:app
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# /etc/systemd/system/testedit-git.service
|
||||||
|
[Unit]
|
||||||
|
Description=AUTOTEST Zilant Bot service group - Reposter Control
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/testbot-git
|
||||||
|
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5006 --workers 4 --timeout=240 wsgi_edit:app
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -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>
|
||||||
|
|
||||||
+271
-83
@@ -12,7 +12,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.preview-image {
|
.preview-image {
|
||||||
max-height: 400px;
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
.required:after {
|
.required:after {
|
||||||
@@ -25,6 +26,53 @@
|
|||||||
right: 20px;
|
right: 20px;
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
}
|
}
|
||||||
|
/* Скрытие крутилок у числовых полей */
|
||||||
|
input[type="text"][pattern="[0-9]*"]::-webkit-outer-spin-button,
|
||||||
|
input[type="text"][pattern="[0-9]*"]::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="text"][pattern="[0-9]*"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
/* Принудительное отображение 24-часового формата */
|
||||||
|
input[type="datetime-local"] {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
/* Жирные заголовки и выравнивание */
|
||||||
|
.form-label {
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
height: 38px; /* Высота стандартного поля ввода Bootstrap */
|
||||||
|
}
|
||||||
|
/* Выравнивание полей с заголовками */
|
||||||
|
.form-control, .form-select {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
textarea.form-control {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
/* Убираем отступы слева у полей для выравнивания */
|
||||||
|
.form-control, .form-select {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
/* Тонкое регулирование ширины для заголовков */
|
||||||
|
.label-narrow {
|
||||||
|
flex: 0 0 100px; /* Фиксированная ширина 100px */
|
||||||
|
}
|
||||||
|
.label-medium {
|
||||||
|
flex: 0 0 140px; /* Фиксированная ширина 140px */
|
||||||
|
}
|
||||||
|
.label-wide {
|
||||||
|
flex: 0 0 180px; /* Фиксированная ширина 180px */
|
||||||
|
}
|
||||||
|
.field-wide {
|
||||||
|
flex: 1; /* Занимает оставшееся место */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -41,61 +89,144 @@
|
|||||||
<form id="post-form">
|
<form id="post-form">
|
||||||
<input type="hidden" id="post-id" value="{{ post_id if post_id else '' }}">
|
<input type="hidden" id="post-id" value="{{ post_id if post_id else '' }}">
|
||||||
|
|
||||||
|
<!-- Заголовок и поле Название в одну строку -->
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<div class="label-narrow">
|
||||||
|
<label for="shortname" class="form-label">Название:</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-wide">
|
||||||
|
<input type="text" class="form-control" id="shortname" maxlength="100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Секция с площадкой и VK ID слева, изображением справа -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<!-- Площадка -->
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="label-narrow">
|
||||||
|
<label for="auto_unit" class="form-label">Площадка:</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-wide">
|
||||||
|
<select class="form-control" id="auto_unit">
|
||||||
|
<option value="">Выберите площадку</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- VK ID -->
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<div class="label-narrow">
|
||||||
|
<label for="vk_post_id" class="form-label">VK ID:</label>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 0 0 100px;">
|
||||||
|
<input type="text" class="form-control" id="vk_post_id" maxlength="8" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Переключатели -->
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="is_event">
|
||||||
|
<label class="form-check-label" for="is_event">Событие</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="is_poll">
|
||||||
|
<label class="form-check-label" for="is_poll">Опрос</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="marked_for_publication">
|
||||||
|
<label class="form-check-label" for="marked_for_publication">К публикации</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="published_in_tg">
|
||||||
|
<label class="form-check-label" for="published_in_tg">Опубликовано в TG</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-end">
|
||||||
|
<div id="image-preview-container" style="display: none;">
|
||||||
|
<img id="image-preview" class="preview-image img-thumbnail" style="max-width: 200px; max-height: 200px; object-fit: contain;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Текстовое поле поста -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="vk_post_id" class="form-label required">VK ID поста</label>
|
<textarea class="form-control" id="text" rows="4" required placeholder="Введите текст поста"></textarea>
|
||||||
<input type="text" class="form-control" id="vk_post_id" required>
|
|
||||||
<div class="form-text">Числовой идентификатор поста во ВКонтакте</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<!-- URL изображения -->
|
||||||
<label for="shortname" class="form-label">Название</label>
|
<div class="d-flex align-items-center mb-3">
|
||||||
<input type="text" class="form-control" id="shortname" maxlength="100">
|
<div class="label-narrow">
|
||||||
<div class="form-text">Короткое имя поста (максимум 100 символов)</div>
|
<label for="image_url" class="form-label">Картинка:</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-wide">
|
||||||
|
<input type="url" class="form-control" id="image_url">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<!-- URL поста ВК с датой -->
|
||||||
<label for="action_number" class="form-label">Номер события в системе расписания</label>
|
<div class="d-flex align-items-center mb-3">
|
||||||
<input type="number" class="form-control" id="action_number">
|
<div class="label-narrow">
|
||||||
|
<label for="vk_post_url" class="form-label">Пост в ВК:</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-wide" style="margin-right: 15px;">
|
||||||
|
<input type="url" class="form-control" id="vk_post_url">
|
||||||
|
</div>
|
||||||
|
<div class="label-narrow">
|
||||||
|
<label for="published_at" class="form-label">датирован:</label>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 0 0 200px;">
|
||||||
|
<input type="datetime-local" class="form-control" id="published_at" step="60">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<!-- Дата публикации в TG -->
|
||||||
<label for="text" class="form-label required">Текст поста</label>
|
<div class="d-flex align-items-center mb-3">
|
||||||
<textarea class="form-control" id="text" rows="4" required></textarea>
|
<div class="label-wide">
|
||||||
|
<label for="tg_publication_date" class="form-label">Опубликовано в TG:</label>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 0 0 200px;">
|
||||||
|
<input type="datetime-local" class="form-control" id="tg_publication_date" step="60">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<!-- ID TG в одну строку -->
|
||||||
<label for="image_url" class="form-label">URL изображения</label>
|
<div class="d-flex align-items-center mb-3">
|
||||||
<input type="url" class="form-control" id="image_url">
|
<div class="label-narrow">
|
||||||
<div class="form-text">Ссылка на изображение для поста</div>
|
<label for="tg_message_id" class="form-label">ID TG:</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="flex: 0 0 100px; margin-right: 15px;">
|
||||||
<div class="mb-3 text-center" id="image-preview-container" style="display: none;">
|
<input type="text" class="form-control" id="tg_message_id" pattern="[0-9]*" inputmode="numeric">
|
||||||
<img id="image-preview" class="preview-image img-thumbnail">
|
</div>
|
||||||
</div>
|
<div style="flex: 0 0 120px;">
|
||||||
|
<label for="tg_poll_id" class="form-label">ID опроса TG:</label>
|
||||||
<div class="mb-3">
|
</div>
|
||||||
<label for="vk_post_url" class="form-label">Ссылка на пост VK</label>
|
<div style="flex: 0 0 100px; margin-right: 15px;">
|
||||||
<input type="url" class="form-control" id="vk_post_url">
|
<input type="text" class="form-control" id="tg_poll_id" pattern="[0-9]*" inputmode="numeric">
|
||||||
</div>
|
</div>
|
||||||
|
<div style="flex: 0 0 120px;">
|
||||||
<div class="mb-3">
|
<label for="tg_markpost_id" class="form-label">ID отметок TG:</label>
|
||||||
<label for="published_at" class="form-label">Дата публикации</label>
|
</div>
|
||||||
<input type="datetime-local" class="form-control" id="published_at">
|
<div style="flex: 0 0 100px;">
|
||||||
<div class="form-text">Формат: ГГГГ-ММ-ДД ЧЧ:ММ</div>
|
<input type="text" class="form-control" id="tg_markpost_id" pattern="[0-9]*" inputmode="numeric">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Новое поле: is_event -->
|
|
||||||
<div class="mb-3 form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="is_event">
|
|
||||||
<label class="form-check-label" for="is_event">Это событие</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="is_poll">
|
|
||||||
<label class="form-check-label" for="is_poll">Это опрос</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Блок параметров опроса -->
|
||||||
<div id="poll-fields" style="display: none;">
|
<div id="poll-fields" style="display: none;">
|
||||||
|
<div class="border-top mt-4 pt-3">
|
||||||
|
<h5>Параметры опроса</h5>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="poll_question" class="form-label">Вопрос опроса</label>
|
<label for="poll_question" class="form-label">Вопрос опроса</label>
|
||||||
<input type="text" class="form-control" id="poll_question">
|
<input type="text" class="form-control" id="poll_question">
|
||||||
@@ -114,44 +245,13 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="poll_end_date" class="form-label">Дата окончания опроса</label>
|
<label for="poll_end_date" class="form-label">Дата окончания опроса</label>
|
||||||
<input type="datetime-local" class="form-control" id="poll_end_date">
|
<input type="datetime-local" class="form-control" id="poll_end_date" step="60">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-top mt-4 pt-3">
|
|
||||||
<div class="mb-3 form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="marked_for_publication">
|
|
||||||
<label class="form-check-label" for="marked_for_publication">Отметить для публикации</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 form-check form-switch">
|
|
||||||
<input class="form-check-input" type="checkbox" id="published_in_tg">
|
|
||||||
<label class="form-check-label" for="published_in_tg">Опубликовано в Telegram</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="tg_publication_date" class="form-label">Дата публикации в Telegram</label>
|
|
||||||
<input type="datetime-local" class="form-control" id="tg_publication_date">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="tg_message_id" class="form-label">ID сообщения в Telegram</label>
|
|
||||||
<input type="number" class="form-control" id="tg_message_id">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="tg_poll_id" class="form-label">ID опроса в Telegram</label>
|
|
||||||
<input type="number" class="form-control" id="tg_poll_id">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="tg_markpost_id" class="form-label">ID поста для отметок в Telegram</label>
|
|
||||||
<input type="number" class="form-control" id="tg_markpost_id">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="tg_poll_results" class="form-label">Результаты опроса</label>
|
<label for="tg_poll_results" class="form-label">Результаты опроса</label>
|
||||||
<textarea class="form-control" id="tg_poll_results" rows="4"></textarea>
|
<textarea class="form-control" id="tg_poll_results" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -221,11 +321,55 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Валидация поля названия
|
||||||
|
const shortnameInput = document.getElementById('shortname');
|
||||||
|
shortnameInput.addEventListener('input', () => {
|
||||||
|
const value = shortnameInput.value;
|
||||||
|
if (value.length > 100) {
|
||||||
|
alert('Название не может превышать 100 символов');
|
||||||
|
shortnameInput.value = value.substring(0, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Валидация поля VK ID
|
||||||
|
const vkPostIdInput = document.getElementById('vk_post_id');
|
||||||
|
vkPostIdInput.addEventListener('input', () => {
|
||||||
|
const value = vkPostIdInput.value;
|
||||||
|
if (value.length > 8) {
|
||||||
|
alert('VK ID не может превышать 8 символов');
|
||||||
|
vkPostIdInput.value = value.substring(0, 8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загрузка списка площадок
|
||||||
|
loadVenues();
|
||||||
|
|
||||||
// Загрузка данных поста (если редактирование)
|
// Загрузка данных поста (если редактирование)
|
||||||
if (postId) {
|
if (postId) {
|
||||||
loadPostData(postId);
|
loadPostData(postId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Принудительная установка 24-часового формата для полей даты-времени
|
||||||
|
const datetimeInputs = document.querySelectorAll('input[type="datetime-local"]');
|
||||||
|
datetimeInputs.forEach(input => {
|
||||||
|
// Устанавливаем атрибут step для округления до минут
|
||||||
|
input.step = '60';
|
||||||
|
|
||||||
|
// Добавляем обработчик для принудительного отображения 24-часового формата
|
||||||
|
input.addEventListener('change', function() {
|
||||||
|
if (this.value) {
|
||||||
|
const date = new Date(this.value);
|
||||||
|
// Форматируем в 24-часовом формате
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
this.value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Обработка отправки формы
|
// Обработка отправки формы
|
||||||
form.addEventListener('submit', (e) => {
|
form.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -233,6 +377,31 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Загрузка списка площадок
|
||||||
|
async function loadVenues() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${PREFIX}/api/venues`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Ошибка загрузки списка площадок');
|
||||||
|
}
|
||||||
|
const venues = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('auto_unit');
|
||||||
|
// Очищаем список, оставляя только первую опцию
|
||||||
|
select.innerHTML = '<option value="">Выберите площадку</option>';
|
||||||
|
|
||||||
|
// Добавляем площадки
|
||||||
|
venues.forEach(venue => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = venue.name;
|
||||||
|
option.textContent = venue.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки площадок:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Загрузка данных поста
|
// Загрузка данных поста
|
||||||
async function loadPostData(postId) {
|
async function loadPostData(postId) {
|
||||||
try {
|
try {
|
||||||
@@ -246,16 +415,25 @@
|
|||||||
// Заполняем форму
|
// Заполняем форму
|
||||||
document.getElementById('vk_post_id').value = post.vk_post_id || '';
|
document.getElementById('vk_post_id').value = post.vk_post_id || '';
|
||||||
document.getElementById('shortname').value = post.shortname || '';
|
document.getElementById('shortname').value = post.shortname || '';
|
||||||
document.getElementById('action_number').value = post.action_number || '';
|
document.getElementById('auto_unit').value = post.auto_unit || '';
|
||||||
document.getElementById('text').value = post.text || '';
|
document.getElementById('text').value = post.text || '';
|
||||||
document.getElementById('image_url').value = post.image_url || '';
|
document.getElementById('image_url').value = post.image_url || '';
|
||||||
document.getElementById('vk_post_url').value = post.vk_post_url || '';
|
document.getElementById('vk_post_url').value = post.vk_post_url || '';
|
||||||
|
|
||||||
// Форматирование дат для input[type=datetime-local]
|
// Форматирование дат для input[type=datetime-local]
|
||||||
|
// Конвертация UTC в локальное время с округлением до минут в 24-часовом формате
|
||||||
const formatForInput = (dateStr) => {
|
const formatForInput = (dateStr) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toISOString().slice(0, 16);
|
// Округляем до минут (убираем секунды и миллисекунды)
|
||||||
|
date.setSeconds(0, 0);
|
||||||
|
// Возвращаем в локальном времени в формате YYYY-MM-DDTHH:MM (24-часовой формат)
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('published_at').value = formatForInput(post.published_at);
|
document.getElementById('published_at').value = formatForInput(post.published_at);
|
||||||
@@ -292,6 +470,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция конвертации локального времени в UTC для отправки на сервер
|
||||||
|
const convertLocalToUTC = (localString) => {
|
||||||
|
if (!localString) return null;
|
||||||
|
const date = new Date(localString);
|
||||||
|
// Округляем до минут
|
||||||
|
date.setSeconds(0, 0);
|
||||||
|
// Возвращаем в UTC формате для сервера (24-часовой формат)
|
||||||
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
};
|
||||||
|
|
||||||
// Сохранение поста
|
// Сохранение поста
|
||||||
async function savePost() {
|
async function savePost() {
|
||||||
const postId = document.getElementById('post-id').value;
|
const postId = document.getElementById('post-id').value;
|
||||||
@@ -312,20 +500,20 @@
|
|||||||
const formData = {
|
const formData = {
|
||||||
vk_post_id: document.getElementById('vk_post_id').value,
|
vk_post_id: document.getElementById('vk_post_id').value,
|
||||||
shortname: document.getElementById('shortname').value,
|
shortname: document.getElementById('shortname').value,
|
||||||
action_number: document.getElementById('action_number').value,
|
auto_unit: document.getElementById('auto_unit').value,
|
||||||
text: document.getElementById('text').value,
|
text: document.getElementById('text').value,
|
||||||
image_url: document.getElementById('image_url').value,
|
image_url: document.getElementById('image_url').value,
|
||||||
vk_post_url: document.getElementById('vk_post_url').value,
|
vk_post_url: document.getElementById('vk_post_url').value,
|
||||||
published_at: document.getElementById('published_at').value,
|
published_at: convertLocalToUTC(document.getElementById('published_at').value),
|
||||||
is_event: document.getElementById('is_event').checked, // Новое поле
|
is_event: document.getElementById('is_event').checked, // Новое поле
|
||||||
is_poll: document.getElementById('is_poll').checked,
|
is_poll: document.getElementById('is_poll').checked,
|
||||||
poll_question: document.getElementById('poll_question').value,
|
poll_question: document.getElementById('poll_question').value,
|
||||||
poll_options: document.getElementById('poll_options').value,
|
poll_options: document.getElementById('poll_options').value,
|
||||||
poll_multiple: document.getElementById('poll_multiple').checked,
|
poll_multiple: document.getElementById('poll_multiple').checked,
|
||||||
poll_end_date: document.getElementById('poll_end_date').value,
|
poll_end_date: convertLocalToUTC(document.getElementById('poll_end_date').value),
|
||||||
marked_for_publication: document.getElementById('marked_for_publication').checked,
|
marked_for_publication: document.getElementById('marked_for_publication').checked,
|
||||||
published_in_tg: document.getElementById('published_in_tg').checked,
|
published_in_tg: document.getElementById('published_in_tg').checked,
|
||||||
tg_publication_date: document.getElementById('tg_publication_date').value,
|
tg_publication_date: convertLocalToUTC(document.getElementById('tg_publication_date').value),
|
||||||
tg_message_id: document.getElementById('tg_message_id').value,
|
tg_message_id: document.getElementById('tg_message_id').value,
|
||||||
tg_poll_id: document.getElementById('tg_poll_id').value,
|
tg_poll_id: document.getElementById('tg_poll_id').value,
|
||||||
tg_markpost_id: document.getElementById('tg_markpost_id').value,
|
tg_markpost_id: document.getElementById('tg_markpost_id').value,
|
||||||
|
|||||||
+2175
-224
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 |
+667
-151
File diff suppressed because it is too large
Load Diff
+524
-137
@@ -6,11 +6,37 @@ import time
|
|||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import httpx
|
||||||
|
import traceback
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from telegram import Bot, PollOption
|
|
||||||
from telegram.error import TelegramError, RetryAfter
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from formatter import get_event_text
|
from formatter import get_event_text
|
||||||
|
from telegram import Bot
|
||||||
|
from telegram.error import TelegramError, TimedOut
|
||||||
|
from telegram.request import HTTPXRequest
|
||||||
|
from telegram_relay import (
|
||||||
|
get_ptb_base_urls,
|
||||||
|
get_relay_url,
|
||||||
|
EDIT_LINKS_INITIAL_DELAY_SEC,
|
||||||
|
EDIT_LINKS_MAX_ATTEMPTS,
|
||||||
|
EDIT_LINKS_RETRY_INTERVAL_SEC,
|
||||||
|
published_message_has_expected_links,
|
||||||
|
extract_text_link_urls,
|
||||||
|
)
|
||||||
|
from season_links import subscription_start_link
|
||||||
|
|
||||||
|
# Настройка anyio для правильной работы в отдельном потоке
|
||||||
|
# Устанавливаем правильный backend для anyio (используем asyncio)
|
||||||
|
try:
|
||||||
|
import anyio
|
||||||
|
# Устанавливаем backend для anyio явно
|
||||||
|
if not hasattr(anyio, '_backend'):
|
||||||
|
# Используем asyncio backend
|
||||||
|
os.environ.setdefault('ANYIO_BACKEND', 'asyncio')
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Загрузка переменных окружения
|
# Загрузка переменных окружения
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -30,6 +56,15 @@ ORG_MESSAGE_TAG = os.getenv('ORG_MESSAGE_TAG', '') # Хештег для опр
|
|||||||
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
|
MAX_CAPTION_LENGTH = int(os.getenv('MAX_CAPTION_LENGTH', 1000))
|
||||||
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
|
MAX_TEXT_LENGTH = int(os.getenv('MAX_TEXT_LENGTH', 4000))
|
||||||
|
|
||||||
|
# Режим публикации без звука
|
||||||
|
PUBLISH_SILENTLY = os.getenv('PUBLISH_SILENTLY', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
# Использование бота подписки
|
||||||
|
USE_SUBSCRIPTION_BOT = os.getenv('USE_SUBSCRIPTION_BOT', 'true').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
# Режим отладочного логирования данных
|
||||||
|
LOG_DEBUG_DATA = os.getenv('LOG_DEBUG_DATA', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logger = logging.getLogger('TG_Poster')
|
logger = logging.getLogger('TG_Poster')
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -54,6 +89,13 @@ file_handler = RetryFileHandler(LOG_FILE, encoding='utf-8')
|
|||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Класс для обработки RetryAfter (FloodWait 429)
|
||||||
|
class RetryAfterException(Exception):
|
||||||
|
"""Исключение для обработки FloodWait (429) от Telegram API"""
|
||||||
|
def __init__(self, retry_after):
|
||||||
|
self.retry_after = retry_after
|
||||||
|
super().__init__(f"RetryAfter: {retry_after}")
|
||||||
|
|
||||||
def prepare_text(text):
|
def prepare_text(text):
|
||||||
"""Подготовка текстовых полей с экранированием HTML-сущностей"""
|
"""Подготовка текстовых полей с экранированием HTML-сущностей"""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -73,6 +115,133 @@ def has_org_tag(text):
|
|||||||
pattern = r'#?' + re.escape(ORG_MESSAGE_TAG) + r'\b'
|
pattern = r'#?' + re.escape(ORG_MESSAGE_TAG) + r'\b'
|
||||||
return bool(re.search(pattern, text, re.IGNORECASE))
|
return bool(re.search(pattern, text, re.IGNORECASE))
|
||||||
|
|
||||||
|
def log_telegram_error(response, context=""):
|
||||||
|
"""Логирует полную информацию об ошибке от Telegram API
|
||||||
|
Работает как с httpx.Response, так и с requests.Response
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if response is not None:
|
||||||
|
logger.error(f"{context} Статус код: {response.status_code}")
|
||||||
|
logger.error(f"{context} Заголовки ответа: {dict(response.headers)}")
|
||||||
|
try:
|
||||||
|
# Работает для обоих типов ответов (httpx и requests)
|
||||||
|
error_data = response.json()
|
||||||
|
logger.error(f"{context} Полный ответ от Telegram API: {json.dumps(error_data, ensure_ascii=False, indent=2)}")
|
||||||
|
except:
|
||||||
|
# Для requests используем text, для httpx тоже text
|
||||||
|
response_text = getattr(response, 'text', str(response.content[:500]) if hasattr(response, 'content') else 'N/A')
|
||||||
|
logger.error(f"{context} Текст ответа: {response_text}")
|
||||||
|
else:
|
||||||
|
logger.error(f"{context} Ответ от Telegram API отсутствует (None)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{context} Ошибка при логировании информации об ошибке: {e}")
|
||||||
|
|
||||||
|
async def download_image(image_url, httpx_client):
|
||||||
|
"""Скачивает изображение по URL и возвращает его содержимое в виде bytes"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Скачивание изображения: {image_url}")
|
||||||
|
download_start_time = time.time()
|
||||||
|
|
||||||
|
response = await httpx_client.get(image_url, timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0))
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
download_duration = time.time() - download_start_time
|
||||||
|
image_data = response.content
|
||||||
|
logger.info(f"Изображение скачано за {download_duration:.2f} секунд, размер: {len(image_data)} байт")
|
||||||
|
|
||||||
|
return image_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при скачивании изображения {image_url}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _edit_post_add_links(
|
||||||
|
bot, message_id, message_has_image, final_text, vk_post_id, expected_urls
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Второй шаг публикации: editMessageCaption/Text со ссылками.
|
||||||
|
Повторяет попытку, пока в ответе API нет всех expected_urls в text_link entities.
|
||||||
|
"""
|
||||||
|
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
|
||||||
|
|
||||||
|
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
|
||||||
|
try:
|
||||||
|
if message_has_image:
|
||||||
|
edited = await bot.edit_message_caption(
|
||||||
|
chat_id=CHANNEL_ID,
|
||||||
|
message_id=message_id,
|
||||||
|
caption=final_text,
|
||||||
|
parse_mode='HTML',
|
||||||
|
)
|
||||||
|
published_text = edited.caption or ""
|
||||||
|
entities = edited.caption_entities or []
|
||||||
|
else:
|
||||||
|
edited = await bot.edit_message_text(
|
||||||
|
chat_id=CHANNEL_ID,
|
||||||
|
message_id=message_id,
|
||||||
|
text=final_text,
|
||||||
|
parse_mode='HTML',
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
published_text = edited.text or ""
|
||||||
|
entities = edited.entities or []
|
||||||
|
|
||||||
|
if published_message_has_expected_links(entities, expected_urls):
|
||||||
|
logger.info(
|
||||||
|
f"Ссылки добавлены для записи VK ID {vk_post_id} "
|
||||||
|
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
found = extract_text_link_urls(entities)
|
||||||
|
logger.warning(
|
||||||
|
f"editMessage* ok, но footer-ссылки не подтверждены "
|
||||||
|
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для VK ID {vk_post_id}; "
|
||||||
|
f"ожидались {expected_urls}, найдены text_link: {found}"
|
||||||
|
)
|
||||||
|
except TimedOut as e:
|
||||||
|
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
|
||||||
|
logger.error(
|
||||||
|
f"Таймаут редактирования ссылок для VK ID {vk_post_id} "
|
||||||
|
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logger.warning(
|
||||||
|
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||||
|
f"для VK ID {vk_post_id}, повтор"
|
||||||
|
)
|
||||||
|
except TelegramError as e:
|
||||||
|
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||||
|
raise RetryAfterException(getattr(e, 'retry_after', 60))
|
||||||
|
err = str(e).lower()
|
||||||
|
if "not modified" in err:
|
||||||
|
logger.info(
|
||||||
|
f"Caption уже содержит ссылки для VK ID {vk_post_id} "
|
||||||
|
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
f"Ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||||
|
f"для VK ID {vk_post_id}: {e}"
|
||||||
|
)
|
||||||
|
except RetryAfterException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Неожиданная ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||||
|
f"для VK ID {vk_post_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
|
||||||
|
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Не удалось добавить строку ссылок для VK ID {vk_post_id} "
|
||||||
|
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def publish_to_tg(vk_post_id):
|
async def publish_to_tg(vk_post_id):
|
||||||
"""Публикация одной записи по VK post ID"""
|
"""Публикация одной записи по VK post ID"""
|
||||||
logger.info(f"Запуск публикации для записи VK ID {vk_post_id}")
|
logger.info(f"Запуск публикации для записи VK ID {vk_post_id}")
|
||||||
@@ -102,168 +271,386 @@ async def publish_to_tg(vk_post_id):
|
|||||||
logger.info(f"Запись VK ID {vk_post_id} не найдена или уже опубликована")
|
logger.info(f"Запись VK ID {vk_post_id} не найдена или уже опубликована")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
bot = Bot(token=BOT_TOKEN)
|
# Настройка таймаутов для HTTPXRequest (аналогично test_tg_poster.py)
|
||||||
|
request = HTTPXRequest(
|
||||||
|
read_timeout=120.0, # Таймаут на чтение (увеличен для больших изображений)
|
||||||
|
write_timeout=120.0, # Таймаут на запись (увеличен для больших файлов)
|
||||||
|
connect_timeout=30.0, # Таймаут на подключение (увеличен)
|
||||||
|
pool_timeout=10.0, # Таймаут на получение соединения из пула
|
||||||
|
media_write_timeout=120.0 # Таймаут для медиа
|
||||||
|
)
|
||||||
|
|
||||||
# Проверяем наличие организационного хештега
|
# Bot API только через internal_relay
|
||||||
is_org_message = has_org_tag(post['text'])
|
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)
|
# Создаем httpx клиент только для скачивания изображений
|
||||||
placeholder_message_id = '0' * 20 # Заполнитель для message_id
|
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://')):
|
message_id = None
|
||||||
links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
publication_successful = False
|
||||||
|
# Флаг, указывающий, было ли отправлено изображение (True) или текстовое сообщение (False)
|
||||||
|
message_has_image = False
|
||||||
|
|
||||||
# Ссылка на подписку (только для событий)
|
try:
|
||||||
if post['is_event']:
|
# Проверяем наличие организационного хештега
|
||||||
links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{placeholder_message_id}">🔜 Подписка</a>')
|
is_org_message = has_org_tag(post['text'])
|
||||||
|
|
||||||
# Формируем строку ссылок
|
# Предварительный расчет строки ссылок (с запасом 20 символов для message_id)
|
||||||
links_line = " | ".join(links) if links else ""
|
placeholder_message_id = '0' * 20 # Заполнитель для message_id
|
||||||
links_line_length = len(links_line)
|
|
||||||
|
|
||||||
# Определяем максимальную длину в зависимости от типа сообщения
|
# Формируем ссылки
|
||||||
# Если это организационное сообщение, используем текстовый лимит
|
links = []
|
||||||
image_url = post['image_url']
|
|
||||||
has_image = image_url and image_url.strip() and not is_org_message
|
|
||||||
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
|
|
||||||
|
|
||||||
# Вычисляем доступную длину для текста
|
# Ссылка на оригинал в ВК
|
||||||
available_length = max_length - links_line_length - 2 # -2 для символов переноса строки
|
|
||||||
if available_length < 0:
|
|
||||||
available_length = 0
|
|
||||||
|
|
||||||
# Получаем обработанный текст с учетом доступной длины
|
|
||||||
text = get_event_text(post['text'], available_length) if post['text'] else ""
|
|
||||||
|
|
||||||
# Публикация первоначального сообщения без ссылок
|
|
||||||
if has_image and not is_org_message:
|
|
||||||
try:
|
|
||||||
message = await bot.send_photo(
|
|
||||||
chat_id=CHANNEL_ID,
|
|
||||||
photo=image_url,
|
|
||||||
caption=text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
disable_notification=True
|
|
||||||
)
|
|
||||||
except TelegramError as e:
|
|
||||||
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
|
|
||||||
message = await bot.send_message(
|
|
||||||
chat_id=CHANNEL_ID,
|
|
||||||
text=text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
disable_notification=True,
|
|
||||||
disable_web_page_preview=True
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
|
|
||||||
message = await bot.send_message(
|
|
||||||
chat_id=CHANNEL_ID,
|
|
||||||
text=text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
disable_notification=True,
|
|
||||||
disable_web_page_preview=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Получаем ID сообщения для использования в ссылке подписки
|
|
||||||
message_id = message.message_id
|
|
||||||
|
|
||||||
# Формируем финальный текст с ссылками
|
|
||||||
if links:
|
|
||||||
# Обновляем ссылку подписки с реальным message_id
|
|
||||||
updated_links = []
|
|
||||||
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||||
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
||||||
|
|
||||||
if post['is_event']:
|
# Ссылка на подписку (только для событий и если включен бот подписки)
|
||||||
updated_links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{message_id}">🔜 Подписка</a>')
|
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||||
|
links.append(
|
||||||
|
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", placeholder_message_id)}">🔜 Подписка</a>'
|
||||||
|
)
|
||||||
|
|
||||||
links_line = " | ".join(updated_links)
|
# Формируем строку ссылок
|
||||||
final_text = f"{text}\n\n{links_line}"
|
links_line = " | ".join(links) if links else ""
|
||||||
|
links_line_length = len(links_line)
|
||||||
|
|
||||||
|
# Определяем максимальную длину в зависимости от типа сообщения
|
||||||
|
# Если это организационное сообщение, используем текстовый лимит
|
||||||
|
image_url = post['image_url']
|
||||||
|
has_image = image_url and image_url.strip() and not is_org_message
|
||||||
|
max_length = MAX_CAPTION_LENGTH if has_image else MAX_TEXT_LENGTH
|
||||||
|
|
||||||
|
# Вычисляем доступную длину для текста
|
||||||
|
available_length = max_length - links_line_length - 2 # -2 для символов переноса строки
|
||||||
|
if available_length < 0:
|
||||||
|
available_length = 0
|
||||||
|
|
||||||
|
# Получаем обработанный текст с учетом доступной длины
|
||||||
|
text = get_event_text(post['text'], available_length) if post['text'] else ""
|
||||||
|
|
||||||
|
# Логирование отформатированного текста для отладки (только если включено)
|
||||||
|
if LOG_DEBUG_DATA:
|
||||||
|
logger.info(f"Начинаем форматирование текста для записи VK ID {vk_post_id}, доступная длина: {available_length}")
|
||||||
|
logger.info(f"=== Отформатированный текст для записи VK ID {vk_post_id} ===")
|
||||||
|
logger.info(f"Длина текста: {len(text)}")
|
||||||
|
logger.info(f"Текст (repr): {repr(text)}")
|
||||||
|
logger.info(f"Текст (содержимое):")
|
||||||
|
# Выводим текст построчно для лучшей читаемости в логе
|
||||||
|
for i, line in enumerate(text.split('\n'), 1):
|
||||||
|
logger.info(f" Строка {i}: {repr(line)}")
|
||||||
|
logger.info(f"=== Конец отформатированного текста ===")
|
||||||
|
|
||||||
|
# Публикация первоначального сообщения без ссылок
|
||||||
|
if has_image and not is_org_message:
|
||||||
|
try:
|
||||||
|
# Логируем информацию об изображении перед отправкой
|
||||||
|
logger.info(f"Отправка изображения для записи VK ID {vk_post_id}")
|
||||||
|
logger.info(f"URL изображения: {image_url}")
|
||||||
|
logger.info(f"Длина URL изображения: {len(image_url)} символов")
|
||||||
|
logger.info(f"Длина подписи: {len(text)} символов")
|
||||||
|
|
||||||
|
# Скачиваем изображение
|
||||||
|
image_data = None
|
||||||
|
try:
|
||||||
|
image_data = await download_image(image_url, httpx_client)
|
||||||
|
except Exception as download_error:
|
||||||
|
logger.error(f"Ошибка при скачивании изображения для записи VK ID {vk_post_id}: {download_error}")
|
||||||
|
raise # Пробрасываем ошибку, чтобы перейти к fallback
|
||||||
|
|
||||||
|
file_size_mb = len(image_data) / (1024 * 1024)
|
||||||
|
logger.info(f"Размер файла: {file_size_mb:.2f} МБ")
|
||||||
|
|
||||||
|
# Повторные попытки отправки фото при таймауте (до 10 раз)
|
||||||
|
message = None
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
image_file = io.BytesIO(image_data)
|
||||||
|
message = await bot.send_photo(
|
||||||
|
chat_id=CHANNEL_ID,
|
||||||
|
photo=image_file,
|
||||||
|
caption=text,
|
||||||
|
parse_mode='HTML',
|
||||||
|
disable_notification=PUBLISH_SILENTLY
|
||||||
|
)
|
||||||
|
message_id = message.message_id
|
||||||
|
message_has_image = True
|
||||||
|
logger.info(f"Сообщение с изображением успешно опубликовано. Message ID: {message_id}")
|
||||||
|
break # Успешная публикация, выходим из цикла
|
||||||
|
except TimedOut as e:
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.warning(f"Таймаут при публикации изображения (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
|
||||||
|
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
|
||||||
|
await asyncio.sleep(retry_interval)
|
||||||
|
else:
|
||||||
|
logger.error(f"Таймаут при публикации изображения после {max_retries} попыток для записи VK ID {vk_post_id}")
|
||||||
|
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
raise # Пробрасываем таймаут наверх
|
||||||
|
except TelegramError as e:
|
||||||
|
# Проверяем, является ли это ошибкой FloodWait (429)
|
||||||
|
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||||
|
retry_after = getattr(e, 'retry_after', 60)
|
||||||
|
raise RetryAfterException(retry_after)
|
||||||
|
logger.error(f"Ошибка Telegram при публикации изображения для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||||
|
logger.error(f"Детали ошибки: {repr(e)}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Неожиданная ошибка при публикации изображения для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||||
|
logger.error(f"Детали ошибки: {repr(e)}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if message_id is None:
|
||||||
|
raise Exception("Не удалось опубликовать изображение после всех попыток")
|
||||||
|
|
||||||
|
except RetryAfterException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# Если не удалось отправить изображение, пробуем отправить текстовое сообщение
|
||||||
|
logger.warning(f"Не удалось отправить изображение для записи VK ID {vk_post_id}: {e}. Отправляем текстовое сообщение.")
|
||||||
|
message_has_image = False # Отправляем текстовое сообщение вместо изображения
|
||||||
|
|
||||||
|
# Повторные попытки отправки текста (fallback) при таймауте
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
message = await bot.send_message(
|
||||||
|
chat_id=CHANNEL_ID,
|
||||||
|
text=text,
|
||||||
|
parse_mode='HTML',
|
||||||
|
disable_notification=PUBLISH_SILENTLY,
|
||||||
|
disable_web_page_preview=True
|
||||||
|
)
|
||||||
|
message_id = message.message_id
|
||||||
|
logger.info(f"Текстовое сообщение (fallback) успешно опубликовано. Message ID: {message_id}")
|
||||||
|
break # Успешная публикация, выходим из цикла
|
||||||
|
except TimedOut as e:
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.warning(f"Таймаут при отправке текста (fallback) (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
|
||||||
|
await asyncio.sleep(retry_interval)
|
||||||
|
else:
|
||||||
|
logger.error(f"Таймаут при отправке текста (fallback) после {max_retries} попыток для записи VK ID {vk_post_id}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
except TelegramError as e:
|
||||||
|
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||||
|
retry_after = getattr(e, 'retry_after', 60)
|
||||||
|
raise RetryAfterException(retry_after)
|
||||||
|
logger.error(f"Ошибка Telegram при отправке текста (fallback) для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Неожиданная ошибка при отправке текста (fallback) для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# Если это организационное сообщение или нет изображения, отправляем текстовое сообщение
|
||||||
|
message_has_image = False # Отправляем текстовое сообщение
|
||||||
|
|
||||||
|
# Повторные попытки отправки текста при таймауте
|
||||||
|
message = None
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
message = await bot.send_message(
|
||||||
|
chat_id=CHANNEL_ID,
|
||||||
|
text=text,
|
||||||
|
parse_mode='HTML',
|
||||||
|
disable_notification=PUBLISH_SILENTLY,
|
||||||
|
disable_web_page_preview=True
|
||||||
|
)
|
||||||
|
message_id = message.message_id
|
||||||
|
logger.info(f"Текстовое сообщение успешно опубликовано. Message ID: {message_id}")
|
||||||
|
break # Успешная публикация, выходим из цикла
|
||||||
|
except TimedOut as e:
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.warning(f"Таймаут при публикации текста (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
|
||||||
|
logger.warning(f"Детали таймаута: {type(e).__name__}: {e}")
|
||||||
|
await asyncio.sleep(retry_interval)
|
||||||
|
else:
|
||||||
|
logger.error(f"Таймаут при публикации текста после {max_retries} попыток для записи VK ID {vk_post_id}")
|
||||||
|
logger.error(f"Детали ошибки: {type(e).__name__}: {e}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
raise # Пробрасываем таймаут наверх
|
||||||
|
except TelegramError as e:
|
||||||
|
# Проверяем, является ли это ошибкой FloodWait (429)
|
||||||
|
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||||
|
retry_after = getattr(e, 'retry_after', 60)
|
||||||
|
raise RetryAfterException(retry_after)
|
||||||
|
logger.error(f"Ошибка Telegram при публикации текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||||
|
logger.error(f"Детали ошибки: {repr(e)}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Неожиданная ошибка при публикации текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||||
|
logger.error(f"Детали ошибки: {repr(e)}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if message_id is None:
|
||||||
|
raise Exception("Не удалось опубликовать текстовое сообщение после всех попыток")
|
||||||
|
|
||||||
|
# Формируем финальный текст с ссылками
|
||||||
|
if links:
|
||||||
|
# Обновляем ссылку подписки с реальным message_id
|
||||||
|
updated_links = []
|
||||||
|
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||||
|
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
|
||||||
|
|
||||||
|
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||||
|
updated_links.append(
|
||||||
|
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)}">🔜 Подписка</a>'
|
||||||
|
)
|
||||||
|
|
||||||
|
links_line = " | ".join(updated_links)
|
||||||
|
final_text = f"{text}\n\n{links_line}"
|
||||||
|
expected_urls = []
|
||||||
|
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||||
|
expected_urls.append(post['vk_post_url'])
|
||||||
|
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||||
|
expected_urls.append(
|
||||||
|
subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
|
||||||
|
)
|
||||||
|
if not await _edit_post_add_links(
|
||||||
|
bot, message_id, message_has_image, final_text, vk_post_id,
|
||||||
|
expected_urls,
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
|
||||||
|
f"но ссылки не добавлены — published_in_tg не обновляем"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
await asyncio.sleep(2) # задержка между обращениями к телеграм
|
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:
|
try:
|
||||||
await bot.edit_message_caption(
|
# Парсим варианты ответа
|
||||||
chat_id=CHANNEL_ID,
|
options = json.loads(post['poll_options'])
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.sleep(2) # задержка между обращениями к телеграм
|
# Подготавливаем параметры для опроса
|
||||||
|
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, если указан
|
||||||
poll_message_id = None
|
if post['poll_end_date']:
|
||||||
if post['is_poll'] and post['poll_question'] and post['poll_options']:
|
# Преобразуем datetime в Unix timestamp
|
||||||
try:
|
if isinstance(post['poll_end_date'], datetime):
|
||||||
# Парсим варианты ответа
|
poll_kwargs['close_date'] = int(post['poll_end_date'].timestamp())
|
||||||
options = json.loads(post['poll_options'])
|
else:
|
||||||
poll_options = [PollOption(option, 0) for option in options]
|
poll_kwargs['close_date'] = post['poll_end_date']
|
||||||
|
|
||||||
# Отправляем опрос (в каналах можно отправлять только анонимные опросы)
|
# Повторные попытки отправки опроса при таймауте
|
||||||
poll_message = await bot.send_poll(
|
poll_message = None
|
||||||
chat_id=CHANNEL_ID,
|
for attempt in range(1, max_retries + 1):
|
||||||
question=post['poll_question'],
|
try:
|
||||||
options=poll_options,
|
poll_message = await bot.send_poll(**poll_kwargs)
|
||||||
is_anonymous=True, # В каналах можно отправлять только анонимные опросы
|
poll_message_id = poll_message.message_id
|
||||||
allows_multiple_answers=post['poll_multiple'],
|
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
|
||||||
close_date=post['poll_end_date'] if post['poll_end_date'] else None,
|
break # Успешная публикация, выходим из цикла
|
||||||
disable_notification=True
|
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
|
||||||
|
|
||||||
poll_message_id = poll_message.message_id
|
except RetryAfterException:
|
||||||
logger.info(f"Опубликован опрос для записи VK ID {vk_post_id}")
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
# Проверяем, что message_id был получен (публикация успешна)
|
||||||
logger.error(f"Ошибка при публикации опроса для записи VK ID {vk_post_id}: {e}")
|
if message_id is None:
|
||||||
|
logger.error(f"Не удалось получить message_id для записи VK ID {vk_post_id}. Публикация не завершена.")
|
||||||
|
return False
|
||||||
|
|
||||||
# Обновляем запись в базе данных
|
# Обновляем запись в базе данных только если публикация успешна
|
||||||
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
publication_successful = True
|
||||||
cursor.execute("""
|
current_time_utc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
UPDATE posts
|
cursor.execute("""
|
||||||
SET published_in_tg = True,
|
UPDATE posts
|
||||||
tg_publication_date = %s,
|
SET published_in_tg = True,
|
||||||
tg_message_id = %s,
|
tg_publication_date = %s,
|
||||||
tg_poll_id = %s
|
tg_message_id = %s,
|
||||||
WHERE vk_post_id = %s
|
tg_poll_id = %s
|
||||||
""", (current_time_utc, message_id, poll_message_id, vk_post_id))
|
WHERE vk_post_id = %s
|
||||||
conn.commit()
|
""", (current_time_utc, message_id, poll_message_id, vk_post_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
logger.info(f"Запись VK ID {vk_post_id} успешно опубликована")
|
logger.info(f"Запись VK ID {vk_post_id} успешно опубликована")
|
||||||
return True
|
return True
|
||||||
|
finally:
|
||||||
|
await httpx_client.aclose()
|
||||||
|
|
||||||
except RetryAfter as e:
|
except RetryAfterException as e:
|
||||||
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}: {e}")
|
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}: {e}")
|
||||||
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
|
logger.error(f"Необходимо подождать {e.retry_after} секунд перед следующей попыткой")
|
||||||
raise
|
raise
|
||||||
except TelegramError as e:
|
except TimedOut as e:
|
||||||
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {e}")
|
logger.error(f"ТАЙМАУТ при публикации записи VK ID {vk_post_id}")
|
||||||
|
logger.error(f"Тип исключения: {type(e).__name__}")
|
||||||
|
logger.error(f"Сообщение об ошибке: {str(e)}")
|
||||||
|
logger.error(f"Полная информация об исключении: {repr(e)}")
|
||||||
|
logger.error(f"Трассировка стека:\n{traceback.format_exc()}")
|
||||||
|
# Убеждаемся, что БД не обновляется при таймауте
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Неожиданная ошибка при публикации записи VK ID {vk_post_id}: {e}")
|
error_msg = str(e)
|
||||||
|
if error_msg.startswith("RetryAfter:") or (hasattr(e, 'retry_after')):
|
||||||
|
retry_after = getattr(e, 'retry_after', int(error_msg.split(":")[1]) if ":" in error_msg else 60)
|
||||||
|
logger.error(f"Получена ошибка FloodWait (429) при публикации записи VK ID {vk_post_id}")
|
||||||
|
logger.error(f"Необходимо подождать {retry_after} секунд перед следующей попыткой")
|
||||||
|
raise RetryAfterException(retry_after)
|
||||||
|
logger.error(f"Ошибка Telegram при публикации записи VK ID {vk_post_id}: {type(e).__name__}: {e}")
|
||||||
|
logger.error(f"Детали ошибки: {repr(e)}")
|
||||||
|
# Убеждаемся, что БД не обновляется при ошибке
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
@@ -304,7 +691,7 @@ async def publish_to_tg_all():
|
|||||||
if post_count < len(posts):
|
if post_count < len(posts):
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
except RetryAfter as e:
|
except RetryAfterException as e:
|
||||||
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
|
logger.error(f"Прерываем публикацию из-за ошибки FloodWait. Ожидание: {e.retry_after} секунд")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Пример скрипта для отправки сообщений через Telegram бота.
|
||||||
|
|
||||||
|
Использование (из корня проекта):
|
||||||
|
python tools/dev/send_telegram_example.py <user_id> <message>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
def send_message(user_id, message_text, bot_url=None, api_token=None):
|
||||||
|
"""
|
||||||
|
Отправляет сообщение пользователю через Telegram бота
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (int): ID пользователя в Telegram
|
||||||
|
message_text (str): Текст сообщения
|
||||||
|
bot_url (str): URL бота (если не указан, берется из переменных окружения)
|
||||||
|
api_token (str): API токен для аутентификации (если не указан, берется из переменных окружения)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success: bool, message: str)
|
||||||
|
"""
|
||||||
|
if bot_url is None:
|
||||||
|
# Пытаемся получить URL из переменных окружения
|
||||||
|
webhook_url = os.getenv('WEBHOOK_URL')
|
||||||
|
webhook_port = os.getenv('WEBHOOK_PORT', '5005')
|
||||||
|
|
||||||
|
if webhook_url:
|
||||||
|
# Используем внешний URL через reverse proxy
|
||||||
|
bot_url = f"{webhook_url}/send_message"
|
||||||
|
else:
|
||||||
|
bot_url = f"http://localhost:{webhook_port}/send_message"
|
||||||
|
|
||||||
|
if api_token is None:
|
||||||
|
api_token = os.getenv('WEBHOOK_SECRET')
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{bot_url}/send_message"
|
||||||
|
data = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"message": message_text
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-API-Secret-Token': api_token
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, json=data, headers=headers, timeout=10)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if response.status_code == 200 and result.get("success"):
|
||||||
|
return True, result.get("message", "Сообщение отправлено успешно")
|
||||||
|
else:
|
||||||
|
return False, result.get("error", "Неизвестная ошибка")
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
return False, f"Ошибка сети: {e}"
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return False, f"Ошибка парсинга ответа: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Неожиданная ошибка: {e}"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("Использование: python tools/dev/send_telegram_example.py <user_id> <message>")
|
||||||
|
print("Пример: python tools/dev/send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = int(sys.argv[1])
|
||||||
|
except ValueError:
|
||||||
|
print("Ошибка: user_id должен быть числом")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
message_text = sys.argv[2]
|
||||||
|
|
||||||
|
print(f"Отправка сообщения пользователю {user_id}: {message_text}")
|
||||||
|
|
||||||
|
success, result_message = send_message(user_id, message_text)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print(f"✅ {result_message}")
|
||||||
|
else:
|
||||||
|
print(f"❌ {result_message}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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 os
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
from mysql.connector import Error
|
from mysql.connector import Error
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Загрузка переменных окружения
|
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
MDB_HOST = os.getenv('MDB_HOST')
|
MDB_HOST = os.getenv('MDB_HOST')
|
||||||
@@ -12,16 +21,15 @@ MDB_USER = os.getenv('MDB_USER')
|
|||||||
MDB_PW = os.getenv('MDB_PW')
|
MDB_PW = os.getenv('MDB_PW')
|
||||||
MDBASE = os.getenv('MDBASE')
|
MDBASE = os.getenv('MDBASE')
|
||||||
|
|
||||||
|
|
||||||
def prefetch_all_events():
|
def prefetch_all_events():
|
||||||
"""
|
"""
|
||||||
Проверяет записи в таблице events и устанавливает флаг marked_to_publication
|
Проверяет записи в таблице events и устанавливает флаг marked_to_publication
|
||||||
для записей, которые:
|
для записей, которые:
|
||||||
- имеют флаг is_visible = 1
|
- имеют флаг is_visible = 1
|
||||||
- не имеют флага marked_to_publication = 1
|
- не имеют флага marked_to_publication = 1
|
||||||
- modified более часа назад от текущего времени (UTC)
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Подключение к базе данных
|
|
||||||
connection = mysql.connector.connect(
|
connection = mysql.connector.connect(
|
||||||
host=MDB_HOST,
|
host=MDB_HOST,
|
||||||
user=MDB_USER,
|
user=MDB_USER,
|
||||||
@@ -29,35 +37,24 @@ def prefetch_all_events():
|
|||||||
database=MDBASE
|
database=MDBASE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Установка временной зоны соединения в UTC
|
|
||||||
cursor_temp = connection.cursor()
|
cursor_temp = connection.cursor()
|
||||||
cursor_temp.execute("SET time_zone = '+00:00'")
|
cursor_temp.execute("SET time_zone = '+00:00'")
|
||||||
cursor_temp.close()
|
cursor_temp.close()
|
||||||
|
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
|
|
||||||
# Текущее время в UTC
|
|
||||||
current_time_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
||||||
one_hour_ago = current_time_utc - timedelta(hours=1)
|
|
||||||
|
|
||||||
# SQL запрос для обновления записей
|
|
||||||
update_query = """
|
update_query = """
|
||||||
UPDATE events
|
UPDATE events
|
||||||
SET marked_to_publication = 1
|
SET marked_to_publication = 1
|
||||||
WHERE is_visible = 1
|
WHERE is_visible = 1
|
||||||
AND marked_to_publication = 0
|
AND marked_to_publication = 0
|
||||||
AND modified < %s
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Выполнение запроса
|
cursor.execute(update_query)
|
||||||
cursor.execute(update_query, (one_hour_ago,))
|
|
||||||
updated_count = cursor.rowcount
|
updated_count = cursor.rowcount
|
||||||
|
|
||||||
# Фиксация изменений
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
print(f"Обновлено записей: {updated_count}")
|
print(f"Обновлено записей: {updated_count}")
|
||||||
|
|
||||||
return updated_count
|
return updated_count
|
||||||
|
|
||||||
except Error as e:
|
except Error as e:
|
||||||
@@ -73,5 +70,6 @@ def prefetch_all_events():
|
|||||||
cursor.close()
|
cursor.close()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
prefetch_all_events()
|
prefetch_all_events()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Использование (из корня проекта):
|
||||||
|
# bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
|
||||||
|
# Пример:
|
||||||
|
# bash tools/ops/copy_databases.sh old_db new_db appuser
|
||||||
|
|
||||||
|
set -e # Завершать выполнение при любой ошибке
|
||||||
|
|
||||||
|
if [ "$#" -ne 3 ]; then
|
||||||
|
echo "Использование: $0 <исходная_база> <целевая_база> <имя_пользователя>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE_DB="$1"
|
||||||
|
TARGET_DB="$2"
|
||||||
|
DB_USER="$3"
|
||||||
|
BACKUP_DIR="."
|
||||||
|
|
||||||
|
# Функция для запроса пароля от MariaDB один раз
|
||||||
|
read_mysql_password() {
|
||||||
|
if [ -z "$MYSQL_PWD" ]; then
|
||||||
|
read -s -p "Введите пароль MariaDB для пользователя root: " MYSQL_PWD
|
||||||
|
echo
|
||||||
|
export MYSQL_PWD
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция проверки существования базы данных
|
||||||
|
db_exists() {
|
||||||
|
local db_name="$1"
|
||||||
|
if mysql -u root -e "SHOW DATABASES LIKE '$db_name';" | grep -q "^$db_name$"; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция резервного копирования базы
|
||||||
|
backup_db() {
|
||||||
|
local db_name="$1"
|
||||||
|
local backup_file="${BACKUP_DIR}/${db_name}.backup.sql"
|
||||||
|
echo "Создаётся резервная копия базы '$db_name' в файл '$backup_file'..."
|
||||||
|
mysqldump -u root \
|
||||||
|
--routines \
|
||||||
|
--triggers \
|
||||||
|
--events \
|
||||||
|
--single-transaction \
|
||||||
|
"$db_name" > "$backup_file"
|
||||||
|
echo "Резервная копия '$db_name' успешно создана."
|
||||||
|
}
|
||||||
|
|
||||||
|
# === НАЧАЛО ОСНОВНОГО СЦЕНАРИЯ ===
|
||||||
|
|
||||||
|
read_mysql_password
|
||||||
|
|
||||||
|
echo "Проверка существования исходной базы '$SOURCE_DB'..."
|
||||||
|
if ! db_exists "$SOURCE_DB"; then
|
||||||
|
echo "Ошибка: исходная база данных '$SOURCE_DB' не существует."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Резервное копирование исходной базы (всегда)
|
||||||
|
backup_db "$SOURCE_DB"
|
||||||
|
|
||||||
|
# Проверка существования целевой базы
|
||||||
|
if db_exists "$TARGET_DB"; then
|
||||||
|
echo "Целевая база '$TARGET_DB' уже существует."
|
||||||
|
backup_db "$TARGET_DB"
|
||||||
|
echo "Удаление существующей базы '$TARGET_DB'..."
|
||||||
|
mysql -u root -e "DROP DATABASE \`$TARGET_DB\`;"
|
||||||
|
else
|
||||||
|
echo "Целевая база '$TARGET_DB' не существует — будет создана."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Создание новой целевой базы
|
||||||
|
echo "Создание новой базы '$TARGET_DB'..."
|
||||||
|
mysql -u root -e "CREATE DATABASE \`$TARGET_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
|
||||||
|
# Создание временного дампа без указания базы (без CREATE DATABASE и USE)
|
||||||
|
TEMP_DUMP=$(mktemp)
|
||||||
|
echo "Создание временного дампа из '$SOURCE_DB'..."
|
||||||
|
mysqldump -u root \
|
||||||
|
--routines \
|
||||||
|
--triggers \
|
||||||
|
--events \
|
||||||
|
--single-transaction \
|
||||||
|
--no-create-db \
|
||||||
|
"$SOURCE_DB" > "$TEMP_DUMP"
|
||||||
|
|
||||||
|
# Восстановление в целевую базу
|
||||||
|
echo "Восстановление данных в '$TARGET_DB'..."
|
||||||
|
mysql -u root "$TARGET_DB" < "$TEMP_DUMP"
|
||||||
|
|
||||||
|
# Удаление временного файла
|
||||||
|
rm -f "$TEMP_DUMP"
|
||||||
|
|
||||||
|
# Настройка прав пользователя
|
||||||
|
echo "Настройка прав для пользователя '$DB_USER'@'%' на базу '$TARGET_DB'..."
|
||||||
|
mysql -u root -e "CREATE USER IF NOT EXISTS \`$DB_USER\`@'%';"
|
||||||
|
mysql -u root -e "GRANT ALL PRIVILEGES ON \`$TARGET_DB\`.* TO \`$DB_USER\`@'%';"
|
||||||
|
mysql -u root -e "FLUSH PRIVILEGES;"
|
||||||
|
|
||||||
|
echo "✅ Копирование завершено успешно!"
|
||||||
|
echo "Исходная база: $SOURCE_DB → Целевая база: $TARGET_DB"
|
||||||
|
echo "Пользователь: $DB_USER@% имеет полные права на целевую базу."
|
||||||
@@ -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())
|
||||||
+97
-12
@@ -18,6 +18,15 @@ VK_ACCESS_TOKEN = os.getenv('VK_ACCESS_TOKEN', '')
|
|||||||
VK_API_VERSION = os.getenv('VK_API_VERSION', '5.131')
|
VK_API_VERSION = os.getenv('VK_API_VERSION', '5.131')
|
||||||
IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on')
|
IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
|
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации
|
||||||
|
# Минимум букв (isalpha), чтобы пост не считался пустым (видео без описания и т.п.)
|
||||||
|
try:
|
||||||
|
VK_POST_MIN_LETTERS = int((os.getenv('VK_POST_MIN_LETTERS') or '5').strip().strip('"').strip("'"))
|
||||||
|
except ValueError:
|
||||||
|
VK_POST_MIN_LETTERS = 5
|
||||||
|
if VK_POST_MIN_LETTERS < 0:
|
||||||
|
VK_POST_MIN_LETTERS = 0
|
||||||
|
EMPTY_POST_SHORTNAME = "- = Пустой пост = -"
|
||||||
LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
|
LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
|
||||||
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
|
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
|
||||||
|
|
||||||
@@ -62,7 +71,7 @@ def get_vk_posts():
|
|||||||
url = 'https://api.vk.ru/method/wall.get'
|
url = 'https://api.vk.ru/method/wall.get'
|
||||||
params = {
|
params = {
|
||||||
'owner_id': VK_GROUP_ID,
|
'owner_id': VK_GROUP_ID,
|
||||||
'count': 10,
|
'count': 20,
|
||||||
'access_token': VK_ACCESS_TOKEN,
|
'access_token': VK_ACCESS_TOKEN,
|
||||||
'v': VK_API_VERSION,
|
'v': VK_API_VERSION,
|
||||||
'extended': 1
|
'extended': 1
|
||||||
@@ -85,12 +94,37 @@ def get_vk_posts():
|
|||||||
log_message(error_msg)
|
log_message(error_msg)
|
||||||
raise Exception(error_msg)
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def count_letters(text):
|
||||||
|
"""Количество букв в тексте (Unicode isalpha), без цифр и знаков."""
|
||||||
|
return sum(1 for c in (text or "") if c.isalpha())
|
||||||
|
|
||||||
|
|
||||||
def process_post(post):
|
def process_post(post):
|
||||||
"""Извлекает необходимые данные из поста VK"""
|
"""Извлекает необходимые данные из поста VK, включая обработку репостов"""
|
||||||
post_id = post['id']
|
post_id = post['id']
|
||||||
vk_post_url = f"https://vk.ru/wall{VK_GROUP_ID}_{post_id}"
|
vk_post_url = f"https://vk.ru/wall{VK_GROUP_ID}_{post_id}"
|
||||||
published_at = datetime.utcfromtimestamp(post['date']).strftime('%Y-%m-%d %H:%M:%S')
|
published_at = datetime.utcfromtimestamp(post['date']).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# Проверка, является ли пост репостом
|
||||||
|
is_repost = 'copy_history' in post and len(post.get('copy_history', [])) > 0
|
||||||
|
|
||||||
|
# Если это репост, используем данные из оригинального поста
|
||||||
|
if is_repost:
|
||||||
|
original_post = post['copy_history'][0] # Берем первый элемент (обычно он один)
|
||||||
|
# Используем данные из оригинального поста
|
||||||
|
source_post = original_post
|
||||||
|
# Сохраняем информацию о том, что это репост, в тексте
|
||||||
|
repost_prefix = f"[Репост] "
|
||||||
|
original_owner_id = original_post.get('owner_id', 0)
|
||||||
|
original_post_id = original_post.get('id', 0)
|
||||||
|
original_url = f"https://vk.ru/wall{original_owner_id}_{original_post_id}"
|
||||||
|
repost_info = f"\n\n[Оригинальный пост: {original_url}]"
|
||||||
|
else:
|
||||||
|
source_post = post
|
||||||
|
repost_prefix = ""
|
||||||
|
repost_info = ""
|
||||||
|
|
||||||
# Инициализация полей опроса
|
# Инициализация полей опроса
|
||||||
is_poll = 0
|
is_poll = 0
|
||||||
poll_question = None
|
poll_question = None
|
||||||
@@ -101,8 +135,8 @@ def process_post(post):
|
|||||||
# Инициализация поля изображения
|
# Инициализация поля изображения
|
||||||
image_url = None
|
image_url = None
|
||||||
|
|
||||||
# Обработка вложений
|
# Обработка вложений из оригинального поста (если это репост) или текущего поста
|
||||||
for attachment in post.get('attachments', []):
|
for attachment in source_post.get('attachments', []):
|
||||||
if attachment['type'] == 'poll':
|
if attachment['type'] == 'poll':
|
||||||
is_poll = 1
|
is_poll = 1
|
||||||
poll = attachment['poll']
|
poll = attachment['poll']
|
||||||
@@ -122,9 +156,21 @@ def process_post(post):
|
|||||||
max_size = max(sizes, key=lambda s: s.get('width', 0) * s.get('height', 0))
|
max_size = max(sizes, key=lambda s: s.get('width', 0) * s.get('height', 0))
|
||||||
image_url = max_size['url']
|
image_url = max_size['url']
|
||||||
|
|
||||||
|
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
|
||||||
|
post_text = source_post.get('text', '')
|
||||||
|
own_text = post.get('text', '')
|
||||||
|
letter_count = count_letters(post_text)
|
||||||
|
if is_repost:
|
||||||
|
letter_count += count_letters(own_text)
|
||||||
|
# Если у репоста есть свой текст, добавляем его перед текстом оригинала
|
||||||
|
if own_text.strip():
|
||||||
|
post_text = f"{own_text}\n\n---\n\n{post_text}"
|
||||||
|
post_text = repost_prefix + post_text + repost_info
|
||||||
|
is_empty_text = letter_count < VK_POST_MIN_LETTERS
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'vk_post_id': post_id,
|
'vk_post_id': post_id,
|
||||||
'text': post['text'],
|
'text': post_text,
|
||||||
'image_url': image_url,
|
'image_url': image_url,
|
||||||
'vk_post_url': vk_post_url,
|
'vk_post_url': vk_post_url,
|
||||||
'published_at': published_at,
|
'published_at': published_at,
|
||||||
@@ -132,7 +178,10 @@ def process_post(post):
|
|||||||
'poll_question': poll_question,
|
'poll_question': poll_question,
|
||||||
'poll_options': poll_options,
|
'poll_options': poll_options,
|
||||||
'poll_multiple': poll_multiple,
|
'poll_multiple': poll_multiple,
|
||||||
'poll_end_date': poll_end_date
|
'poll_end_date': poll_end_date,
|
||||||
|
'is_repost': is_repost,
|
||||||
|
'is_empty_text': is_empty_text,
|
||||||
|
'letter_count': letter_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
def save_to_database(posts):
|
def save_to_database(posts):
|
||||||
@@ -144,6 +193,19 @@ def save_to_database(posts):
|
|||||||
new_posts_count = 0
|
new_posts_count = 0
|
||||||
for post in posts:
|
for post in posts:
|
||||||
try:
|
try:
|
||||||
|
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
|
||||||
|
post_text = post.get('text', '')
|
||||||
|
is_empty_text = post.get('is_empty_text', False)
|
||||||
|
should_auto_publish = AUTO_PUBLISH
|
||||||
|
shortname = None
|
||||||
|
|
||||||
|
if is_empty_text:
|
||||||
|
should_auto_publish = False
|
||||||
|
shortname = EMPTY_POST_SHORTNAME
|
||||||
|
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
|
||||||
|
# Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH
|
||||||
|
should_auto_publish = False
|
||||||
|
|
||||||
# Добавляем поля is_event, marked_for_publication, shortname и action_number
|
# Добавляем поля is_event, marked_for_publication, shortname и action_number
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO posts (
|
INSERT INTO posts (
|
||||||
@@ -164,14 +226,33 @@ def save_to_database(posts):
|
|||||||
int(post['poll_multiple']),
|
int(post['poll_multiple']),
|
||||||
post['poll_end_date'],
|
post['poll_end_date'],
|
||||||
IS_EVENT,
|
IS_EVENT,
|
||||||
int(AUTO_PUBLISH), # Значение из .env
|
int(should_auto_publish),
|
||||||
None, # shortname - пока не используется, устанавливаем NULL
|
shortname,
|
||||||
None # action_number - пока не используется, устанавливаем NULL
|
None # action_number
|
||||||
))
|
))
|
||||||
if cursor.rowcount > 0:
|
if cursor.rowcount > 0:
|
||||||
new_posts_count += 1
|
new_posts_count += 1
|
||||||
# Логируем добавление нового поста
|
# Логируем добавление нового поста с указанием причины отключения автопубликации
|
||||||
log_message(f"Добавлен новый пост: ID {post['vk_post_id']}, Дата: {post['published_at']}, Автопубликация: {'Да' if AUTO_PUBLISH else 'Нет'}")
|
auto_publish_reason = 'Да'
|
||||||
|
if not should_auto_publish:
|
||||||
|
if is_empty_text:
|
||||||
|
auto_publish_reason = (
|
||||||
|
f'Нет (пустой текст: {post.get("letter_count", 0)} букв '
|
||||||
|
f'< {VK_POST_MIN_LETTERS})'
|
||||||
|
)
|
||||||
|
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
|
||||||
|
auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')'
|
||||||
|
else:
|
||||||
|
auto_publish_reason = 'Нет'
|
||||||
|
|
||||||
|
# Добавляем информацию о репосте в лог
|
||||||
|
repost_info = ""
|
||||||
|
if post.get('is_repost', False):
|
||||||
|
repost_info = ", Репост: Да"
|
||||||
|
else:
|
||||||
|
repost_info = ", Репост: Нет"
|
||||||
|
|
||||||
|
log_message(f"Добавлен новый пост: ID {post['vk_post_id']}, Дата: {post['published_at']}, Автопубликация: {auto_publish_reason}{repost_info}")
|
||||||
except mysql.connector.Error as err:
|
except mysql.connector.Error as err:
|
||||||
error_msg = f"Ошибка при вставке поста {post['vk_post_id']}: {err}"
|
error_msg = f"Ошибка при вставке поста {post['vk_post_id']}: {err}"
|
||||||
log_message(error_msg)
|
log_message(error_msg)
|
||||||
@@ -195,7 +276,11 @@ def vk_load_10():
|
|||||||
Возвращает количество добавленных постов
|
Возвращает количество добавленных постов
|
||||||
"""
|
"""
|
||||||
# Стартовая информация
|
# Стартовая информация
|
||||||
start_msg = f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH}"
|
start_msg = (
|
||||||
|
f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | "
|
||||||
|
f"IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH} | "
|
||||||
|
f"VK_POST_MIN_LETTERS: {VK_POST_MIN_LETTERS}"
|
||||||
|
)
|
||||||
print(start_msg)
|
print(start_msg)
|
||||||
log_message(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())
|
||||||
|
|
||||||
+566
@@ -0,0 +1,566 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import socket
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.request import urlopen
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import mysql.connector
|
||||||
|
from mysql.connector import Error
|
||||||
|
from ensure_db import ensure_database_structure
|
||||||
|
|
||||||
|
# Загрузка переменных окружения
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
VOLK_CATEGORY_LIST = os.getenv('VOLK_CATEGORY_LIST')
|
||||||
|
VOLK_CATEGORY_DESC_PREFIX = os.getenv('VOLK_CATEGORY_DESC_PREFIX')
|
||||||
|
VOLK_EVENT_LIST_PREFIX = os.getenv('VOLK_EVENT_LIST_PREFIX')
|
||||||
|
VOLK_API_VERSION = os.getenv('VOLK_API_VERSION')
|
||||||
|
MDB_HOST = os.getenv('MDB_HOST')
|
||||||
|
MDB_USER = os.getenv('MDB_USER')
|
||||||
|
MDB_PW = os.getenv('MDB_PW')
|
||||||
|
MDBASE = os.getenv('MDBASE')
|
||||||
|
LOG_FILE = os.getenv('LOG_FILE')
|
||||||
|
AUTO_PUB_EVT = os.getenv('AUTO_PUB_EVT', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
def log_message(message, category="VOLK_loader"):
|
||||||
|
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
log_entry = f"[{timestamp}] [{category}] {message}\n"
|
||||||
|
|
||||||
|
# До 5 попыток записи с увеличением задержки
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with open(LOG_FILE, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(log_entry)
|
||||||
|
return True
|
||||||
|
except (IOError, OSError) as e:
|
||||||
|
if attempt < 4: # Не последняя попытка
|
||||||
|
time.sleep(0.4) # Задержка 400ms между попытками
|
||||||
|
else:
|
||||||
|
# После 5 неудачных попыток просто возвращаем False
|
||||||
|
# Не используем print() чтобы избежать реентерабельных вызовов с gunicorn
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def safe_log_direct(message, category="VOLK_loader"):
|
||||||
|
"""Безопасная функция для прямой записи в лог-файл без обработки исключений
|
||||||
|
Используется в критических ситуациях, когда обычный log_message может не сработать"""
|
||||||
|
try:
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
log_entry = f"[{timestamp}] [{category}] {message}\n"
|
||||||
|
# Прямая запись без контекстного менеджера для максимальной надежности
|
||||||
|
f = open(LOG_FILE, 'a', encoding='utf-8')
|
||||||
|
try:
|
||||||
|
f.write(log_entry)
|
||||||
|
f.flush() # Принудительно сбрасываем буфер
|
||||||
|
finally:
|
||||||
|
f.close()
|
||||||
|
except:
|
||||||
|
# Игнорируем все исключения - эта функция должна быть максимально безопасной
|
||||||
|
pass
|
||||||
|
|
||||||
|
def load_categories():
|
||||||
|
"""Экспортируемая функция для загрузки категорий из JSON в базу данных"""
|
||||||
|
inserted_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_message("Начало работы")
|
||||||
|
|
||||||
|
# Проверка наличия URL
|
||||||
|
if not VOLK_CATEGORY_LIST:
|
||||||
|
error_msg = "Переменная окружения VOLK_CATEGORY_LIST не установлена"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Чтение JSON из URL
|
||||||
|
log_message(f"Чтение данных из {VOLK_CATEGORY_LIST}")
|
||||||
|
with urlopen(VOLK_CATEGORY_LIST) as response:
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
|
||||||
|
# Проверка версии API
|
||||||
|
api_version = data.get('api_version')
|
||||||
|
if api_version != VOLK_API_VERSION:
|
||||||
|
error_msg = f"Несовпадение версии API: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Обработка остановлена."
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
log_message(f"Версия API соответствует: {api_version}")
|
||||||
|
|
||||||
|
# Проверка наличия блока data
|
||||||
|
if 'data' not in data:
|
||||||
|
error_msg = "В JSON отсутствует блок 'data'"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
categories_data = data['data']
|
||||||
|
log_message(f"Получено {len(categories_data)} записей для обработки")
|
||||||
|
|
||||||
|
# Подключение к базе данных
|
||||||
|
connection = mysql.connector.connect(
|
||||||
|
host=MDB_HOST,
|
||||||
|
user=MDB_USER,
|
||||||
|
password=MDB_PW,
|
||||||
|
database=MDBASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Установка временной зоны соединения в UTC
|
||||||
|
cursor_temp = connection.cursor()
|
||||||
|
cursor_temp.execute("SET time_zone = '+00:00'")
|
||||||
|
cursor_temp.close()
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
|
# Проверка структуры БД
|
||||||
|
log_message("Проверка структуры базы данных")
|
||||||
|
ensure_database_structure()
|
||||||
|
|
||||||
|
# Список категорий для последующей загрузки описаний
|
||||||
|
loaded_categories = []
|
||||||
|
|
||||||
|
for item in categories_data:
|
||||||
|
# Проверка обязательного поля ID
|
||||||
|
if 'id' not in item or not item['id']:
|
||||||
|
log_message(f"Пропущена запись без id или с пустым id: {item}", "WARNING")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Подготовка значений
|
||||||
|
category_id = item['id']
|
||||||
|
# TITLE может отсутствовать или быть пустым - в этом случае используем пустую строку
|
||||||
|
title = item.get('title', '') or ''
|
||||||
|
|
||||||
|
# Проверка существующей записи
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT AUTO_ID, ID, TITLE FROM categories WHERE ID = %s",
|
||||||
|
(category_id,)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Вставка новой записи
|
||||||
|
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
|
||||||
|
marked_for_publication = 1 if AUTO_PUB_EVT else 0
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO categories (ID, TITLE, description, marked_for_publication) VALUES (%s, %s, %s, %s)",
|
||||||
|
(category_id, title, None, marked_for_publication)
|
||||||
|
)
|
||||||
|
inserted_count += 1
|
||||||
|
pub_status = "для публикации" if AUTO_PUB_EVT else "не для публикации"
|
||||||
|
log_message(f"Добавление: Категория {category_id}, Название: {title if title else '(пусто)'}, Статус: {pub_status}")
|
||||||
|
else:
|
||||||
|
# Обновление существующей записи (всегда обновляем TITLE, даже если оно пустое)
|
||||||
|
if existing['TITLE'] != title:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE categories SET TITLE = %s WHERE ID = %s",
|
||||||
|
(title, category_id)
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
log_message(f"Обновление: Категория {category_id}, Новое название: {title if title else '(пусто)'}")
|
||||||
|
else:
|
||||||
|
log_message(f"Категория {category_id} без изменений")
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
loaded_categories.append(category_id)
|
||||||
|
|
||||||
|
log_message(f"Загружено категорий: {len(loaded_categories)}. Добавлено записей: {inserted_count}. Обновлено записей: {updated_count}.")
|
||||||
|
|
||||||
|
# Загрузка описаний категорий
|
||||||
|
if VOLK_CATEGORY_DESC_PREFIX and loaded_categories:
|
||||||
|
log_message(f"Начало загрузки описаний для {len(loaded_categories)} категорий")
|
||||||
|
desc_updated_count = 0
|
||||||
|
|
||||||
|
for category_id in loaded_categories:
|
||||||
|
try:
|
||||||
|
desc_url = VOLK_CATEGORY_DESC_PREFIX + category_id
|
||||||
|
log_message(f"Запрос описания для категории {category_id} из {desc_url}")
|
||||||
|
|
||||||
|
desc_response = None
|
||||||
|
try:
|
||||||
|
# Добавляем таймаут для избежания зависаний (30 секунд)
|
||||||
|
from urllib.request import Request
|
||||||
|
req = Request(desc_url, headers={'User-Agent': 'Mozilla/5.0 (Telefaust)'})
|
||||||
|
log_message(f"request response = {req}", "DEBUG")
|
||||||
|
response = urlopen(req, timeout=30)
|
||||||
|
except (URLError, HTTPError, OSError, socket.gaierror, socket.timeout, ConnectionError) as e:
|
||||||
|
# Используем безопасное логирование для критических ошибок
|
||||||
|
safe_log_direct("ПЕРЕХВАЧЕНА СЕТЕВАЯ ОШИБКА urlopen", "ERROR")
|
||||||
|
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
|
||||||
|
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
|
||||||
|
try:
|
||||||
|
traceback_str = traceback.format_exc()
|
||||||
|
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Также пробуем обычное логирование
|
||||||
|
try:
|
||||||
|
error_msg = f"Ошибка сетевого подключения при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
# Перехватываем любые другие исключения, которые могут возникнуть при urlopen
|
||||||
|
safe_log_direct("ПЕРЕХВАЧЕНА НЕОЖИДАННАЯ ОШИБКА urlopen", "ERROR")
|
||||||
|
safe_log_direct(f"Категория: {category_id}, URL: {desc_url}", "ERROR")
|
||||||
|
safe_log_direct(f"Тип ошибки: {type(e).__name__}, Сообщение: {str(e)}", "ERROR")
|
||||||
|
try:
|
||||||
|
traceback_str = traceback.format_exc()
|
||||||
|
safe_log_direct(f"Трассировка:\n{traceback_str}", "ERROR")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Также пробуем обычное логирование
|
||||||
|
try:
|
||||||
|
error_msg = f"Неожиданная ошибка при запросе описания категории {category_id} из {desc_url}: {type(e).__name__}: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with response:
|
||||||
|
# Явно указываем кодировку UTF-8 при декодировании
|
||||||
|
raw_data = response.read()
|
||||||
|
try:
|
||||||
|
decoded_data = raw_data.decode('utf-8')
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
error_msg = f"Ошибка декодирования ответа для категории {category_id}: {e}. Попытка декодирования как latin-1."
|
||||||
|
log_message(error_msg, "WARNING")
|
||||||
|
decoded_data = raw_data.decode('latin-1')
|
||||||
|
|
||||||
|
try:
|
||||||
|
desc_response = json.loads(decoded_data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
error_msg = f"Ошибка парсинга JSON для категории {category_id}: {e}. Первые 500 символов ответа: {decoded_data[:500]}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при чтении ответа для категории {category_id}: {type(e).__name__}: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
traceback_str = traceback.format_exc()
|
||||||
|
log_message(f"Трассировка ошибки чтения для категории {category_id}:\n{traceback_str}", "ERROR")
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверяем, что desc_response был успешно получен
|
||||||
|
if desc_response is None:
|
||||||
|
error_msg = f"Не удалось получить ответ для категории {category_id}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверка версии API
|
||||||
|
api_version = desc_response.get('api_version')
|
||||||
|
if api_version != VOLK_API_VERSION:
|
||||||
|
error_msg = f"Несовпадение версии API при загрузке описания категории {category_id}: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Пропуск категории."
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверка наличия блока data
|
||||||
|
if 'data' not in desc_response:
|
||||||
|
error_msg = f"В JSON ответе для категории {category_id} отсутствует блок 'data'. Пропуск категории."
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку
|
||||||
|
continue
|
||||||
|
|
||||||
|
desc_data = desc_response['data']
|
||||||
|
|
||||||
|
# Проверка типа desc_data - должен быть словарём
|
||||||
|
if not isinstance(desc_data, dict):
|
||||||
|
error_msg = f"Блок 'data' для категории {category_id} не является словарём (тип: {type(desc_data)}). Пропуск категории."
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Получаем description из блока data
|
||||||
|
description = desc_data.get('description')
|
||||||
|
# Если description пустое, None или отсутствует, обнуляем поле в БД
|
||||||
|
if not description:
|
||||||
|
description = None
|
||||||
|
|
||||||
|
# Обработка маркдаун-ссылок на изображения в конце описания
|
||||||
|
image_url = None
|
||||||
|
if description:
|
||||||
|
# Паттерн для поиска маркдаун-ссылки вида  или 
|
||||||
|
# Ищем в конце текста (с учетом пробелов и переносов строк)
|
||||||
|
# Паттерн:  или 
|
||||||
|
# URL может содержать различные символы, включая скобки в URL-кодированном виде
|
||||||
|
markdown_image_pattern = r'!\[\]\(([^)]+?)(?:\s+"[^"]*")?\)\s*$'
|
||||||
|
match = re.search(markdown_image_pattern, description, re.MULTILINE)
|
||||||
|
if match:
|
||||||
|
image_url = match.group(1).strip()
|
||||||
|
# Удаляем маркдаун-ссылку из описания (включая пробелы и переносы строк перед ней)
|
||||||
|
description = re.sub(r'\s*!\[\]\([^)]+?(?:\s+"[^"]*")?\)\s*$', '', description, flags=re.MULTILINE).rstrip()
|
||||||
|
# Если после удаления описание стало пустым, обнуляем его
|
||||||
|
if not description.strip():
|
||||||
|
description = None
|
||||||
|
log_message(f"Извлечен URL изображения для категории {category_id}: {image_url}")
|
||||||
|
|
||||||
|
# Обновляем описание и image_url в базе данных
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE categories SET description = %s, image_url = %s WHERE ID = %s",
|
||||||
|
(description, image_url, category_id)
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
desc_updated_count += 1
|
||||||
|
|
||||||
|
if description:
|
||||||
|
log_message(f"Обновлено описание для категории {category_id}")
|
||||||
|
else:
|
||||||
|
log_message(f"Описание для категории {category_id} обнулено (пустое или отсутствует)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при загрузке описания для категории {category_id}: {type(e).__name__}: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
traceback_str = traceback.format_exc()
|
||||||
|
log_message(f"Трассировка ошибки для категории {category_id}:\n{traceback_str}", "ERROR")
|
||||||
|
# Пропускаем эту категорию и продолжаем обработку остальных
|
||||||
|
continue
|
||||||
|
|
||||||
|
log_message(f"Завершение загрузки описаний. Обновлено описаний: {desc_updated_count}.")
|
||||||
|
|
||||||
|
log_message(f"Завершение работы. Добавлено записей: {inserted_count}. Обновлено записей: {updated_count}.")
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
error_msg = f"Ошибка базы данных: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
if 'connection' in locals() and connection.is_connected():
|
||||||
|
connection.rollback()
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Общая ошибка: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
finally:
|
||||||
|
if 'connection' in locals() and connection.is_connected():
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def load_events_by_category(category_id):
|
||||||
|
"""Экспортируемая функция для загрузки событий категории из JSON в базу данных"""
|
||||||
|
inserted_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_message(f"Начало загрузки событий для категории {category_id}")
|
||||||
|
|
||||||
|
# Проверка наличия URL префикса
|
||||||
|
if not VOLK_EVENT_LIST_PREFIX:
|
||||||
|
error_msg = "Переменная окружения VOLK_EVENT_LIST_PREFIX не установлена"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверка наличия category_id
|
||||||
|
if not category_id:
|
||||||
|
error_msg = "ID категории не указан"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Формирование URL для запроса
|
||||||
|
event_url = VOLK_EVENT_LIST_PREFIX + category_id
|
||||||
|
log_message(f"Чтение данных из {event_url}")
|
||||||
|
|
||||||
|
# Чтение JSON из URL
|
||||||
|
with urlopen(event_url) as response:
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
|
||||||
|
# Проверка версии API
|
||||||
|
api_version = data.get('api_version')
|
||||||
|
if api_version != VOLK_API_VERSION:
|
||||||
|
error_msg = f"Несовпадение версии API: получена версия '{api_version}', ожидается '{VOLK_API_VERSION}'. Обработка остановлена."
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
log_message(f"Версия API соответствует: {api_version}")
|
||||||
|
|
||||||
|
# Проверка наличия блока data
|
||||||
|
if 'data' not in data:
|
||||||
|
error_msg = "В JSON отсутствует блок 'data'"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
return
|
||||||
|
|
||||||
|
events_data = data['data']
|
||||||
|
log_message(f"Получено {len(events_data)} событий для обработки")
|
||||||
|
|
||||||
|
# Подключение к базе данных
|
||||||
|
connection = mysql.connector.connect(
|
||||||
|
host=MDB_HOST,
|
||||||
|
user=MDB_USER,
|
||||||
|
password=MDB_PW,
|
||||||
|
database=MDBASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Установка временной зоны соединения в UTC
|
||||||
|
cursor_temp = connection.cursor()
|
||||||
|
cursor_temp.execute("SET time_zone = '+00:00'")
|
||||||
|
cursor_temp.close()
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
|
# Проверка структуры БД
|
||||||
|
log_message("Проверка структуры базы данных")
|
||||||
|
ensure_database_structure()
|
||||||
|
|
||||||
|
# Получаем максимальный number для всей таблицы, чтобы продолжить нумерацию
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT MAX(number) as max_number FROM events"
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
max_number = result['max_number'] or 0
|
||||||
|
start_number = max_number + 1
|
||||||
|
|
||||||
|
# Обработка событий
|
||||||
|
current_number = start_number
|
||||||
|
for item in events_data:
|
||||||
|
# Проверка обязательного поля id
|
||||||
|
if 'id' not in item or item['id'] is None:
|
||||||
|
log_message(f"Пропущено событие без id: {item}", "WARNING")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Проверка обязательного поля title
|
||||||
|
if 'title' not in item or not item['title']:
|
||||||
|
log_message(f"Пропущено событие без title (id: {item.get('id', 'N/A')}): {item}", "WARNING")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Подготовка значений с маппингом полей
|
||||||
|
# Преобразуем id в число, если это необходимо
|
||||||
|
try:
|
||||||
|
id_event = int(item['id']) # Уникальный идентификатор события из JSON
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
log_message(f"Пропущено событие с некорректным id (не число): {item.get('id', 'N/A')}", "WARNING")
|
||||||
|
continue
|
||||||
|
name = item.get('title', '')
|
||||||
|
about = item.get('announce', '') or ''
|
||||||
|
authors = item.get('authors', '') or ''
|
||||||
|
about_social_picture = item.get('image', '') or ''
|
||||||
|
unit_name = category_id
|
||||||
|
|
||||||
|
# Проверка существующей записи по id_event (уникальный идентификатор)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, id_event, name, authors, about, about_social_picture FROM events WHERE id_event = %s",
|
||||||
|
(id_event,)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Вставка новой записи
|
||||||
|
# Используем id из JSON для id_event, а для number используем автоинкремент
|
||||||
|
# Устанавливаем флаг публикации, если AUTO_PUB_EVT включен
|
||||||
|
marked_to_publication = 1 if AUTO_PUB_EVT else 0
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO events (id_event, number, unit_name, name, about, about_social_picture, authors, marked_to_publication)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
|
(id_event, current_number, unit_name, name, about, about_social_picture, authors, marked_to_publication)
|
||||||
|
)
|
||||||
|
inserted_count += 1
|
||||||
|
pub_status = "для публикации" if AUTO_PUB_EVT else "не для публикации"
|
||||||
|
log_message(f"Добавление: Событие '{name}', id_event: {id_event}, номер: {current_number}, категория: {category_id}, Статус: {pub_status}")
|
||||||
|
current_number += 1
|
||||||
|
else:
|
||||||
|
# Обновление существующей записи
|
||||||
|
needs_update = False
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
if existing['name'] != name:
|
||||||
|
needs_update = True
|
||||||
|
update_fields.append('name')
|
||||||
|
if existing['authors'] != authors:
|
||||||
|
needs_update = True
|
||||||
|
update_fields.append('authors')
|
||||||
|
if existing['about'] != about:
|
||||||
|
needs_update = True
|
||||||
|
update_fields.append('about')
|
||||||
|
if existing['about_social_picture'] != about_social_picture:
|
||||||
|
needs_update = True
|
||||||
|
update_fields.append('about_social_picture')
|
||||||
|
|
||||||
|
if needs_update:
|
||||||
|
cursor.execute(
|
||||||
|
"""UPDATE events SET name = %s, authors = %s, about = %s, about_social_picture = %s
|
||||||
|
WHERE id_event = %s""",
|
||||||
|
(name, authors, about, about_social_picture, id_event)
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
log_message(f"Обновление: Событие id_event={id_event} '{name}', обновлены поля: {', '.join(update_fields)}")
|
||||||
|
else:
|
||||||
|
log_message(f"Событие id_event={id_event} '{name}' без изменений")
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
log_message(f"Завершение работы. Добавлено событий: {inserted_count}. Обновлено событий: {updated_count}.")
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
error_msg = f"Ошибка базы данных: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
if 'connection' in locals() and connection.is_connected():
|
||||||
|
connection.rollback()
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Общая ошибка: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
finally:
|
||||||
|
if 'connection' in locals() and connection.is_connected():
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def load_all_events():
|
||||||
|
"""Экспортируемая функция для загрузки всех событий по всем категориям"""
|
||||||
|
try:
|
||||||
|
log_message("Начало загрузки всех событий по всем категориям")
|
||||||
|
|
||||||
|
# Подключение к базе данных
|
||||||
|
connection = mysql.connector.connect(
|
||||||
|
host=MDB_HOST,
|
||||||
|
user=MDB_USER,
|
||||||
|
password=MDB_PW,
|
||||||
|
database=MDBASE
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
|
||||||
|
# Получаем список всех категорий
|
||||||
|
cursor.execute("SELECT ID FROM categories ORDER BY ID")
|
||||||
|
categories = cursor.fetchall()
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
log_message("В базе данных нет категорий для загрузки событий", "WARNING")
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
log_message(f"Найдено {len(categories)} категорий для обработки")
|
||||||
|
|
||||||
|
# Закрываем соединение перед вызовами load_events_by_category
|
||||||
|
# (так как она создает свое соединение)
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
# Загружаем события для каждой категории
|
||||||
|
processed_count = 0
|
||||||
|
for category in categories:
|
||||||
|
category_id = category['ID']
|
||||||
|
try:
|
||||||
|
log_message(f"Обработка категории {category_id} ({processed_count + 1}/{len(categories)})")
|
||||||
|
load_events_by_category(category_id)
|
||||||
|
processed_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Ошибка при загрузке событий для категории {category_id}: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
# Продолжаем обработку других категорий
|
||||||
|
continue
|
||||||
|
|
||||||
|
log_message(f"Завершение загрузки всех событий. Обработано категорий: {processed_count}/{len(categories)}")
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
error_msg = f"Ошибка базы данных при загрузке всех событий: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Общая ошибка при загрузке всех событий: {e}"
|
||||||
|
log_message(error_msg, "ERROR")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
load_all_events()
|
||||||
|
|
||||||
+29
-1
@@ -1,4 +1,32 @@
|
|||||||
from tg_mainbot import app # Импортируйте ваше приложение
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
from tg_mainbot import WEBHOOK_SECRET, WEBHOOK_URL, app, setup_webhook
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_webhook():
|
||||||
|
"""
|
||||||
|
Один раз на старте gunicorn регистрируем webhook с secret_token.
|
||||||
|
Блокирующий flock — без гонки 4 воркеров и без 429.
|
||||||
|
"""
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
f"{WEBHOOK_URL}|{WEBHOOK_SECRET or ''}|allowed_v2".encode("utf-8")
|
||||||
|
).hexdigest()[:16]
|
||||||
|
marker_path = f"/tmp/zilant_webhook_ok_{digest}"
|
||||||
|
lock_path = "/tmp/zilant_webhook_setup.lock"
|
||||||
|
|
||||||
|
with open(lock_path, "w") as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
if os.path.exists(marker_path):
|
||||||
|
return
|
||||||
|
setup_webhook()
|
||||||
|
with open(marker_path, "w") as marker:
|
||||||
|
marker.write("ok\n")
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_webhook()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
setup_webhook()
|
||||||
app.run()
|
app.run()
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
from evtg_mainbot import app # Импортируйте ваше приложение
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run()
|
|
||||||
+71
-7
@@ -17,6 +17,8 @@ MDB_USER = os.getenv('MDB_USER')
|
|||||||
MDB_PW = os.getenv('MDB_PW')
|
MDB_PW = os.getenv('MDB_PW')
|
||||||
MDBASE = os.getenv('MDBASE')
|
MDBASE = os.getenv('MDBASE')
|
||||||
LOG_FILE = os.getenv('LOG_FILE')
|
LOG_FILE = os.getenv('LOG_FILE')
|
||||||
|
PROCESS_EVENT_UPDATES = os.getenv('PROCESS_EVENT_UPDATES', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
AUTO_PUB_EVT = os.getenv('AUTO_PUB_EVT', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
def log_message(message, category="JSON_loader"):
|
def log_message(message, category="JSON_loader"):
|
||||||
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
|
"""Функция для записи в лог-файл с повторными попытками при блокировке"""
|
||||||
@@ -52,6 +54,41 @@ def parse_datetime(dt_str):
|
|||||||
|
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
def create_event_post_links(cursor, event_id, unit_name):
|
||||||
|
"""Создание связей между событием и постами по auto_unit"""
|
||||||
|
if not unit_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Поиск постов с соответствующим auto_unit
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM posts WHERE auto_unit = %s",
|
||||||
|
(unit_name,)
|
||||||
|
)
|
||||||
|
posts = cursor.fetchall()
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
post_id = post['id']
|
||||||
|
|
||||||
|
# Проверяем, существует ли уже связь
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id FROM interlinks WHERE post_id = %s AND event_id = %s",
|
||||||
|
(post_id, event_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
# Создаем новую связь с auto_added = 1
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO interlinks (post_id, event_id, created_at, auto_added) VALUES (%s, %s, %s, 1)",
|
||||||
|
(post_id, event_id, datetime.now())
|
||||||
|
)
|
||||||
|
log_message(f"Создана автоматическая связь: событие {event_id} <-> пост {post_id} (unit: {unit_name})")
|
||||||
|
else:
|
||||||
|
log_message(f"Связь уже существует: событие {event_id} <-> пост {post_id}")
|
||||||
|
|
||||||
|
except Error as e:
|
||||||
|
log_message(f"Ошибка при создании связей для события {event_id}: {e}", "ERROR")
|
||||||
|
|
||||||
def process_json_data():
|
def process_json_data():
|
||||||
inserted_count = 0
|
inserted_count = 0
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
@@ -116,6 +153,9 @@ def process_json_data():
|
|||||||
# Вычисление is_visible
|
# Вычисление is_visible
|
||||||
is_visible = bool(name and about and not is_canceled and not denied)
|
is_visible = bool(name and about and not is_canceled and not denied)
|
||||||
|
|
||||||
|
# Определение marked_to_publication для новой записи
|
||||||
|
marked_to_publication = 1 if (is_visible and AUTO_PUB_EVT) else 0
|
||||||
|
|
||||||
# Проверка существующей записи
|
# Проверка существующей записи
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT id_event, number, modified, name, about, is_posted_tg FROM events WHERE id_event = %s AND number = %s",
|
"SELECT id_event, number, modified, name, about, is_posted_tg FROM events WHERE id_event = %s AND number = %s",
|
||||||
@@ -134,10 +174,16 @@ def process_json_data():
|
|||||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||||
(id_event, number, unit_name, name, about, about_social_picture,
|
(id_event, number, unit_name, name, about, about_social_picture,
|
||||||
modified, added, tags, is_canceled, accepted, denied,
|
modified, added, tags, is_canceled, accepted, denied,
|
||||||
is_visible, 0, 0, None, None, None)
|
is_visible, 0, marked_to_publication, None, None, None)
|
||||||
)
|
)
|
||||||
inserted_count += 1
|
inserted_count += 1
|
||||||
log_message(f"Добавление: Запись {id_event}, Created: {added}")
|
log_message(f"Добавление: Запись {id_event}, Created: {added}")
|
||||||
|
|
||||||
|
# Создание связей с постами для новой записи
|
||||||
|
cursor.execute("SELECT id FROM events WHERE id_event = %s AND number = %s", (id_event, number))
|
||||||
|
event_record = cursor.fetchone()
|
||||||
|
if event_record:
|
||||||
|
create_event_post_links(cursor, event_record['id'], unit_name)
|
||||||
else:
|
else:
|
||||||
# Преобразуем дату из БД к наивному datetime для сравнения
|
# Преобразуем дату из БД к наивному datetime для сравнения
|
||||||
existing_modified = existing['modified']
|
existing_modified = existing['modified']
|
||||||
@@ -146,30 +192,48 @@ def process_json_data():
|
|||||||
existing_modified = existing_modified.replace(tzinfo=None)
|
existing_modified = existing_modified.replace(tzinfo=None)
|
||||||
|
|
||||||
# Проверка расхождения во времени
|
# Проверка расхождения во времени
|
||||||
time_diff = abs(modified - existing_modified) if modified and existing_modified else timedelta(minutes=2)
|
time_diff = abs(modified - existing_modified) if modified and existing_modified else timedelta(seconds=2)
|
||||||
|
|
||||||
if time_diff > timedelta(minutes=1):
|
if time_diff > timedelta(seconds=1):
|
||||||
# Проверка изменения только по полю name
|
# Проверка изменения только по полю name
|
||||||
name_changed = existing['name'] != name
|
name_changed = existing['name'] != name
|
||||||
is_posted_tg = 0 if name_changed else existing['is_posted_tg']
|
# Сброс флага is_posted_tg только если PROCESS_EVENT_UPDATES = true
|
||||||
|
is_posted_tg = 0 if (name_changed and PROCESS_EVENT_UPDATES) else existing['is_posted_tg']
|
||||||
|
|
||||||
|
# Обновление marked_to_publication на основе новых данных
|
||||||
|
new_marked_to_publication = 1 if (is_visible and AUTO_PUB_EVT) else 0
|
||||||
|
|
||||||
|
# log_message(f"PROCESS_EVENT_UPDATES: {PROCESS_EVENT_UPDATES}")
|
||||||
|
# log_message(f"name_changed: {name_changed}")
|
||||||
|
|
||||||
# Обновление записи
|
# Обновление записи
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""UPDATE events SET
|
"""UPDATE events SET
|
||||||
unit_name = %s, name = %s, about = %s, about_social_picture = %s,
|
unit_name = %s, name = %s, about = %s, about_social_picture = %s,
|
||||||
modified = %s, added = %s, tags = %s, is_canceled = %s,
|
modified = %s, added = %s, tags = %s, is_canceled = %s,
|
||||||
accepted = %s, denied = %s, is_visible = %s, is_posted_tg = %s
|
accepted = %s, denied = %s, is_visible = %s, is_posted_tg = %s,
|
||||||
|
marked_to_publication = %s
|
||||||
WHERE id_event = %s AND number = %s""",
|
WHERE id_event = %s AND number = %s""",
|
||||||
(unit_name, name, about, about_social_picture, modified, added,
|
(unit_name, name, about, about_social_picture, modified, added,
|
||||||
tags, is_canceled, accepted, denied, is_visible, is_posted_tg,
|
tags, is_canceled, accepted, denied, is_visible, is_posted_tg,
|
||||||
id_event, number)
|
new_marked_to_publication, id_event, number)
|
||||||
)
|
)
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
|
|
||||||
|
# Создание связей с постами для обновленной записи
|
||||||
|
cursor.execute("SELECT id FROM events WHERE id_event = %s AND number = %s", (id_event, number))
|
||||||
|
event_record = cursor.fetchone()
|
||||||
|
if event_record:
|
||||||
|
create_event_post_links(cursor, event_record['id'], unit_name)
|
||||||
|
|
||||||
# Формирование сообщения для лога
|
# Формирование сообщения для лога
|
||||||
update_msg = f"Обновление: Запись {id_event}, Updated: {modified}"
|
update_msg = f"Обновление: Запись {id_event}, Updated: {modified}"
|
||||||
if name_changed:
|
if name_changed and PROCESS_EVENT_UPDATES:
|
||||||
update_msg += ". Флаг публикации сброшен."
|
update_msg += ". Флаг публикации сброшен."
|
||||||
|
elif name_changed and not PROCESS_EVENT_UPDATES:
|
||||||
|
update_msg += ". Имя изменено, но флаг публикации сохранен (PROCESS_EVENT_UPDATES=false)."
|
||||||
|
if new_marked_to_publication:
|
||||||
|
update_msg += f" marked_to_publication установлен в 1 (is_visible={is_visible}, AUTO_PUB_EVT={AUTO_PUB_EVT})."
|
||||||
log_message(update_msg)
|
log_message(update_msg)
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user