Compare commits

...
28 Commits
Author SHA1 Message Date
gitadmin 87ccd23e96 [ZILANT] коррекция логики проверки добавленных ссылок
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2026-09-02 22:49:03 +03:00
gitadmin f74953e082 [ZILANT] логирование ответа АИ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 22:19:58 +03:00
gitadmin 709178d9ea [ZILANT] отладка добавления ссылок 02
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:51:18 +03:00
gitadmin ac17043a08 [ZILANT] отладка до-редактирования ссылок в пост
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:40:29 +03:00
gitadmin 62a77dba78 Коррекция логики добавления ссылок при [ZILANT] постинге в ТГ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-02 21:32:35 +03:00
gitadmin ea6c5e5a7d [ZILANT] обработка пустых постов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 12:33:10 +03:00
gitadmin 80e0847329 [ZILANT] перенаправление лога автораннера
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 12:06:42 +03:00
gitadmin c7ba0479ba fix accidental local overwrite
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:49:52 +03:00
gitadmin d7e105d986 [ZILANT] отправлять картинки при посте событий как файл, а не как URL; делаем как при репосте ВК постов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:37:19 +03:00
gitadmin 352c0da209 update .env for debug mode
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:18:40 +03:00
gitadmin 4500cf5bf4 создан шаблон .env.example
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 11:02:30 +03:00
gitadmin a1aadb6393 создан шаблон .env файла конфигурации 2026-08-30 11:02:19 +03:00
gitadmin 251fa0be11 реструктуризация файлов проекта
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-30 10:08:43 +03:00
gitadmin adc49c285c delete gitignored file
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 20:19:43 +03:00
gitadmin 1b080990fa Add .gitignore for season_links.pyc
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-02 20:17:04 +03:00
gitadmin 7af6748c74 [ZILANT] вывод __pycache__ в gitignore 2026-08-02 20:16:11 +03:00
gitadmin e0e27ad569 [ZILANT] Защита от старых подписок
ci/woodpecker/push/woodpecker Pipeline failed
2026-08-02 19:54:39 +03:00
gitadmin 739e7171ea [ZILANT] коррекция работы фронта с долгим ответом ИИ
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 16:52:48 +03:00
gitadmin 9b8b5514b8 [ZILANT] Переход на RouterAI
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 16:42:51 +03:00
gitadmin 7c59c0d5e2 [ZILANT] доработка параметров setwebhook для корректных callback'ов кнопок подписки
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 16:09:42 +03:00
gitadmin 2797183834 [ZILANT] debug 2 - test reg script
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:50:44 +03:00
gitadmin 72c7d38609 [ZILANT] debug - failing secret code
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:41:37 +03:00
gitadmin 116b9bca10 [ZILANT] обновление реквизитов
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:31:05 +03:00
gitadmin c5ba5d6462 [ZILANT] изменение регистрации респондер-бота для корректного проброса секретного кода
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:25:07 +03:00
gitadmin 4795079dce [ZILANT] обновление механизма setwebhook с секретным токеном
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:10:59 +03:00
gitadmin 8c3a62d021 [ZILANT] убрать зацикливание запроса на вебхук
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 15:05:14 +03:00
gitadmin 064d22cfd8 [ZILANT] настройка запуска респондер-бота через релей
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-02 14:28:57 +03:00
gitadmin 0b5f20b748 [ZILANT] Установка дефолтной схемы при ее отсутствии в RELAY_URL 2026-08-02 14:18:24 +03:00
35 changed files with 1980 additions and 752 deletions
+23
View File
@@ -0,0 +1,23 @@
---
description: Поддерживать VERSION и docs/CHANGELOG при изменениях проекта
alwaysApply: true
---
# Версионирование и документация
При **значимых** изменениях кода или поведения:
1. Обновить [`VERSION`](../VERSION) (SemVer):
- **PATCH** — исправления, мелкие правки без смены API/поведения
- **MINOR** — новая функциональность, обратно совместимая
- **MAJOR** — ломающие изменения (формат ссылок, схема БД, API)
2. Добавить запись в [`docs/CHANGELOG.md`](../docs/CHANGELOG.md) под новой версией (Added / Changed / Fixed / Removed).
3. При изменении архитектуры, пайплайна, режимов ZILANT/VOLK или карты файлов — обновить [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) и при необходимости [`README.md`](../README.md), [`docs/volk-mode.md`](../docs/volk-mode.md).
4. Перенос скриптов в `tools/` — обновить [`tools/README.md`](../tools/README.md).
5. Не поднимать версию за косметические правки без изменения поведения.
Текущая базовая версия: **1.1.4**.
+22 -20
View File
@@ -6,26 +6,25 @@ LOG_FILE = /opt/testbot-git/logs/testzilantposter.log
WORKMODE = "ZILANT" WORKMODE = "ZILANT"
# WORKMODE = "VOLK" # WORKMODE = "VOLK"
# Код сезона для deep link / callback подписок (zk2026_post_…, zk2026_event_…)
SEASON = "zk2026"
# Telegram API через internal_relay (retransbot) — обязательно # Telegram API через internal_relay (retransbot) — обязательно
# Локально: http://127.0.0.1:8081 # Нужна схема http:// или https:// (если без схемы — подставится http://)
# Локально: http://127.0.0.1:5100 или http://192.168.0.109:5100
# Через nginx: https://rbin.aabpro.ru # Через nginx: https://rbin.aabpro.ru
RELAY_URL = "https://rbin.aabpro.ru" RELAY_URL = "http://192.168.0.109:5100"
# Webhook Responder-bot # Webhook Responder-bot
# WEBHOOK_URL — адрес, по которому internal_relay доставляет апдейты боту # WEBHOOK_URL — адрес локальной доставки апдейтов от internal_relay к gunicorn-боту
# (должен быть доступен с машины internal_relay). Не публичный URL для Telegram: # (должен быть доступен с машины internal_relay). Не URL для Telegram.
# Telegram ходит на external_relay; setWebhook перехватывается relay. # gunicorn слушает 192.168.0.109:5005 — указываем его напрямую.
# WEBHOOK_URL = "http://127.0.0.1:5010/volk_bot" # WEBHOOK_URL = "http://192.168.0.109:5010/volk_bot"
# WEBHOOK_PORT = 5010 # WEBHOOK_PORT = 5010
# WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c" # WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
# Webhook Responder-bot # Webhook Responder-bot
# Пример: бот и relay на одной машине — http://127.0.0.1:5005/testbot WEBHOOK_URL = "http://192.168.0.109:5005/testbot"
# Или URL через nginx, если internal_relay достучится до него
WEBHOOK_URL = "https://bot.aabpro.ru/testbot"
WEBHOOK_PORT = 5005 WEBHOOK_PORT = 5005
WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c" WEBHOOK_SECRET = "cc0dbc1841660da0b14d7df86ab5e0ed973c"
@@ -57,13 +56,15 @@ VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012
VK_API_VERSION = '5.131' VK_API_VERSION = '5.131'
# MySQL database # MySQL database
# MDBASE = 'testposts' MDBASE = 'testposts'
# MDBASE = 'tempzilant' # debug Zilant database # MDBASE = 'tempzilant' # debug Zilant database
# Zilant 2026 database # Zilant 2026 database
MDBASE = 'zilant_2026' # MDBASE = 'zilant_2026'
# MDBASE = 'volk_test' # debug VOLK database # MDBASE = 'volk_test' # debug VOLK database
# MDBASE = 'volkdata' # VOLK production database # MDBASE = 'volkdata' # VOLK production database
MDB_HOST = 'localhost' MDB_HOST = 'localhost'
# MDB_USER = 'zilantposter'
# MDB_PW = 'Z1l@ntp08t3r'
MDB_USER = 'testuser' MDB_USER = 'testuser'
MDB_PW = 'T38tU83r69' MDB_PW = 'T38tU83r69'
# MDB_USER = 'volkuser' # MDB_USER = 'volkuser'
@@ -103,6 +104,8 @@ RESPONDER_BOT_NAME = "posto_1638_bot"
ORG_MESSAGE_TAG = "#оргинфаЗиланткона" ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
# tag to detect posts to not repost from VK # tag to detect posts to not repost from VK
VK_NO_REPOST_TAG = "#окоЗиланта" VK_NO_REPOST_TAG = "#окоЗиланта"
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
VK_POST_MIN_LETTERS = 5
# Minutes delay before publishing an event # Minutes delay before publishing an event
@@ -150,11 +153,10 @@ PUBLISH_SILENTLY = true
USE_SUBSCRIPTION_BOT = true USE_SUBSCRIPTION_BOT = true
LOG_DEBUG_DATA = false LOG_DEBUG_DATA = false
# RouterAI (генерация shortname)
RA_KEY = "sk-7pJxRlbYWUGMNX9y2mHzi2soKDIsZPzp"
RA_MODEL = "moonshotai/kimi-k2-thinking"
# OpenRouter # OpenRouter (не используется, оставлен для справки)
OR_KEY = "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" # OR_KEY = "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"
# OR_MODEL_NAME = "qwen/qwen3-vl-235b-a22b-instruct" # OR_MODEL_NAME = "anthropic/claude-sonnet-5"
OR_MODEL_NAME = "anthropic/claude-sonnet-5"
# OR_MODEL_NAME = "deepseek/deepseek-chat-v3.1"
# OR_MODEL_NAME = "deepseek/deepseek-r1-0528:free"
# OR_MODEL_NAME = "qwen/qwen3-coder:free"
+128
View File
@@ -0,0 +1,128 @@
# =============================================================================
# Шаблон окружения для инстанса проекта.
# Скопируйте в .env и заполните значения:
# cp .env.example .env
# Генерация SECRET_KEY / WEBHOOK_SECRET:
# python tools/ops/genkey.py
# =============================================================================
# --- Общие настройки ---------------------------------------------------------
SECRET_KEY=CHANGE_ME_hex_64_chars
LOG_FILE=./logs/app.log
# Режим работы: ZILANT | VOLK
WORKMODE=ZILANT
# Код сезона для deep link / callback подписок (zk2026_post_…, subscribe_zk2026_…)
SEASON=zk2026
# --- Telegram через internal_relay (retransbot) — обязательно ----------------
# Схема http:// или https:// обязательна (иначе подставится http://)
# Примеры: http://127.0.0.1:5100 | https://relay.example.com
RELAY_URL=http://127.0.0.1:5100
# --- Webhook responder-бота --------------------------------------------------
# WEBHOOK_URL — адрес локальной доставки апдейтов от relay к gunicorn-боту
# (доступен с машины relay). Это не публичный URL для Telegram.
# ZILANT (пример): http://bot.example.com:5005/testbot
# VOLK (пример): http://bot.example.com:5010/volk_bot
WEBHOOK_URL=http://192.168.0.1:5005/testbot
WEBHOOK_PORT=5005
WEBHOOK_SECRET=CHANGE_ME_webhook_secret
# Опционально: webhook poster-бота (если используется)
# POSTHOOK_URL=https://bot.example.com/poster
# POSTHOOK_PORT=5008
# POSTHOOK_SECRET=CHANGE_ME
# --- Веб-контроллер (db_edit) ------------------------------------------------
# ZILANT (пример):
WEBCTRL_URL=https://bot.example.com/testedit
WEBCTRL_PORT=5006
WEBCTRL_PREFIX=/testedit
# VOLK (пример, раскомментировать при WORKMODE=VOLK):
# WEBCTRL_URL=https://bot.example.com/volk
# WEBCTRL_PORT=5009
# WEBCTRL_PREFIX=/volk
# Логин/пароль первого администратора веб-UI (создаётся ensure_db при пустой таблице users)
DEFNM=admin
DEFPW=CHANGE_ME_admin_password
# --- VK API ------------------------------------------------------------------
# ZILANT group (пример): -3342146
# VOLK group (пример): -58475527
VK_GROUP_ID=-3342146
VK_ACCESS_TOKEN=CHANGE_ME_vk_token
VK_API_VERSION=5.131
# --- MariaDB -----------------------------------------------------------------
# ZILANT: zilant_2026 | tempzilant | testposts
# VOLK: volkdata | volk_test
MDBASE=CHANGE_ME_database_name
MDB_HOST=localhost
MDB_USER=CHANGE_ME_db_user_name
MDB_PW=CHANGE_ME_db_password
# --- Telegram-боты -----------------------------------------------------------
# Poster — публикация в канал
POSTER_BOT_TOKEN=CHANGE_ME_poster_bot_token
# Responder — подписки / webhook
RESPONDER_BOT_TOKEN=CHANGE_ME_responder_bot_token
RESPONDER_BOT_NAME=your_responder_bot_username
# --- Канал -------------------------------------------------------------------
# ZILANT debug: @posto1638 | production: @zilantkon
# VOLK production: @VOLKinformer
CHANNEL_ID=@your_channel
MAX_CAPTION_LENGTH=1000
MAX_TEXT_LENGTH=4000
# --- Теги VK (ZILANT) --------------------------------------------------------
# Org messages go without picture
# to fit the long text into TG message limit
ORG_MESSAGE_TAG=#оргинфаЗиланткона
# VK posts with no repost tag are skipped by poster
VK_NO_REPOST_TAG=#окоЗиланта
# Минимум букв (isalpha) в тексте VK-поста. Меньше — в БД как «- = Пустой пост = -», не для публикации
VK_POST_MIN_LETTERS=5
# --- Публикация событий ------------------------------------------------------
# Задержка перед публикацией события (минуты). В БД время в UTC.
# Ближе к событию: 60; далеко: 1440
EVENT_POST_DELAY=1440
# --- Ссылки ZILANT (План Захвата / карточки событий) --------------------------
# В конце обычно trailing slash; к URL добавляется номер события
PZK_PREFIX=https://reg.konvent.ru/zk2026/draft/plan/api/add/
EVT_PREFIX=https://reg.konvent.ru/zk2026/events/actions/api/
DESC_PREFIX=https://reg.konvent.ru/zk2026/events/actions/
# --- API VOLK (нужны при WORKMODE=VOLK) --------------------------------------
# Раскомментируйте и заполните для режима VOLK:
# VOLK_API_VERSION=1.0
# VOLK_CATEGORY_LIST=https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event
# VOLK_CATEGORY_DESC_PREFIX=https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category=
# VOLK_EVENT_LIST_PREFIX=https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category=
# --- Флаги поведения ---------------------------------------------------------
# IS_EVENT=true - каждый пост в ВК считать пригодным для репоста в канал
# AUTO_PUBLISH=true - автоматическая публикация потсов из ВК в канал
# AUTO_PUB_EVT=true - автоматическая публикация событий в канал
# PROCESS_EVENT_UPDATES=false - не репостить уже опубликованные события при обновлениях
# PUBLISH_SILENTLY=true - публиковать в канал в режиме "без звука"
# USE_SUBSCRIPTION_BOT=true - использовать бота подписок
# LOG_DEBUG_DATA=false - не вести расширенного журналирования отладочных данных
IS_EVENT=true
AUTO_PUBLISH=true
AUTO_PUB_EVT=true
PROCESS_EVENT_UPDATES=false
PUBLISH_SILENTLY=true
USE_SUBSCRIPTION_BOT=true
LOG_DEBUG_DATA=false
# --- RouterAI (генерация shortname через «Назвать») ---------------------------
RA_KEY=CHANGE_ME_routerai_key
RA_MODEL=moonshotai/kimi-k2-thinking
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
*$py.class
# Секреты инстанса — только шаблон .env.example в репозитории
.env
.env.local
.env.*.local
# Логи инстанса
logs/
+67 -3
View File
@@ -1,4 +1,68 @@
# Zilant2025 # Zilant / VOLK — Telegram poster и подписки
Zilantkon bot testarea **Версия:** 1.1.4 ([`VERSION`](VERSION))
test update #8
Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link.
## Режимы
| `WORKMODE` | Описание |
|------------|----------|
| `ZILANT` | VK + Zilant JSON → канал (основной пайплайн) |
| `VOLK` | VOLK API → категории и события → канал |
Подробнее: [`docs/volk-mode.md`](docs/volk-mode.md).
## Быстрый старт (сервер)
```bash
# Deploy через Woodpecker → rsync + post-deploy.sh
# Сервисы:
sudo systemctl status testbot-git.service # бот :5005
sudo systemctl status testedit-git.service # UI :5006
sudo systemctl status runner.timer # hourly pipeline
```
Конфигурация — скопируйте [`.env.example`](.env.example) в `.env` и заполните значения
(файл `.env` в git не коммитится). Ключевые переменные: `WORKMODE`, `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
Генерация секретов: `python tools/ops/genkey.py`.
## Пайплайн (ZILANT)
`runner.sh` по расписанию:
`vk_load``zk_load``db_update_shortname``tg_publish``evtg_publish`
Настройка timer: [`docs/runner-setup.md`](docs/runner-setup.md).
## Документация
| Документ | О чём |
|----------|--------|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Компоненты, карта файлов, deploy |
| [`docs/volk-mode.md`](docs/volk-mode.md) | Режим VOLK |
| [`docs/telegram-messaging.md`](docs/telegram-messaging.md) | API отправки сообщений пользователям |
| [`docs/runner-setup.md`](docs/runner-setup.md) | Systemd timer / cron для runner |
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | История версий |
| [`tools/README.md`](tools/README.md) | Вспомогательные и legacy-скрипты |
## Версионирование
SemVer в [`VERSION`](VERSION). При изменениях обновлять `VERSION` и [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
## Структура каталогов
```
├── tg_mainbot.py, wsgi_bot.py # responder-бот
├── db_edit.py, wsgi_edit.py # веб-UI
├── tg_publish.py, evtg_publish.py # публикация (ZILANT)
├── volk_load.py, volk_cat_publish.py # VOLK
├── runner.sh, backupdb.sh
├── services/ # systemd units, post-deploy
├── templates/ # HTML UI
├── docs/ # документация
└── tools/
├── ops/ # genkey, register_tg_webhook, copy_databases
├── dev/ # send_telegram_example, volk_*_testing
└── legacy/ # evt_prefetch
```
+2 -204
View File
@@ -1,205 +1,3 @@
# Отправка сообщений через Telegram бота # Перенесено
## Описание Документация: [`docs/telegram-messaging.md`](docs/telegram-messaging.md)
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
1. **API endpoint** для отправки сообщений из других скриптов
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
3. **Обработку ошибок** и логирование всех операций
## Архитектура системы
Система состоит из двух основных компонентов:
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
### Конфигурация портов
Порты и URL настраиваются в файле `.env`:
- `RELAY_URL` - базовый URL **internal_relay** (все исходящие вызовы Bot API идут только через него)
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
- `WEBHOOK_URL` - URL доставки апдейтов от internal_relay к боту (доступен с машины relay; не URL, который видит Telegram напрямую)
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
Веб-интерфейс автоматически читает URL и порт бота из переменных окружения и использует их для отправки запросов.
## Использование через веб-интерфейс
В заголовке страницы "Управление постами VK и событиями" добавлены два поля:
- **ID пользователя** - числовой ID пользователя в Telegram
- **Сообщение** - текст сообщения для отправки
- **Кнопка "Отправить"** - отправляет сообщение через бота
### Особенности интерфейса:
- Поля проверяются на заполненность перед отправкой
- ID пользователя проверяется на корректность (должен быть числом)
- Поддержка отправки по клавише Enter
- Индикатор загрузки во время отправки
- Автоматическая очистка полей после успешной отправки
## Использование через API
### Endpoint
```
POST /send_message
POST {WEBHOOK_PATH}/send_message
```
Где `WEBHOOK_PATH` - путь из переменной окружения `WEBHOOK_URL` (например, `/testbot`).
### Примеры URL:
- **Локально:** `http://localhost:5005/send_message`
- **Через reverse proxy:** `https://bot.aabpro.ru/testbot/send_message`
### Заголовки запроса
```
Content-Type: application/json
X-API-Secret-Token: <секретный_токен>
```
### Параметры запроса
```json
{
"user_id": 123456789,
"message": "Текст сообщения"
}
```
### Аутентификация
API защищен секретным токеном, который передается в заголовке `X-API-Secret-Token`. Токен берется из переменной окружения `WEBHOOK_SECRET`.
### Ответы
**Успешная отправка (200):**
```json
{
"success": true,
"message": "Сообщение отправлено успешно"
}
```
**Ошибка (400):**
```json
{
"success": false,
"error": "Описание ошибки"
}
```
### Пример использования из Python
```python
import requests
def send_telegram_message(user_id, message):
# Используем внешний URL через reverse proxy
url = "https://bot.aabpro.ru/testbot/send_message"
data = {
"user_id": user_id,
"message": message
}
headers = {
'Content-Type': 'application/json',
'X-API-Secret-Token': 'ваш_секретный_токен'
}
response = requests.post(url, json=data, headers=headers)
result = response.json()
if result["success"]:
print("Сообщение отправлено успешно!")
else:
print(f"Ошибка: {result['error']}")
```
### Пример использования из командной строки
```bash
# Используя curl (через reverse proxy)
curl -X POST https://bot.aabpro.ru/testbot/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
# Используя curl (локально)
curl -X POST http://localhost:5005/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
# Используя примерный скрипт
python send_telegram_example.py 123456789 "Привет! Это тестовое сообщение."
```
## Обработка ошибок
Система обрабатывает следующие типы ошибок:
### HTTP ошибки:
1. **401 Unauthorized** - Неверный или отсутствующий API токен
2. **400 Bad Request** - Неверный ID пользователя или отсутствующие данные
3. **500 Internal Server Error** - Внутренняя ошибка сервера
### Коды ошибок API:
- `INVALID_TOKEN` - Неверный токен аутентификации
- `NO_DATA` - Отсутствуют данные в запросе
- `NO_USER_ID` - Не указан ID пользователя
- `NO_MESSAGE` - Не указан текст сообщения
- `INVALID_USER_ID` - user_id не является числом
- `TELEGRAM_ERROR` - Ошибка Telegram API
- `INTERNAL_ERROR` - Внутренняя ошибка сервера
### Ошибки Telegram API:
1. **403 Forbidden** - Пользователь заблокировал бота или не начал с ним диалог
2. **400 Bad Request** - Неверный ID пользователя
3. **429 Too Many Requests** - Превышен лимит отправки сообщений
4. **404 Not Found** - Пользователь не найден
### Диагностика в веб-интерфейсе
Веб-интерфейс предоставляет подробную диагностику ошибок:
- **Время ответа** сервера
- **HTTP статус** код
- **Код ошибки** API
- **Конкретные предложения** по решению проблемы
- **Автоматическое скрытие** уведомлений
Все ошибки логируются в файл логов с указанием типа ошибки и ID пользователя.
## Логирование
Все операции отправки сообщений записываются в лог с указанием:
- Времени операции
- ID пользователя
- Статуса отправки (успех/ошибка)
- Краткого описания ошибки (при наличии)
Пример записи в логе:
```
[2024-01-15 10:30:45] [TG_bot] ИНФО: Сообщение отправлено: Привет! Это тестовое сообщение... (Пользователь user_123456789)
```
## Требования
- Telegram бот должен быть запущен и подключен через webhook
- Пользователь должен начать диалог с ботом (отправить команду /start)
- Пользователь не должен заблокировать бота
## Безопасность
- API endpoint доступен только для POST запросов
- Проверка корректности входных данных
- Логирование всех попыток отправки для аудита
- Обработка ошибок без раскрытия внутренней информации системы
+1
View File
@@ -0,0 +1 @@
1.1.4
+17 -3
View File
@@ -1011,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",
@@ -1031,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
+149 -22
View File
@@ -21,14 +21,88 @@ DB_CONFIG = {
'charset': 'utf8mb4' 'charset': 'utf8mb4'
} }
OPENROUTER_CONFIG = { ROUTERAI_CONFIG = {
'api_key': os.getenv('OR_KEY'), 'api_key': os.getenv('RA_KEY'),
'model': os.getenv('OR_MODEL_NAME') 'model': os.getenv('RA_MODEL'),
'base_url': 'https://routerai.ru/api/v1',
} }
# Настройки журналирования # Настройки журналирования
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе
def _truncate_for_log(value, max_len=None):
"""Обрезает длинные значения для журнала, не теряя информацию о размере."""
if value is None:
return None
if not isinstance(value, str):
value = json.dumps(value, ensure_ascii=False, default=str)
limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX
if len(value) <= limit:
return value
return f"{value[:limit]}... [обрезано, всего {len(value)} символов]"
def _message_fields_for_log(message):
"""Все значимые поля message из ответа chat/completions."""
if not message:
return {}
logged = {}
for key in (
'role', 'content', 'reasoning_content', 'reasoning',
'refusal', 'tool_calls', 'function_call', 'audio',
):
if key not in message:
continue
value = message.get(key)
if value in (None, '', [], {}):
logged[key] = value
elif isinstance(value, str):
logged[key] = _truncate_for_log(value)
else:
logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str))
return logged
def _log_routerai_request(text, prompt):
"""Журнал параметров исходящего запроса (без API-ключа)."""
log_message(
"Запрос RouterAI: "
f"model={ROUTERAI_CONFIG['model']}, "
f"text_len={len(text)}, prompt_len={len(prompt)}, "
f"text_preview={_truncate_for_log(text.strip(), 300)}"
)
def _log_routerai_response(response, result=None, error_body=None):
"""Подробный журнал HTTP-ответа RouterAI."""
payload = {
"http_status": getattr(response, 'status_code', None),
"model": ROUTERAI_CONFIG.get('model'),
}
if result is not None:
payload["response_id"] = result.get("id")
payload["object"] = result.get("object")
payload["created"] = result.get("created")
payload["usage"] = result.get("usage")
payload["system_fingerprint"] = result.get("system_fingerprint")
choices = result.get("choices") or []
payload["choices_count"] = len(choices)
payload["choices"] = []
for i, choice in enumerate(choices):
payload["choices"].append({
"index": choice.get("index", i),
"finish_reason": choice.get("finish_reason"),
"message": _message_fields_for_log(choice.get("message") or {}),
"logprobs": choice.get("logprobs"),
})
if result.get("error"):
payload["api_error"] = result.get("error")
if error_body is not None:
payload["error_body"] = _truncate_for_log(error_body)
log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}")
def log_message(message, max_retries=5, retry_delay=0.1): def log_message(message, max_retries=5, retry_delay=0.1):
""" """
@@ -59,11 +133,16 @@ def log_message(message, max_retries=5, retry_delay=0.1):
return False return False
def _generate_shortname_by_api(text): def _generate_shortname_by_api(text):
"""Внутренняя функция для генерации краткого названия через OpenRouter API""" """Внутренняя функция для генерации краткого названия через RouterAI API"""
url = "https://openrouter.ai/api/v1/chat/completions" if not ROUTERAI_CONFIG['api_key'] or not ROUTERAI_CONFIG['model']:
log_message("RA_KEY или RA_MODEL не заданы в .env")
print("Ошибка: RA_KEY или RA_MODEL не заданы в .env")
return None
url = f"{ROUTERAI_CONFIG['base_url']}/chat/completions"
headers = { headers = {
"Authorization": f"Bearer {OPENROUTER_CONFIG['api_key']}", "Authorization": f"Bearer {ROUTERAI_CONFIG['api_key']}",
"Content-Type": "application/json" "Content-Type": "application/json",
} }
prompt = f""" prompt = f"""
@@ -78,14 +157,16 @@ def _generate_shortname_by_api(text):
Название:""" Название:"""
data = { data = {
"model": OPENROUTER_CONFIG['model'], "model": ROUTERAI_CONFIG['model'],
"messages": [{"role": "user", "content": prompt}], "messages": [{"role": "user", "content": prompt}],
"max_tokens": 5000, "max_tokens": 5000,
"temperature": 0.3 "temperature": 0.3,
} }
_log_routerai_request(text, prompt)
try: try:
response = requests.post(url, headers=headers, json=data, timeout=60) response = requests.post(url, headers=headers, json=data, timeout=180)
# Обработка ошибки 429 (Too Many Requests) # Обработка ошибки 429 (Too Many Requests)
if response.status_code == 429: if response.status_code == 429:
@@ -98,27 +179,55 @@ def _generate_shortname_by_api(text):
# Пытаемся распарсить JSON ответ # Пытаемся распарсить JSON ответ
try: try:
error_info["body"] = response.json() error_info["body"] = response.json()
except: except Exception:
pass pass
_log_routerai_response(response, error_body=error_info.get("body"))
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}") log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.") print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
sys.exit(1) # Не sys.exit — эта функция вызывается и из веб-API Flask
raise RuntimeError("RouterAI 429: превышен лимит запросов")
if not response.ok:
_log_routerai_response(response, error_body=response.text)
response.raise_for_status()
response.raise_for_status()
result = response.json() result = response.json()
_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)
+165
View File
@@ -0,0 +1,165 @@
# Архитектура проекта
Версия: см. [`VERSION`](../VERSION). История изменений: [`docs/CHANGELOG.md`](CHANGELOG.md).
## Назначение
Система загружает посты и события из внешних источников, хранит их в MariaDB, публикует анонсы в Telegram-канал и даёт веб-интерфейс для редактирования и управления подписками через responder-бота.
Поддерживаются два режима работы (`WORKMODE` в `.env`):
| Режим | Источники данных | Публикация |
|-------|------------------|------------|
| **ZILANT** | VK (`vk_load.py`), JSON Zilant (`zk_load.py`) | `tg_publish.py`, `evtg_publish.py` |
| **VOLK** | VOLK API (`volk_load.py`) | `volk_cat_publish.py` |
Подробнее о VOLK: [`docs/volk-mode.md`](volk-mode.md).
## Компоненты runtime
```
┌─────────────────┐ rsync ┌──────────────────┐
│ Woodpecker CI │ ─────────────► │ /opt/testbot-git │
└─────────────────┘ └────────┬─────────┘
┌──────────────────────────────────┼──────────────────────────┐
▼ ▼ ▼
runner.timer testbot-git.service testedit-git.service
→ runner.sh gunicorn :5005 gunicorn :5006
vk_load, zk_load, wsgi_bot → tg_mainbot wsgi_edit → db_edit
db_update_shortname,
tg_publish, evtg_publish
```
### Responder-бот (подписки)
- **`tg_mainbot.py`** — Flask + webhook, обработка `/start`, callback-кнопок подписки.
- **`wsgi_bot.py`** — точка входа gunicorn; однократная регистрация webhook с file lock.
- **`season_links.py`** — сезонные payload (`zk2026_post_*`, `subscribe_zk2026_*` и т.д.).
- Старые ссылки без префикса сезона → сообщение «сезон завершён» + кнопка в канал.
### Poster (публикация в канал)
- **`tg_publish.py`** — посты VK → Telegram.
- **`evtg_publish.py`** — события → Telegram.
- **`volk_cat_publish.py`** — категории VOLK → Telegram (режим VOLK).
- Все исходящие вызовы Bot API — через **`telegram_relay.py`** (`RELAY_URL`).
### Веб-интерфейс
- **`db_edit.py`** — CRUD постов/событий, кнопки рескана, публикации, «Назвать» (AI shortname).
- **`db_update_shortname.py`** — генерация коротких названий через RouterAI.
- **`test_tg_poster.py`** — тестовый пост в канал (кнопка «Тест» в UI).
- Шаблоны: `templates/`.
### База данных
- **`ensure_db.py`** — создание/миграция схемы (вызывается из loader-скриптов).
- **`backupdb.sh`** — дамп БД при deploy (`post-deploy.sh`).
## Пайплайн runner.sh (ZILANT)
Порядок выполнения (systemd timer `runner.timer`):
1. `vk_load.py` — загрузка постов из VK
2. `zk_load.py` — загрузка событий из JSON
3. `db_update_shortname.py` — AI shortname для новых записей
4. `tg_publish.py` — публикация постов
5. `evtg_publish.py` — публикация событий
> **`tools/legacy/evt_prefetch.py`** раньше стоял перед `evtg_publish.py`; с 1.0.0 снят с пайплайна.
## Deploy
- **`.woodpecker.yml`** — rsync на сервер, затем `services/post-deploy.sh`.
- **`post-deploy.sh`** — venv, pip, backup БД, обновление systemd units, restart сервисов.
## Конфигурация (.env)
Шаблон для нового инстанса: [`.env.example`](../.env.example).
```bash
cp .env.example .env
# заполнить CHANGE_ME_*; секреты: python tools/ops/genkey.py
```
| Переменная | Назначение |
|------------|------------|
| `WORKMODE` | `ZILANT` или `VOLK` |
| `SEASON` | Префикс сезона для deep link (`zk2026`) |
| `RELAY_URL` | Internal relay для Bot API |
| `WEBHOOK_URL`, `WEBHOOK_SECRET` | Доставка апдейтов боту |
| `MDB_*`, `MDBASE` | MariaDB |
| `POSTER_BOT_TOKEN`, `RESPONDER_BOT_TOKEN` | Токены ботов |
| `PZK_PREFIX`, `EVT_PREFIX`, `DESC_PREFIX` | Ссылки ZILANT |
| `VK_NO_REPOST_TAG` | Тег VK, отключающий автопубликацию |
| `VK_POST_MIN_LETTERS` | Минимум букв в тексте VK-поста (иначе «пустой пост», не публиковать) |
| `VOLK_*` | API VOLK (при `WORKMODE=VOLK`) |
| `RA_KEY`, `RA_MODEL` | RouterAI для shortname |
## Карта файлов
### Ядро (runtime)
| Файл | Роль |
|------|------|
| `tg_mainbot.py` | Responder-бот, webhook |
| `wsgi_bot.py` | Gunicorn entry (бот) |
| `db_edit.py` | Веб-UI |
| `wsgi_edit.py` | Gunicorn entry (UI) |
| `tg_publish.py` | Публикация постов |
| `evtg_publish.py` | Публикация событий |
| `vk_load.py` | Загрузка VK |
| `zk_load.py` | Загрузка Zilant JSON |
| `volk_load.py` | Загрузка VOLK (режим VOLK) |
| `volk_cat_publish.py` | Публикация VOLK (режим VOLK) |
| `db_update_shortname.py` | AI shortname |
| `formatter.py` | Форматирование текста постов/событий |
| `ensure_db.py` | Схема БД |
| `telegram_relay.py` | Relay для Bot API |
| `season_links.py` | Сезонные ссылки и callback |
| `test_tg_poster.py` | Тестовая публикация (UI) |
| `runner.sh` | Пайплайн по расписанию |
| `backupdb.sh` | Бэкап БД |
| `gunicorn_bot.conf.py` | Stub-конфиг gunicorn (бот) |
### Инфраструктура
| Путь | Роль |
|------|------|
| `services/testbot-git.service` | systemd: бот :5005 |
| `services/testedit-git.service` | systemd: UI :5006 |
| `services/runner.service`, `runner.timer` | systemd: hourly runner |
| `services/post-deploy.sh` | Post-deploy |
| `.woodpecker.yml` | CI deploy |
### Вспомогательные (`tools/`)
| Путь | Роль |
|------|------|
| `tools/ops/genkey.py` | Генерация секретов |
| `tools/ops/register_tg_webhook.py` | Ручная регистрация webhook |
| `tools/ops/copy_databases.sh` | Клонирование MariaDB |
| `tools/dev/send_telegram_example.py` | CLI-пример `/send_message` |
| `tools/dev/volk_telegram_testing.py` | Отладка публикации в Telegram |
| `tools/dev/volk_category_testing.py` | Отладка загрузки категории VOLK |
| `tools/legacy/evt_prefetch.py` | Legacy: prefetch событий |
См. [`tools/README.md`](../tools/README.md).
### Документация
| Файл | Содержание |
|------|------------|
| `README.md` | Быстрый старт |
| `docs/ARCHITECTURE.md` | Этот файл |
| `docs/volk-mode.md` | Режим VOLK |
| `docs/telegram-messaging.md` | API `/send_message` |
| `docs/runner-setup.md` | Настройка runner timer |
| `docs/CHANGELOG.md` | История версий |
## Версионирование
- Версия в корне: **`VERSION`** (SemVer: `MAJOR.MINOR.PATCH`).
- При каждом значимом изменении обновлять `VERSION` и запись в `docs/CHANGELOG.md`.
- Правило для агентов: `.cursor/rules/version-and-docs.mdc`.
+98
View File
@@ -0,0 +1,98 @@
# Changelog
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
Версия проекта — в файле [`VERSION`](../VERSION) (SemVer).
## [1.1.4] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: убрано сравнение `min_length` (HTML длиннее plain text от Telegram) — лишние retry, мигание ссылок и обрыв пакетной публикации из UI.
### Changed
- Проверка footer-ссылок: убрано сравнение `min_length` (HTML длиннее plain text от Telegram).
## [1.1.3] - 2026-09-02
### Fixed
- Проверка footer-ссылок после edit: сравнение по конкретным URL (Инфо/Иду/Подписка, ВК), а не по любому `text_link` или словам в тексте тела события — устраняет ложный «успех» при пакетной публикации.
## [1.1.2] - 2026-09-02
### Fixed
- Проверка успешного edit ссылок: Telegram отдаёт plain text и `text_link` entities, а не `href=` в caption; `message is not modified` трактуется как успех (ссылки уже применены). Устраняет ложные ошибки при успешной публикации.
## [1.1.1] - 2026-09-02
### Fixed
- Публикация постов и событий: второй шаг (edit со строкой ссылок) повторяется до появления `href=` в ответе API; без подтверждённых ссылок запись не помечается опубликованной. Устраняет редкие случаи, когда edit логировался как успех, а ссылки в канале отсутствовали.
## [1.1.0] - 2026-08-30
### Added
- Импорт VK (`vk_load.py`): посты с числом букв меньше `VK_POST_MIN_LETTERS` (по умолчанию 5) сохраняются с названием `- = Пустой пост = -` и без отметки «к публикации».
### Documentation
- Параметр `VK_POST_MIN_LETTERS` в `.env.example` и `.env`.
## [1.0.4] - 2026-08-30
### Changed
- `runner.sh` пишет лог в `logs/runner.log` (каталог создаётся при первом запуске).
## [1.0.3] - 2026-08-30
### Fixed
- Публикация событий (`evtg_publish.py`): картинка скачивается на сервере и уходит в `sendPhoto` файлом, как у постов VK. Раньше в Bot API передавался URL (`http://reg.konvent.ru/...`), Telegram не мог его забрать (`failed to get HTTP URL content`) и пост уходил текстом.
## [1.0.2] - 2026-08-30
### Added
- Шаблон окружения [`.env.example`](../.env.example) для ZILANT и VOLK (секреты — плейсхолдеры).
- `.env` добавлен в [`.gitignore`](../.gitignore).
### Documentation
- README и `docs/ARCHITECTURE.md`: копирование `.env.example``.env` при настройке инстанса.
## [1.0.1] - 2026-08-30
### Changed
- Утилиты перенесены в `tools/`:
- `tools/ops/``register_tg_webhook.py`, `copy_databases.sh` (рядом с `genkey.py`)
- `tools/dev/``send_telegram_example.py`, `volk_telegram_testing.py`, `volk_category_testing.py`
- Скрипты в `tools/` загружают `.env` из корня проекта и добавляют корень в `sys.path` при импорте модулей ядра.
### Documentation
- Обновлены `tools/README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`, `docs/telegram-messaging.md`, `README.md`.
## [1.0.0] - 2026-08-30
### Added
- Файл версии `VERSION` (SemVer).
- Документация: `README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`.
- Каталог `tools/` с README; `tools/ops/genkey.py`, `tools/legacy/evt_prefetch.py`.
- Сезонные deep link и callback с префиксом `SEASON` (`season_links.py`, по умолчанию `zk2026`).
- Интеграция Telegram Bot API через internal relay (`telegram_relay.py`, `RELAY_URL`).
### Changed
- `evt_prefetch.py` перенесён в `tools/legacy/` — не входит в `runner.sh`.
- `genkey.py` перенесён в `tools/ops/`.
- Документация по runner и отправке сообщений — в `docs/`.
### Documentation
- Правило Cursor `.cursor/rules/version-and-docs.mdc` — поддержка версии и changelog при изменениях.
+77
View File
@@ -0,0 +1,77 @@
# Настройка автоматического запуска runner.sh
См. также [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) — состав пайплайна.
Есть два способа настроить запуск `runner.sh` каждый час:
## Вариант 1: Systemd Timer (рекомендуется)
В проекте уже используются systemd-сервисы.
### Установка
1. Скопируйте файлы в systemd:
```bash
sudo cp services/runner.service /etc/systemd/system/
sudo cp services/runner.timer /etc/systemd/system/
```
2. Или используйте скрипт установки:
```bash
chmod +x services/install_runner_timer.sh
sudo bash services/install_runner_timer.sh
```
3. Вручную:
```bash
sudo systemctl daemon-reload
sudo systemctl enable runner.timer
sudo systemctl start runner.timer
```
### Управление
- Статус: `sudo systemctl status runner.timer`
- Остановить: `sudo systemctl stop runner.timer`
- Запустить: `sudo systemctl start runner.timer`
- Отключить: `sudo systemctl disable runner.timer`
- Логи: `sudo journalctl -u runner.service -f`
- Список timers: `sudo systemctl list-timers`
### Расписание
Файл `/etc/systemd/system/runner.timer`:
- `OnCalendar=hourly` — каждый час
- `OnCalendar=*-*-* *:00:00` — в начале каждого часа
- `OnCalendar=*-*-* 0,6,12,18:00:00` — в 0, 6, 12, 18 часов
- `OnCalendar=Mon..Fri 09:00:00` — будни в 9:00
После изменения: `sudo systemctl daemon-reload`
---
## Вариант 2: Cron
```bash
sudo crontab -e
```
```bash
0 * * * * /bin/bash /opt/testbot-git/runner.sh
```
Другие варианты: `30 * * * *`, `*/30 * * * *`, `0 9-17 * * *`.
---
## Состав runner.sh (ZILANT)
`vk_load.py``zk_load.py``db_update_shortname.py``tg_publish.py``evtg_publish.py`
Legacy `tools/legacy/evt_prefetch.py` в пайплайн не входит (с 1.0.0).
Лог пайплайна: `/opt/testbot-git/logs/runner.log` (каталог `logs/` создаётся автоматически).
+94
View File
@@ -0,0 +1,94 @@
# Отправка сообщений через Telegram бота
## Описание
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
1. **API endpoint** для отправки сообщений из других скриптов
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
3. **Обработку ошибок** и логирование всех операций
## Архитектура системы
Система состоит из двух основных компонентов:
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
### Конфигурация портов
Порты и URL настраиваются в файле `.env`:
- `RELAY_URL` - базовый URL **internal_relay** (все исходящие вызовы Bot API идут только через него)
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
- `WEBHOOK_URL` - URL доставки апдейтов от internal_relay к боту
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
## Использование через веб-интерфейс
В заголовке страницы добавлены поля **ID пользователя**, **Сообщение** и кнопка **Отправить**.
## Использование через API
### Endpoint
```
POST /send_message
POST {WEBHOOK_PATH}/send_message
```
### Заголовки
```
Content-Type: application/json
X-API-Secret-Token: <WEBHOOK_SECRET>
```
### Тело запроса
```json
{
"user_id": 123456789,
"message": "Текст сообщения"
}
```
### Пример (Python)
```python
import requests
def send_telegram_message(user_id, message):
url = "http://192.168.0.109:5005/testbot/send_message"
headers = {
"Content-Type": "application/json",
"X-API-Secret-Token": "ваш_секретный_токен",
}
response = requests.post(url, json={"user_id": user_id, "message": message}, headers=headers)
return response.json()
```
### Пример (curl)
```bash
curl -X POST http://localhost:5005/testbot/send_message \
-H "Content-Type: application/json" \
-H "X-API-Secret-Token: ваш_секретный_токен" \
-d '{"user_id": 123456789, "message": "Привет!"}'
```
Опционально: CLI-скрипт `tools/dev/send_telegram_example.py`.
## Коды ошибок API
- `INVALID_TOKEN`, `NO_DATA`, `NO_USER_ID`, `NO_MESSAGE`, `INVALID_USER_ID`
- `TELEGRAM_ERROR`, `INTERNAL_ERROR`
## Требования
- Бот запущен (webhook через `wsgi_bot.py`)
- Пользователь начал диалог с ботом (`/start`)
См. также [`ARCHITECTURE.md`](ARCHITECTURE.md).
+53
View File
@@ -0,0 +1,53 @@
# Режим VOLK
Режим включается в `.env` (скопируйте из [`.env.example`](../.env.example)):
```env
WORKMODE = "VOLK"
```
Проект поддерживает оба режима — **ZILANT** и **VOLK**; переключение только через `WORKMODE`, без смены кодовой базы.
## Отличия от ZILANT
| Аспект | ZILANT | VOLK |
|--------|--------|------|
| Загрузка данных | `vk_load.py`, `zk_load.py` | `volk_load.py` |
| Публикация | `tg_publish.py`, `evtg_publish.py` | `volk_cat_publish.py` |
| Runner | Полный пайплайн ZILANT | Отдельные кнопки рескана в UI |
| UI | Посты + события VK/Zilant | Категории (площадки) + события VOLK |
## Модули VOLK
### `volk_load.py`
- Загружает список категорий и событий с VOLK API.
- Переменные: `VOLK_CATEGORY_LIST`, `VOLK_CATEGORY_DESC_PREFIX`, `VOLK_EVENT_LIST_PREFIX`, `VOLK_API_VERSION`.
- Вызывается из `db_edit.py` (API `/api/run_volk_rescan`, кнопка «Рескан ВОЛК»).
### `volk_cat_publish.py`
- Публикует категории (площадки) в Telegram-канал.
- Функции `tg_post_category_by_id`, `tg_post_all_categories` — из веб-UI.
### Шаблоны
- `templates/edit_category.html` — редактирование площадки (только VOLK).
- Блоки `{% if g.workmode == 'VOLK' %}` в `templates/index.html`.
## Отладочные скрипты (не в production-пайплайне)
| Файл | Запуск |
|------|--------|
| `tools/dev/volk_category_testing.py` | `python tools/dev/volk_category_testing.py <category_id>` |
| `tools/dev/volk_telegram_testing.py` | `python tools/dev/volk_telegram_testing.py` |
См. [`tools/README.md`](../tools/README.md).
## Переключение режима на сервере
1. Изменить `WORKMODE` в `/opt/testbot-git/.env`.
2. Перезапустить UI: `sudo systemctl restart testedit-git.service`.
3. Responder-бот (`testbot-git`) от режима не зависит; сезонные ссылки общие (`SEASON`).
При смене режима проверьте переменные VOLK/ZILANT в `.env` (префиксы URL, API, `LOG_FILE`).
+205 -205
View File
@@ -6,9 +6,18 @@ import time
import html import html
import httpx import httpx
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from urllib.parse import urlparse, unquote
from dotenv import load_dotenv from dotenv import load_dotenv
from formatter import get_event_text from formatter import get_event_text
from telegram_relay import bot_api_method_url 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()
@@ -91,6 +100,154 @@ def prepare_text(text):
return text return text
def _filename_from_image_url(image_url):
path = unquote(urlparse(image_url).path)
name = os.path.basename(path) or "image.jpg"
if "." not in name:
name = f"{name}.jpg"
return name
async def download_image(image_url, httpx_client):
"""Скачивает картинку самим сервером (как tg_publish), не отдаёт URL Telegram.
sendPhoto с photo=http://... Telegram качает сам и часто отвечает
«failed to get HTTP URL content» (http, недоступность с DC Telegram).
"""
logger.info(f"Скачивание изображения: {image_url}")
started = time.time()
response = await httpx_client.get(
image_url,
timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0),
)
response.raise_for_status()
image_data = response.content
content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
if not content_type.startswith("image/"):
content_type = "image/jpeg"
logger.info(
f"Изображение скачано за {time.time() - started:.2f} с, "
f"размер: {len(image_data)} байт, type={content_type}"
)
return image_data, content_type, _filename_from_image_url(image_url)
async def _edit_event_add_links(
httpx_client, has_image, message_id, final_text, entity_label, expected_urls
):
"""Edit caption/text со ссылками; повтор до подтверждения всех expected_urls в text_link."""
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
method = "editMessageCaption" if has_image else "editMessageText"
field = "caption" if has_image else "text"
entities_field = "caption_entities" if has_image else "entities"
api_url = bot_api_method_url(BOT_TOKEN, method)
logger.info(
f"Попытка редактирования {entity_label}: message_id={message_id}, "
f"final_text_length={len(final_text)}"
)
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
field: final_text,
'parse_mode': 'HTML',
}
if not has_image:
payload['disable_web_page_preview'] = True
try:
response = await httpx_client.post(api_url, json=payload)
except httpx.TimeoutException as e:
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
logger.error(
f"Таймаут edit ссылок для {entity_label} после "
f"{EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
)
return False
logger.warning(
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}"
)
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
continue
if response.status_code == 200:
data = response.json()
if data.get('ok'):
result = data.get('result') or {}
result_text = result.get(field) or ""
entities = result.get(entities_field) or []
if published_message_has_expected_links(entities, expected_urls):
logger.info(
f"Ссылки добавлены для {entity_label} "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
found = extract_text_link_urls(entities)
logger.warning(
f"edit ok, но footer-ссылки не подтверждены "
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}; "
f"ожидались {expected_urls}, найдены text_link: {found}"
)
else:
error_code = data.get('error_code')
error_description = (data.get('description') or '').lower()
if error_code == 429:
retry_after = data.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
if error_code == 400 and "not modified" in error_description:
logger.info(
f"Caption уже содержит ссылки для {entity_label} "
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"API error edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {data.get('description')}"
)
elif response.status_code == 429:
retry_after = 60
try:
retry_after = response.json().get('parameters', {}).get('retry_after', 60)
except Exception:
pass
raise Exception(f"RetryAfter:{retry_after}")
elif response.status_code == 400:
try:
error_description = (response.json().get('description') or '').lower()
if "not modified" in error_description:
logger.info(
f"Caption уже содержит ссылки для {entity_label} "
f"(message is not modified HTTP 400, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
)
return True
logger.warning(
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
except Exception:
logger.warning(
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
else:
logger.warning(
f"HTTP {response.status_code} edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
f"для {entity_label}: {response.text[:300]}"
)
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
logger.error(
f"Не удалось добавить строку ссылок для {entity_label} "
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
)
return False
async def tg_post_event(httpx_client, event_data): async def tg_post_event(httpx_client, event_data):
"""Публикация одного события""" """Публикация одного события"""
try: try:
@@ -149,7 +306,9 @@ async def tg_post_event(httpx_client, event_data):
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>') links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
if USE_SUBSCRIPTION_BOT: if USE_SUBSCRIPTION_BOT:
links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>') links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
links_parts.append(f'🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{placeholder_message_id}">Подписка</a>') links_parts.append(
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", placeholder_message_id)}">Подписка</a>'
)
if links_parts: if links_parts:
links_line_placeholder = ' | '.join(links_parts) links_line_placeholder = ' | '.join(links_parts)
@@ -191,24 +350,35 @@ async def tg_post_event(httpx_client, event_data):
# Публикация первоначального сообщения без ссылок # Публикация первоначального сообщения без ссылок
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto") send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage") send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
message_is_photo = False
if has_image: if has_image:
try: try:
logger.info(f"Попытка отправки фото для события {event_data['number']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}") logger.info(
photo_payload = { f"Попытка отправки фото для события {event_data['number']}: "
f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
f"image_url={image_url}"
)
image_data, content_type, filename = await download_image(image_url, httpx_client)
photo_data = {
'chat_id': CHANNEL_ID, 'chat_id': CHANNEL_ID,
'photo': image_url,
'caption': initial_text, 'caption': initial_text,
'parse_mode': 'HTML', 'parse_mode': 'HTML',
'disable_notification': PUBLISH_SILENTLY 'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
} }
photo_response = await httpx_client.post(send_photo_url, json=photo_payload) photo_files = {
'photo': (filename, image_data, content_type),
}
photo_response = await httpx_client.post(
send_photo_url, data=photo_data, files=photo_files
)
if photo_response.status_code == 200: if photo_response.status_code == 200:
photo_result = photo_response.json() photo_result = photo_response.json()
if photo_result.get('ok'): if photo_result.get('ok'):
message_data = photo_result.get('result', {}) message_data = photo_result.get('result', {})
message_id = message_data.get('message_id') message_id = message_data.get('message_id')
message_is_photo = True
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}") logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
else: else:
# Ошибка в ответе API - пробуем отправить текстовое сообщение # Ошибка в ответе API - пробуем отправить текстовое сообщение
@@ -357,7 +527,9 @@ async def tg_post_event(httpx_client, event_data):
links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>') links_parts.append(f'🔎 <a href="{DESC_PREFIX}{number}/">Инфо</a>')
if USE_SUBSCRIPTION_BOT: if USE_SUBSCRIPTION_BOT:
links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>') links_parts.append(f'📌 <a href="{PZK_PREFIX}{number}/">Иду</a>')
links_parts.append(f'🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{message_id}">Подписка</a>') links_parts.append(
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)}">Подписка</a>'
)
if links_parts: if links_parts:
links_line = ' | '.join(links_parts) links_line = ' | '.join(links_parts)
@@ -365,208 +537,36 @@ async def tg_post_event(httpx_client, event_data):
else: else:
final_text = initial_text 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: if final_text == initial_text:
logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется") logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
return message_id return message_id
await asyncio.sleep(2) entity_label = f"события {event_data['number']}"
links_ok = await _edit_event_add_links(
# Редактируем сообщение, добавляя ссылки httpx_client, message_is_photo, message_id, final_text, entity_label,
logger.info(f"Попытка редактирования сообщения для события {event_data['number']}: message_id={message_id}, final_text_length={len(final_text)}") expected_urls,
edit_caption_url = bot_api_method_url(BOT_TOKEN, "editMessageCaption") )
edit_text_url = bot_api_method_url(BOT_TOKEN, "editMessageText") if not links_ok and message_is_photo:
links_ok = await _edit_event_add_links(
if has_image: httpx_client, False, message_id, final_text, entity_label,
try: expected_urls,
edit_caption_payload = { )
'chat_id': CHANNEL_ID, if not links_ok:
'message_id': message_id, logger.error(
'caption': final_text, f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
'parse_mode': 'HTML' f"но ссылки не добавлены"
} )
edit_caption_response = await httpx_client.post(edit_caption_url, json=edit_caption_payload) return None
if edit_caption_response.status_code == 200:
edit_result = edit_caption_response.json()
if edit_result.get('ok'):
logger.info(f"Подпись успешно отредактирована для события {event_data['number']}")
else:
# Ошибка при редактировании подписи
error_code = edit_result.get('error_code')
error_description = edit_result.get('description', 'Unknown error')
# Обрабатываем ошибку 400 (сообщение не изменено) как предупреждение
if error_code == 400 and "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {error_description}")
else:
if edit_caption_response.status_code == 400:
try:
error_data = edit_caption_response.json()
error_description = error_data.get('description', '')
if "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
except:
pass
if edit_caption_response.status_code == 429:
retry_after = 60
try:
error_data = edit_caption_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_caption_response.status_code}: {edit_caption_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при редактировании подписи для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" caption_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
logger.warning(f"Не удалось отредактировать подпись для события номер {event_data['number']}: {e}. Пробуем отредактировать текстовое сообщение.")
logger.error(f"Полная информация об ошибке Telegram при редактировании подписи для события {event_data['number']}:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e) if hasattr(e, '__dict__') else 'N/A'}")
logger.error(f" Полное представление: {repr(e)}")
logger.info(f"Попытка редактирования текста вместо подписи для события {event_data['number']}")
try:
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200:
edit_result = edit_text_response.json()
if edit_result.get('ok'):
logger.info(f"Текст успешно отредактирован для события {event_data['number']}")
else:
error_code = edit_result.get('error_code')
error_description = edit_result.get('description', 'Unknown error')
# Обрабатываем ошибку 400 (сообщение не изменено) как предупреждение
if error_code == 400 and "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {error_description}")
else:
if edit_text_response.status_code == 400:
try:
error_data = edit_text_response.json()
error_description = error_data.get('description', '')
if "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
except:
pass
if edit_text_response.status_code == 429:
retry_after = 60
try:
error_data = edit_text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_text_response.status_code}: {edit_text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при редактировании текста для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" text_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
except Exception as e:
error_msg = str(e)
if error_msg.startswith("RetryAfter:"):
raise
raise
else:
try:
edit_text_payload = {
'chat_id': CHANNEL_ID,
'message_id': message_id,
'text': final_text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
edit_text_response = await httpx_client.post(edit_text_url, json=edit_text_payload)
if edit_text_response.status_code == 200:
edit_result = edit_text_response.json()
if edit_result.get('ok'):
logger.info(f"Текст успешно отредактирован для события {event_data['number']}")
else:
error_code = edit_result.get('error_code')
error_description = edit_result.get('description', 'Unknown error')
# Обрабатываем ошибку 400 (сообщение не изменено) как предупреждение
if error_code == 400 and "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
if error_code == 429:
retry_after = edit_result.get('parameters', {}).get('retry_after', 60)
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"API error: {error_description}")
else:
if edit_text_response.status_code == 400:
try:
error_data = edit_text_response.json()
error_description = error_data.get('description', '')
if "not modified" in error_description.lower():
logger.info(f"Сообщение для события {event_data['number']} не требует редактирования (текст идентичен)")
return message_id
except:
pass
if edit_text_response.status_code == 429:
retry_after = 60
try:
error_data = edit_text_response.json()
retry_after = error_data.get('parameters', {}).get('retry_after', 60)
except:
pass
raise Exception(f"RetryAfter:{retry_after}")
raise Exception(f"HTTP {edit_text_response.status_code}: {edit_text_response.text}")
except httpx.TimeoutException as e:
logger.error(f"ТАЙМАУТ при редактировании текста для события номер {event_data['number']}")
logger.error(f"Полная диагностическая информация об ошибке TimedOut:")
logger.error(f" Тип ошибки: {type(e).__name__}")
logger.error(f" Сообщение: {str(e)}")
logger.error(f" Все атрибуты ошибки: {vars(e)}")
logger.error(f" Полное представление: {repr(e)}")
logger.error(f" Параметры запроса:")
logger.error(f" chat_id: {CHANNEL_ID}")
logger.error(f" message_id: {message_id}")
logger.error(f" text_length: {len(final_text)}")
logger.error(f" parse_mode: HTML")
raise
return message_id # Возвращаем только message_id return message_id # Возвращаем только message_id
-2
View File
@@ -1,2 +0,0 @@
import secrets
print(secrets.token_hex(32))
+5
View File
@@ -0,0 +1,5 @@
"""Конфиг gunicorn для responder-бота.
Регистрация webhook выполняется в wsgi_bot.py (с file lock), чтобы сработала
даже если unit забыли обновить с --config.
"""
+19 -10
View File
@@ -6,15 +6,18 @@ SCRIPTS_DIR="/opt/testbot-git"
VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate" VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate"
# Список скриптов для запуска в порядке выполнения # Список скриптов для запуска в порядке выполнения
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "evt_prefetch.py" "tg_publish.py" "evtg_publish.py") # Legacy prefetch (не в пайплайне): tools/legacy/evt_prefetch.py
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py") # SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tools/legacy/evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py")
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py")
# Интервалы между скриптами (в секундах) # Интервалы между скриптами (в секундах)
# DELAYS=(2 2 2 2 30) # DELAYS=(2 2 2 2 30)
DELAYS=(2 2 2 30) DELAYS=(2 2 2)
# Лог-файл # Лог-файл
LOG_FILE="$SCRIPTS_DIR/runner.log" LOG_DIR="$SCRIPTS_DIR/logs"
LOG_FILE="$LOG_DIR/runner.log"
# Функция для логирования # Функция для логирования
log_message() { log_message() {
@@ -24,11 +27,20 @@ log_message() {
# Проверка и настройка лог-файла # Проверка и настройка лог-файла
setup_logfile() { setup_logfile() {
# Проверяем, существует ли файл и можно ли в него писать if [ ! -d "$LOG_DIR" ]; then
if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
if sudo mkdir -p "$LOG_DIR" 2>/dev/null; then
sudo chown "$(whoami)" "$LOG_DIR"
sudo chmod 755 "$LOG_DIR"
else
echo "ОШИБКА: Не удалось создать каталог $LOG_DIR!"
exit 1
fi
fi
fi
if [ ! -f "$LOG_FILE" ]; then if [ ! -f "$LOG_FILE" ]; then
# Пытаемся создать файл
if ! touch "$LOG_FILE" 2>/dev/null; then if ! touch "$LOG_FILE" 2>/dev/null; then
# Если не получается, пробуем с sudo
if sudo touch "$LOG_FILE" 2>/dev/null; then if sudo touch "$LOG_FILE" 2>/dev/null; then
sudo chown $(whoami) "$LOG_FILE" sudo chown $(whoami) "$LOG_FILE"
sudo chmod 644 "$LOG_FILE" sudo chmod 644 "$LOG_FILE"
@@ -40,9 +52,7 @@ setup_logfile() {
fi fi
fi fi
# Проверяем права на запись
if [ ! -w "$LOG_FILE" ]; then if [ ! -w "$LOG_FILE" ]; then
# Пытаемся изменить права
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
log_message "Изменены права на лог-файл: $LOG_FILE" log_message "Изменены права на лог-файл: $LOG_FILE"
else else
@@ -112,4 +122,3 @@ done
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!" log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
log_message "=== ЗАВЕРШЕНИЕ ===" log_message "=== ЗАВЕРШЕНИЕ ==="
+127
View File
@@ -0,0 +1,127 @@
"""
Сезонные deep link и callback для подписок.
Формат текущего сезона (SEASON=zk2026):
start=zk2026_post_{tg_message_id}
start=zk2026_event_{tg_message_id}
callback: subscribe_zk2026_{id} / unsubscribe_zk2026_{id}
callback: subscribe_evt_zk2026_{id} / unsubscribe_evt_zk2026_{id}
Старые форматы (post_123, event_123, subscribe_123, …) и чужой сезон
считаются завершённым прошлым сезоном.
"""
import os
import re
from typing import Optional, Tuple
from dotenv import load_dotenv
_ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
load_dotenv(_ENV_PATH)
load_dotenv()
SEASON = (os.getenv("SEASON") or "zk2026").strip().strip('"').strip("'")
# start: zk2026_post_123 / zk2026_event_123
_START_RE = re.compile(
rf"^(?P<season>[A-Za-z0-9]+)_((?P<kind>post|event)_)(?P<id>\d+)$"
)
# legacy start: post_123 / event_123
_START_LEGACY_RE = re.compile(r"^(?P<kind>post|event)_(?P<id>\d+)$")
# callback posts: subscribe_zk2026_123
_CB_POST_RE = re.compile(
r"^(?P<action>subscribe|unsubscribe)_(?P<season>[A-Za-z0-9]+)_(?P<id>\d+)$"
)
# legacy: subscribe_123
_CB_POST_LEGACY_RE = re.compile(r"^(?P<action>subscribe|unsubscribe)_(?P<id>\d+)$")
# callback events: subscribe_evt_zk2026_123
_CB_EVT_RE = re.compile(
r"^(?P<action>subscribe|unsubscribe)_evt_(?P<season>[A-Za-z0-9]+)_(?P<id>\d+)$"
)
# legacy: subscribe_evt_123
_CB_EVT_LEGACY_RE = re.compile(r"^(?P<action>subscribe|unsubscribe)_evt_(?P<id>\d+)$")
def subscription_start_link(bot_name: str, kind: str, message_id) -> str:
"""HTTPS deep link на бота: kind = 'post' | 'event'."""
payload = f"{SEASON}_{kind}_{message_id}"
return f"https://t.me/{bot_name}?start={payload}"
def callback_subscribe_post(message_id) -> str:
return f"subscribe_{SEASON}_{message_id}"
def callback_unsubscribe_post(message_id) -> str:
return f"unsubscribe_{SEASON}_{message_id}"
def callback_subscribe_event(message_id) -> str:
return f"subscribe_evt_{SEASON}_{message_id}"
def callback_unsubscribe_event(message_id) -> str:
return f"unsubscribe_evt_{SEASON}_{message_id}"
def parse_start_payload(payload: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Разбор аргумента /start.
Returns:
(kind, id, is_current_season)
kind: 'post' | 'event' | None
is_current_season: False → показать «сезон завершён»
"""
if not payload:
return None, None, True
m = _START_RE.match(payload)
if m:
return m.group("kind"), m.group("id"), m.group("season") == SEASON
m = _START_LEGACY_RE.match(payload)
if m:
return m.group("kind"), m.group("id"), False
# Неизвестный payload — считаем устаревшим/чужим, без действия по id
return None, None, False
def parse_post_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Returns:
(action, id, is_current_season) для кнопок подписки на посты.
action: 'subscribe' | 'unsubscribe' | None
"""
if data.startswith("subscribe_evt_") or data.startswith("unsubscribe_evt_"):
return None, None, True
m = _CB_POST_RE.match(data)
if m:
return m.group("action"), m.group("id"), m.group("season") == SEASON
m = _CB_POST_LEGACY_RE.match(data)
if m:
return m.group("action"), m.group("id"), False
return None, None, True
def parse_event_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]:
"""
Returns:
(action, id, is_current_season) для кнопок подписки на события.
"""
m = _CB_EVT_RE.match(data)
if m:
return m.group("action"), m.group("id"), m.group("season") == SEASON
m = _CB_EVT_LEGACY_RE.match(data)
if m:
return m.group("action"), m.group("id"), False
return None, None, True
+2 -86
View File
@@ -1,87 +1,3 @@
# Настройка автоматического запуска runner.sh каждый час # Перенесено
Есть два способа настроить запуск `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
Классический способ для периодических задач.
### Установка:
1. Откройте crontab для редактирования:
```bash
sudo crontab -e
```
2. Добавьте строку:
```bash
0 * * * * /bin/bash /opt/testbot-git/runner.sh >> /opt/testbot-git/runner_cron.log 2>&1
```
Это запустит скрипт в начале каждого часа (0 минут каждого часа).
### Альтернативные варианты расписания:
- `0 * * * *` - каждый час в начале часа (00:00, 01:00, 02:00...)
- `30 * * * *` - каждый час в 30 минут (00:30, 01:30, 02:30...)
- `*/30 * * * *` - каждые 30 минут
- `0 9-17 * * *` - каждый час с 9:00 до 17:00
### Просмотр логов:
Логи будут записываться в `/opt/testbot-git/runner_cron.log` (если указан в crontab).
---
## Рекомендации
- **Systemd Timer** - лучше для интеграции с существующими сервисами, более гибкое управление, лучшие логи
- **Cron** - проще для простых задач, не требует root прав (можно использовать `crontab -e` без sudo)
Документация: [`docs/runner-setup.md`](../docs/runner-setup.md)
+1 -1
View File
@@ -6,7 +6,7 @@ After=network.target
[Service] [Service]
User=root User=root
WorkingDirectory=/opt/testbot-git WorkingDirectory=/opt/testbot-git
ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5005 --workers 4 wsgi_bot:app ExecStart=/opt/testbot-git/venv/bin/gunicorn --bind 192.168.0.109:5005 --workers 4 --config gunicorn_bot.conf.py wsgi_bot:app
Restart=always Restart=always
[Install] [Install]
+50 -1
View File
@@ -13,12 +13,15 @@ from dotenv import load_dotenv
def get_relay_url() -> str: def get_relay_url() -> str:
"""Базовый URL internal_relay без завершающего слэша.""" """Базовый URL internal_relay без завершающего слэша."""
load_dotenv() load_dotenv()
relay = (os.getenv("RELAY_URL") or "").rstrip("/") relay = (os.getenv("RELAY_URL") or "").strip().rstrip("/")
if not relay: if not relay:
raise RuntimeError( raise RuntimeError(
"Требуется переменная окружения RELAY_URL " "Требуется переменная окружения RELAY_URL "
"(базовый URL internal_relay, например http://127.0.0.1:8081)" "(базовый URL internal_relay, например http://127.0.0.1:8081)"
) )
# Допускаем host:port без схемы — httpx требует http:// или https://
if "://" not in relay:
relay = f"http://{relay}"
return relay return relay
@@ -47,3 +50,49 @@ def get_ptb_base_urls() -> Tuple[str, str]:
def bot_api_method_url(token: str, method: str) -> str: def bot_api_method_url(token: str, method: str) -> str:
"""Полный URL метода Bot API через relay (для сырых httpx/requests вызовов).""" """Полный URL метода Bot API через relay (для сырых httpx/requests вызовов)."""
return f"{get_relay_url()}/bot{token}/{method}" 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)
+32 -9
View File
@@ -1579,23 +1579,46 @@
'X-Requested-With': 'XMLHttpRequest' 'X-Requested-With': 'XMLHttpRequest'
} }
}) })
.then(response => response.json()) .then(async response => {
.then(data => { let data = null;
try {
data = await response.json();
} catch (e) {
// Прокси мог оборвать долгий ответ (502/504 HTML) — название часто уже в БД
throw new Error(
`Сервер вернул не-JSON (HTTP ${response.status}). ` +
`Если запрос был долгим, название могло уже сохраниться — обновите список.`
);
}
if (!response.ok) {
throw new Error((data && data.error) || `HTTP ${response.status}`);
}
return data;
})
.then(async data => {
hideProcessingMessage(); hideProcessingMessage();
if (data.success) { if (data && data.success) {
alert('Название успешно сгенерировано: ' + data.shortname); alert('Название успешно сгенерировано: ' + data.shortname);
loadPosts(); // Перезагружаем список постов await loadPosts();
// Восстанавливаем выбранный пост
highlightSelectedPost(postId); highlightSelectedPost(postId);
showPostDetails(postId); await showPostDetails(postId);
} else { } else {
alert('Ошибка генерации названия: ' + (data.error || 'неизвестная ошибка')); alert('Ошибка генерации названия: ' + ((data && data.error) || 'неизвестная ошибка'));
await loadPosts();
} }
}) })
.catch(error => { .catch(async error => {
hideProcessingMessage(); hideProcessingMessage();
console.error('Ошибка генерации названия:', error); console.error('Ошибка генерации названия:', error);
alert('Произошла ошибка при генерации названия'); // После таймаута прокси название часто уже записано — подтянем список
try {
await loadPosts();
highlightSelectedPost(postId);
await showPostDetails(postId);
} catch (e) {
console.error('Не удалось обновить список после ошибки:', e);
}
alert('Ошибка при генерации названия: ' + (error.message || error));
}); });
} }
// Функция для публикации конкретного поста // Функция для публикации конкретного поста
+243 -63
View File
@@ -2,6 +2,7 @@ import os
import logging import logging
import time import time
import errno import errno
import json
from dotenv import load_dotenv from dotenv import load_dotenv
import telebot import telebot
from telebot import apihelper from telebot import apihelper
@@ -14,13 +15,25 @@ import requests
from io import BytesIO from io import BytesIO
from flask import Flask, request from flask import Flask, request
from formatter import get_post_text, get_event_text from formatter import get_post_text, get_event_text
from telegram_relay import configure_telebot_api, get_relay_url from telegram_relay import configure_telebot_api, get_relay_url, bot_api_method_url
from season_links import (
SEASON,
callback_subscribe_post,
callback_unsubscribe_post,
callback_subscribe_event,
callback_unsubscribe_event,
parse_start_payload,
parse_post_callback,
parse_event_callback,
)
# Создаем Flask app на верхнем уровне для экспорта # Создаем Flask app на верхнем уровне для экспорта
app = Flask(__name__) app = Flask(__name__)
# Загружаем переменные окружения из файла .env # Загружаем .env рядом с этим файлом (не зависим от cwd gunicorn)
load_dotenv() _ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
load_dotenv(_ENV_PATH)
load_dotenv() # и из cwd на всякий случай
# Получаем данные из переменных окружения # Получаем данные из переменных окружения
RESPONDER_BOT_TOKEN = os.getenv('RESPONDER_BOT_TOKEN') RESPONDER_BOT_TOKEN = os.getenv('RESPONDER_BOT_TOKEN')
@@ -28,6 +41,8 @@ CHANNEL_ID = os.getenv('CHANNEL_ID')
WEBHOOK_URL = os.getenv('WEBHOOK_URL') WEBHOOK_URL = os.getenv('WEBHOOK_URL')
WEBHOOK_PORT = int(os.getenv('WEBHOOK_PORT', '8443')) WEBHOOK_PORT = int(os.getenv('WEBHOOK_PORT', '8443'))
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET') WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET')
if WEBHOOK_SECRET:
WEBHOOK_SECRET = WEBHOOK_SECRET.strip().strip('"').strip("'")
LOG_FILE = os.getenv('LOG_FILE', 'bot.log') LOG_FILE = os.getenv('LOG_FILE', 'bot.log')
RELAY_URL = os.getenv('RELAY_URL') RELAY_URL = os.getenv('RELAY_URL')
@@ -174,6 +189,7 @@ logger.info(f"Telegram Bot API через ретранслятор: {relay}")
# Создаем экземпляр бота # Создаем экземпляр бота
bot = telebot.TeleBot(RESPONDER_BOT_TOKEN) bot = telebot.TeleBot(RESPONDER_BOT_TOKEN)
logger.info(f"Сезон подписок SEASON={SEASON}")
def get_user_name(user): def get_user_name(user):
"""Форматирует имя пользователя для записи в БД""" """Форматирует имя пользователя для записи в БД"""
@@ -567,6 +583,65 @@ def format_channel_link(post_id=None):
else: else:
return f"https://t.me/c/{CHANNEL_ID}" return f"https://t.me/c/{CHANNEL_ID}"
def get_last_channel_message_id():
"""Максимальный tg_message_id среди опубликованных постов и событий (текущая БД)."""
conn = create_db_connection()
if conn is None:
return None
try:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT MAX(mid) AS last_id FROM (
SELECT MAX(tg_message_id) AS mid FROM posts
WHERE tg_message_id IS NOT NULL AND tg_message_id > 0
UNION ALL
SELECT MAX(tg_message_id) AS mid FROM events
WHERE tg_message_id IS NOT NULL AND tg_message_id > 0
) t
"""
)
row = cursor.fetchone()
last_id = row["last_id"] if row else None
return int(last_id) if last_id else None
except pymysql.Error as e:
logger.error(f"Ошибка получения последнего message_id канала: {e}")
return None
finally:
conn.close()
def get_return_to_channel_link():
"""Ссылка на последнее известное сообщение в канале или на сам канал."""
last_id = get_last_channel_message_id()
if last_id:
return format_channel_link(last_id)
return format_channel_link()
def create_season_ended_keyboard():
keyboard = InlineKeyboardMarkup()
keyboard.row(
InlineKeyboardButton("↩ Вернуться в канал", url=get_return_to_channel_link())
)
return keyboard
def send_season_ended_message(chat_id):
"""Ответ на клик по подписке прошлого сезона (или без префикса SEASON)."""
logger.info(f"send_season_ended_message chat_id={chat_id}, current SEASON={SEASON}")
text = (
"Этот сезон уже завершён.\n\n"
"Подписки и кнопки из прошлых анонсов больше не работают. "
"Актуальные события — в канале."
)
bot.send_message(
chat_id,
text,
reply_markup=create_season_ended_keyboard(),
)
def create_help_keyboard(): def create_help_keyboard():
"""Создает клавиатуру для справки""" """Создает клавиатуру для справки"""
logger.info(f"starting create_help_keyboard") logger.info(f"starting create_help_keyboard")
@@ -596,13 +671,13 @@ def create_manage_keyboard(post_id, is_subscribed):
if is_subscribed: if is_subscribed:
keyboard.row( keyboard.row(
InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❓ Справка", callback_data="cmd_help"),
InlineKeyboardButton("❌ Отписаться", callback_data=f"unsubscribe_{post_id}"), InlineKeyboardButton("❌ Отписаться", callback_data=callback_unsubscribe_post(post_id)),
InlineKeyboardButton("↪ Назад", url=channel_link) InlineKeyboardButton("↪ Назад", url=channel_link)
) )
else: else:
keyboard.row( keyboard.row(
InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❓ Справка", callback_data="cmd_help"),
InlineKeyboardButton("✔ Подписаться", callback_data=f"subscribe_{post_id}"), InlineKeyboardButton("✔ Подписаться", callback_data=callback_subscribe_post(post_id)),
InlineKeyboardButton("↪ Назад", url=channel_link) InlineKeyboardButton("↪ Назад", url=channel_link)
) )
return keyboard return keyboard
@@ -616,13 +691,13 @@ def create_manage_keyboard_evt(event_id, is_subscribed):
if is_subscribed: if is_subscribed:
keyboard.row( keyboard.row(
InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❓ Справка", callback_data="cmd_help"),
InlineKeyboardButton("❌ Отписаться", callback_data=f"unsubscribe_evt_{event_id}"), InlineKeyboardButton("❌ Отписаться", callback_data=callback_unsubscribe_event(event_id)),
InlineKeyboardButton("↪ Назад", url=channel_link) InlineKeyboardButton("↪ Назад", url=channel_link)
) )
else: else:
keyboard.row( keyboard.row(
InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), InlineKeyboardButton("❓ Справка", callback_data="cmd_help"),
InlineKeyboardButton("✔ Подписаться", callback_data=f"subscribe_evt_{event_id}"), InlineKeyboardButton("✔ Подписаться", callback_data=callback_subscribe_event(event_id)),
InlineKeyboardButton("↪ Назад", url=channel_link) InlineKeyboardButton("↪ Назад", url=channel_link)
) )
@@ -1023,18 +1098,19 @@ def handle_start(message):
user_name = get_user_name(user) user_name = get_user_name(user)
if len(args) > 1: if len(args) > 1:
# Обработка команды подписки на посты kind, entity_id, is_current = parse_start_payload(args[1])
if args[1].startswith('post_'): if not is_current:
post_id = args[1].split('_')[1] send_season_ended_message(message.chat.id)
# Отправляем сообщение управления подпиской
send_management_message(message.chat.id, post_id, user_id, user_name)
return return
# Обработка команды подписки на события if kind == "post" and entity_id:
elif args[1].startswith('event_'): send_management_message(message.chat.id, entity_id, user_id, user_name)
event_id = args[1].split('_')[1]
# Отправляем сообщение управления подпиской
send_management_message_evt(message.chat.id, event_id, user_id, user_name)
return return
if kind == "event" and entity_id:
send_management_message_evt(message.chat.id, entity_id, user_id, user_name)
return
# Неизвестный payload текущего сезона
send_season_ended_message(message.chat.id)
return
# Команда /start без параметров # Команда /start без параметров
welcome_text = get_welcome_text() welcome_text = get_welcome_text()
@@ -1307,17 +1383,26 @@ def handle_callback(call):
return return
# Обработка кнопок управления подпиской на посты # Обработка кнопок управления подпиской на посты
if (call.data.startswith("subscribe_") or call.data.startswith("unsubscribe_")) and not call.data.startswith(("subscribe_evt_", "unsubscribe_evt_")): action, post_id, is_current = parse_post_callback(call.data)
if action and post_id:
if not is_current:
bot.answer_callback_query(call.id, "Этот сезон уже завершён")
send_season_ended_message(call.message.chat.id)
return
user = call.from_user user = call.from_user
user_id = user.id user_id = user.id
user_name = get_user_name(user) user_name = get_user_name(user)
# Разделяем данные callback if not get_post_data(post_id):
parts = call.data.split('_', 1) bot.answer_callback_query(call.id, "Анонс не найден")
action = parts[0] bot.send_message(
post_id = parts[1] call.message.chat.id,
"❌ Анонс не найден в базе данных текущего сезона.",
reply_markup=create_season_ended_keyboard(),
)
return
# Выполняем действие
if action == "subscribe": if action == "subscribe":
save_mark_to_db(post_id, user_id, user_name) save_mark_to_db(post_id, user_id, user_name)
result_text = "✅ Вы успешно подписались!" result_text = "✅ Вы успешно подписались!"
@@ -1366,15 +1451,26 @@ def handle_callback(call):
return return
# Обработка кнопок управления подпиской на события # Обработка кнопок управления подпиской на события
if call.data.startswith("subscribe_evt_") or call.data.startswith("unsubscribe_evt_"): action, event_id, is_current = parse_event_callback(call.data)
if action and event_id:
if not is_current:
bot.answer_callback_query(call.id, "Этот сезон уже завершён")
send_season_ended_message(call.message.chat.id)
return
user = call.from_user user = call.from_user
user_id = user.id user_id = user.id
user_name = get_user_name(user) user_name = get_user_name(user)
parts = call.data.split('_', 2)
action = parts[0]
event_id = parts[2]
# Выполняем действие if not get_event_data(event_id):
bot.answer_callback_query(call.id, "Событие не найдено")
bot.send_message(
call.message.chat.id,
"❌ Событие не найдено в базе данных текущего сезона.",
reply_markup=create_season_ended_keyboard(),
)
return
if action == "subscribe": if action == "subscribe":
save_mark_to_db_evt(event_id, user_id, user_name) save_mark_to_db_evt(event_id, user_id, user_name)
result_text = "✅ Вы успешно подписались!" result_text = "✅ Вы успешно подписались!"
@@ -1382,22 +1478,14 @@ def handle_callback(call):
remove_mark_from_db_evt(event_id, user_id, user_name) remove_mark_from_db_evt(event_id, user_id, user_name)
result_text = "✅ Вы успешно отписались!" result_text = "✅ Вы успешно отписались!"
# Обновляем сообщение с новым статусом
try: try:
# Получаем текущие данные о событии
event_data = format_event_message(event_id) event_data = format_event_message(event_id)
text = event_data['text'] text = event_data['text']
# Создаем новую клавиатуру
is_subscribed = action == "subscribe" is_subscribed = action == "subscribe"
keyboard = create_manage_keyboard_evt(event_id, is_subscribed) keyboard = create_manage_keyboard_evt(event_id, is_subscribed)
# Для сообщений с изображением
if call.message.content_type == 'photo': if call.message.content_type == 'photo':
# Получаем file_id существующего изображения
file_id = call.message.photo[-1].file_id
# Редактируем подпись к изображению
bot.edit_message_caption( bot.edit_message_caption(
chat_id=call.message.chat.id, chat_id=call.message.chat.id,
message_id=call.message.message_id, message_id=call.message.message_id,
@@ -1406,7 +1494,6 @@ def handle_callback(call):
parse_mode='HTML' parse_mode='HTML'
) )
else: else:
# Редактируем текстовое сообщение
bot.edit_message_text( bot.edit_message_text(
chat_id=call.message.chat.id, chat_id=call.message.chat.id,
message_id=call.message.message_id, message_id=call.message.message_id,
@@ -1415,7 +1502,6 @@ def handle_callback(call):
parse_mode='HTML' parse_mode='HTML'
) )
# Отправляем отдельное сообщение о результате
bot.answer_callback_query(call.id, result_text) bot.answer_callback_query(call.id, result_text)
except Exception as e: except Exception as e:
logger.error(f"Ошибка обновления сообщения (events): {e}") logger.error(f"Ошибка обновления сообщения (events): {e}")
@@ -1433,38 +1519,128 @@ def handle_callback(call):
bot.answer_callback_query(call.id, "Произошла ошибка. Пожалуйста, попробуйте позже.") bot.answer_callback_query(call.id, "Произошла ошибка. Пожалуйста, попробуйте позже.")
def setup_webhook(): def setup_webhook():
"""Настройка вебхука""" """
logger.info(f"starting setup_webhook") Регистрация webhook через internal_relay.
try:
# Удаляем предыдущий вебхук
bot.remove_webhook()
# Устанавливаем новый вебхук Важно: relay перехватывает setWebhook и уносит secret_token в Telegram через
bot.set_webhook( extra_params. Параметры нужно слать как application/x-www-form-urlencoded
url=WEBHOOK_URL, (так делает echobot/PTB и так разбирает relay после фикса 0.1.1).
secret_token=WEBHOOK_SECRET, """
max_connections=40 logger.info("starting setup_webhook")
if not WEBHOOK_SECRET:
raise RuntimeError(
"WEBHOOK_SECRET не задан — без него Telegram не будет присылать "
"X-Telegram-Bot-Api-Secret-Token"
) )
logger.info(f"Вебхук установлен через ретранслятор {get_relay_url()}: локальная доставка {WEBHOOK_URL}") if not WEBHOOK_URL:
logger.info(f"Секретный токен: {'установлен' if WEBHOOK_SECRET else 'не установлен'}") raise RuntimeError("WEBHOOK_URL не задан")
logger.info(f"Прослушивание порта: {WEBHOOK_PORT}")
logger.info(f"Путь вебхука: {WEBHOOK_PATH}")
# Логируем успешную настройку вебхука api_url = bot_api_method_url(RESPONDER_BOT_TOKEN, "setWebhook")
log_event("system", "system", message=f"Вебхук установлен на {WEBHOOK_URL}") # Все значения — строки (form-urlencoded), как ожидает parse_qs на relay.
# allowed_updates обязательно: если не указать, Telegram сохраняет прежнюю
# настройку — часто без callback_query, и тогда кнопки «Подписаться»/«Справка» молчат.
form = {
"url": WEBHOOK_URL,
"secret_token": WEBHOOK_SECRET,
"max_connections": "40",
"allowed_updates": json.dumps([
"message",
"edited_message",
"callback_query",
]),
}
logger.info(
f"setWebhook через relay: delivery_url={WEBHOOK_URL}, "
f"secret_len={len(WEBHOOK_SECRET)}, relay={get_relay_url()}"
)
last_error = None
for attempt in range(1, 6):
try:
resp = requests.post(
api_url,
data=form,
timeout=60,
)
try:
data = resp.json()
except Exception:
data = {"ok": False, "description": (resp.text or "")[:500]}
logger.info(f"setWebhook ответ HTTP {resp.status_code}: {data}")
desc = str(data.get("description", "")) if isinstance(data, dict) else ""
if resp.status_code == 429 or "retry after" in desc.lower():
raise RuntimeError(desc or f"HTTP {resp.status_code}")
if not (isinstance(data, dict) and data.get("ok")):
raise RuntimeError(f"setWebhook failed: HTTP {resp.status_code} {data}")
logger.info(
f"Вебхук установлен через ретранслятор {get_relay_url()}: "
f"локальная доставка {WEBHOOK_URL}"
)
logger.info(f"Секретный токен: установлен (len={len(WEBHOOK_SECRET)})")
logger.info(f"Прослушивание порта: {WEBHOOK_PORT}")
logger.info(f"Путь вебхука: {WEBHOOK_PATH}")
log_event("system", "system", message=f"Вебхук установлен на {WEBHOOK_URL}")
return
except Exception as e:
last_error = e
retry_after = _webhook_retry_after(e)
if retry_after is not None and attempt < 5:
logger.warning(
f"setup_webhook: попытка {attempt}/5 — {e}; ждём {retry_after} с"
)
time.sleep(retry_after)
continue
break
error_msg = f"Ошибка настройка вебхука: {last_error}"
logger.error(error_msg)
log_event("error", "system", message=error_msg)
raise RuntimeError(error_msg) from last_error
def _webhook_retry_after(exc):
"""Извлекает секунды ожидания из 429 / 'retry after N', иначе None."""
text = str(exc)
if "429" not in text and "Too Many Requests" not in text and "retry after" not in text.lower():
return None
import re
match = re.search(r"retry after (\d+)", text, re.IGNORECASE)
if match:
return max(int(match.group(1)), 1) + 1
return 2
def _read_telegram_secret_header():
"""Читает секрет из заголовка (Flask headers и сырой WSGI environ)."""
return (
request.headers.get("X-Telegram-Bot-Api-Secret-Token")
or request.environ.get("HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN")
)
except Exception as e:
error_msg = f"Ошибка настройка вебхука: {e}"
logger.error(error_msg)
log_event("error", "system", message=error_msg)
exit(1)
# Используем путь из WEBHOOK_URL # Используем путь из WEBHOOK_URL
@app.route(WEBHOOK_PATH, methods=['POST']) @app.route(WEBHOOK_PATH, methods=['POST'])
def webhook(): def webhook():
logger.info(f"starting webhook") logger.info("starting webhook")
if request.headers.get('X-Telegram-Bot-Api-Secret-Token') != WEBHOOK_SECRET: received_secret = _read_telegram_secret_header()
logger.warning("Неверный секретный токен!") if received_secret != WEBHOOK_SECRET:
related = [
k for k in request.headers.keys()
if "secret" in k.lower() or "telegram" in k.lower() or k.lower().startswith("x-")
]
if not received_secret:
logger.warning(
"Секретный токен отсутствует в заголовке X-Telegram-Bot-Api-Secret-Token; "
f"IP={request.remote_addr}; related_headers={related}"
)
else:
logger.warning(
"Секретный токен не совпадает "
f"(получен len={len(received_secret)}, ожидается len={len(WEBHOOK_SECRET or '')}); "
f"related_headers={related}"
)
log_event("security", "system", message="Попытка доступа с неверным секретным токеном") log_event("security", "system", message="Попытка доступа с неверным секретным токеном")
return "Unauthorized", 401 return "Unauthorized", 401
@@ -1472,6 +1648,7 @@ def webhook():
try: try:
update = telebot.types.Update.de_json(json_data) update = telebot.types.Update.de_json(json_data)
bot.process_new_updates([update]) bot.process_new_updates([update])
return "OK", 200 return "OK", 200
except Exception as e: except Exception as e:
error_msg = f"Ошибка обработки вебхука: {e}" error_msg = f"Ошибка обработки вебхука: {e}"
@@ -1532,7 +1709,10 @@ if __name__ == '__main__':
log_event("system", "system", message="Бот запущен") log_event("system", "system", message="Бот запущен")
# Настраиваем вебхук # Настраиваем вебхук
setup_webhook() try:
setup_webhook()
except Exception:
exit(1)
# Логируем запуск Flask # Логируем запуск Flask
logger.info(f"starting Flask") logger.info(f"starting Flask")
+120 -76
View File
@@ -16,7 +16,16 @@ from formatter import get_event_text
from telegram import Bot from telegram import Bot
from telegram.error import TelegramError, TimedOut from telegram.error import TelegramError, TimedOut
from telegram.request import HTTPXRequest from telegram.request import HTTPXRequest
from telegram_relay import get_ptb_base_urls, get_relay_url 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 для правильной работы в отдельном потоке # Настройка anyio для правильной работы в отдельном потоке
# Устанавливаем правильный backend для anyio (используем asyncio) # Устанавливаем правильный backend для anyio (используем asyncio)
@@ -145,6 +154,94 @@ async def download_image(image_url, httpx_client):
logger.error(f"Ошибка при скачивании изображения {image_url}: {e}") logger.error(f"Ошибка при скачивании изображения {image_url}: {e}")
raise 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}")
@@ -232,7 +329,9 @@ async def publish_to_tg(vk_post_id):
# Ссылка на подписку (только для событий и если включен бот подписки) # Ссылка на подписку (только для событий и если включен бот подписки)
if post['is_event'] and USE_SUBSCRIPTION_BOT: if post['is_event'] and USE_SUBSCRIPTION_BOT:
links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{placeholder_message_id}">🔜 Подписка</a>') links.append(
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", placeholder_message_id)}">🔜 Подписка</a>'
)
# Формируем строку ссылок # Формируем строку ссылок
links_line = " | ".join(links) if links else "" links_line = " | ".join(links) if links else ""
@@ -419,83 +518,28 @@ async def publish_to_tg(vk_post_id):
updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>') updated_links.append(f'<a href="{post["vk_post_url"]}">Оригинал в ВК</a>')
if post['is_event'] and USE_SUBSCRIPTION_BOT: if post['is_event'] and USE_SUBSCRIPTION_BOT:
updated_links.append(f'<a href="https://t.me/{RESPONDER_BOT_NAME}?start=post_{message_id}">🔜 Подписка</a>') updated_links.append(
f'<a href="{subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)}">🔜 Подписка</a>'
)
links_line = " | ".join(updated_links) links_line = " | ".join(updated_links)
final_text = f"{text}\n\n{links_line}" final_text = f"{text}\n\n{links_line}"
expected_urls = []
await asyncio.sleep(2) # задержка между обращениями к телеграм if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
expected_urls.append(post['vk_post_url'])
# Редактируем сообщение, добавляя ссылки if post['is_event'] and USE_SUBSCRIPTION_BOT:
# Используем message_has_image для определения типа сообщения expected_urls.append(
# Если было отправлено изображение, редактируем caption, иначе - текст subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
if message_has_image: )
# Сообщение с изображением - редактируем caption с повторными попытками if not await _edit_post_add_links(
edit_success = False bot, message_id, message_has_image, final_text, vk_post_id,
for attempt in range(1, max_retries + 1): expected_urls,
try: ):
await bot.edit_message_caption( logger.error(
chat_id=CHANNEL_ID, f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
message_id=message_id, f"но ссылки не добавлены — published_in_tg не обновляем"
caption=final_text, )
parse_mode='HTML' return False
)
edit_success = True
logger.info(f"Caption успешно отредактирован для записи VK ID {vk_post_id}")
break # Успешное редактирование, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при редактировании caption (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
await asyncio.sleep(retry_interval)
else:
logger.warning(f"Таймаут при редактировании caption после {max_retries} попыток для записи VK ID {vk_post_id}. Сообщение опубликовано, но без ссылок.")
# Не критичная ошибка - сообщение уже опубликовано
break
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.warning(f"Не удалось отредактировать caption для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
except Exception as e:
# Не критичная ошибка - сообщение уже опубликовано
logger.warning(f"Неожиданная ошибка при редактировании caption для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
else:
# Текстовое сообщение - редактируем текст с повторными попытками
edit_success = False
for attempt in range(1, max_retries + 1):
try:
await bot.edit_message_text(
chat_id=CHANNEL_ID,
message_id=message_id,
text=final_text,
parse_mode='HTML',
disable_web_page_preview=True
)
edit_success = True
logger.info(f"Текст успешно отредактирован для записи VK ID {vk_post_id}")
break # Успешное редактирование, выходим из цикла
except TimedOut as e:
if attempt < max_retries:
logger.warning(f"Таймаут при редактировании текста (попытка {attempt}/{max_retries}) для записи VK ID {vk_post_id}. Повтор через {retry_interval} секунд...")
await asyncio.sleep(retry_interval)
else:
logger.warning(f"Таймаут при редактировании текста после {max_retries} попыток для записи VK ID {vk_post_id}. Сообщение опубликовано, но без ссылок.")
# Не критичная ошибка - сообщение уже опубликовано
break
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.warning(f"Не удалось отредактировать текст для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
except Exception as e:
# Не критичная ошибка - сообщение уже опубликовано
logger.warning(f"Неожиданная ошибка при редактировании текста для записи VK ID {vk_post_id}: {type(e).__name__}: {e}. Сообщение опубликовано, но без ссылок.")
break
await asyncio.sleep(2) # задержка между обращениями к телеграм await asyncio.sleep(2) # задержка между обращениями к телеграм
+41
View File
@@ -0,0 +1,41 @@
# Вспомогательные скрипты
Скрипты вне основного пайплайна (`runner.sh`) и systemd-сервисов. Запускать из **корня проекта** с активированным venv.
## ops/ — эксплуатация
| Файл | Назначение |
|------|------------|
| `genkey.py` | Генерация hex-ключа для `SECRET_KEY` / `WEBHOOK_SECRET` |
| `register_tg_webhook.py` | Ручная регистрация webhook (в норме — `wsgi_bot.py` при старте) |
| `copy_databases.sh` | Клонирование MariaDB между базами |
```bash
python tools/ops/genkey.py
./venv/bin/python tools/ops/register_tg_webhook.py
bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
```
## dev/ — отладка и примеры
| Файл | Назначение |
|------|------------|
| `send_telegram_example.py` | CLI-пример API `/send_message` |
| `volk_telegram_testing.py` | Отладочный тест публикации в Telegram |
| `volk_category_testing.py` | Отладочный тест загрузки категории VOLK |
```bash
python tools/dev/send_telegram_example.py <user_id> "<message>"
python tools/dev/volk_telegram_testing.py
python tools/dev/volk_category_testing.py <category_id>
```
## legacy/ — снятые с пайплайна
| Файл | Назначение |
|------|------------|
| `evt_prefetch.py` | Массовая пометка видимых событий `marked_to_publication=1`. Убран из `runner.sh` в 1.0.0 |
```bash
./venv/bin/python tools/legacy/evt_prefetch.py
```
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Пример скрипта для отправки сообщений через Telegram бота Пример скрипта для отправки сообщений через Telegram бота.
Использование: python send_telegram_example.py <user_id> <message>
Использование (из корня проекта):
python tools/dev/send_telegram_example.py <user_id> <message>
""" """
import sys import sys
@@ -9,6 +11,12 @@ import requests
import json import json
import os import os
from dotenv import load_dotenv
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
def send_message(user_id, message_text, bot_url=None, api_token=None): def send_message(user_id, message_text, bot_url=None, api_token=None):
""" """
Отправляет сообщение пользователю через Telegram бота Отправляет сообщение пользователю через Telegram бота
@@ -64,8 +72,8 @@ def send_message(user_id, message_text, bot_url=None, api_token=None):
def main(): def main():
if len(sys.argv) != 3: if len(sys.argv) != 3:
print("Использование: python send_telegram_example.py <user_id> <message>") print("Использование: python tools/dev/send_telegram_example.py <user_id> <message>")
print("Пример: python send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'") print("Пример: python tools/dev/send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
sys.exit(1) sys.exit(1)
try: try:
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Тестовый модуль для проверки загрузки описания категории. Тестовый модуль для проверки загрузки описания категории.
Использование: python volk_category_testing.py <category_id>
Использование (из корня проекта):
python tools/dev/volk_category_testing.py <category_id>
""" """
import os import os
import sys import sys
@@ -15,7 +17,8 @@ from urllib.request import urlopen, Request
from urllib.error import URLError, HTTPError from urllib.error import URLError, HTTPError
from dotenv import load_dotenv from dotenv import load_dotenv
# Загрузка переменных окружения _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv() load_dotenv()
# Параметры из .env # Параметры из .env
@@ -260,8 +263,8 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=""" epilog="""
Примеры использования: Примеры использования:
python volk_category_testing.py volk26_nri python tools/dev/volk_category_testing.py volk26_nri
python volk_category_testing.py volk26_nri volk26_other volk26_third python tools/dev/volk_category_testing.py volk26_nri volk26_other volk26_third
""" """
) )
parser.add_argument( parser.add_argument(
@@ -7,11 +7,17 @@ import sys
import json import json
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv from dotenv import load_dotenv
from telegram_relay import bot_api_method_url, get_relay_url
# Загрузка переменных окружения _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv() load_dotenv()
from telegram_relay import bot_api_method_url, get_relay_url
from season_links import subscription_start_link
# Настройки из переменных окружения # Настройки из переменных окружения
POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN') POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME') RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
@@ -94,7 +100,11 @@ async def test_telegram_publish():
# Формируем строку ссылок # Формируем строку ссылок
placeholder_message_id = '1234567890' placeholder_message_id = '1234567890'
if USE_SUBSCRIPTION_BOT: if USE_SUBSCRIPTION_BOT:
links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a> | 📌 <a href="{PZK_PREFIX}{test_number}/">Иду</a> | 🔜 <a href="https://t.me/{RESPONDER_BOT_NAME}?start=event_{placeholder_message_id}">Подписка</a>' links_line = (
f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a> | '
f'📌 <a href="{PZK_PREFIX}{test_number}/">Иду</a> | '
f'🔜 <a href="{subscription_start_link(RESPONDER_BOT_NAME, "event", placeholder_message_id)}">Подписка</a>'
)
else: else:
links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a>' links_line = f'🔎 <a href="{DESC_PREFIX}{test_number}/">Инфо</a>'
@@ -1,10 +1,19 @@
#!/usr/bin/env python3
"""LEGACY — не входит в пайплайн runner.sh с версии 1.0.0.
Проставляет marked_to_publication = 1 видимым событиям (is_visible = 1),
у которых флаг ещё не установлен.
Запуск вручную из корня проекта:
./venv/bin/python tools/legacy/evt_prefetch.py
"""
import os import os
import mysql.connector import mysql.connector
from mysql.connector import Error from mysql.connector import Error
from datetime import datetime, timezone
from dotenv import load_dotenv from dotenv import load_dotenv
# Загрузка переменных окружения _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv() load_dotenv()
MDB_HOST = os.getenv('MDB_HOST') MDB_HOST = os.getenv('MDB_HOST')
@@ -12,6 +21,7 @@ MDB_USER = os.getenv('MDB_USER')
MDB_PW = os.getenv('MDB_PW') MDB_PW = os.getenv('MDB_PW')
MDBASE = os.getenv('MDBASE') MDBASE = os.getenv('MDBASE')
def prefetch_all_events(): def prefetch_all_events():
""" """
Проверяет записи в таблице events и устанавливает флаг marked_to_publication Проверяет записи в таблице events и устанавливает флаг marked_to_publication
@@ -20,7 +30,6 @@ def prefetch_all_events():
- не имеют флага marked_to_publication = 1 - не имеют флага marked_to_publication = 1
""" """
try: try:
# Подключение к базе данных
connection = mysql.connector.connect( connection = mysql.connector.connect(
host=MDB_HOST, host=MDB_HOST,
user=MDB_USER, user=MDB_USER,
@@ -28,14 +37,12 @@ def prefetch_all_events():
database=MDBASE database=MDBASE
) )
# Установка временной зоны соединения в UTC
cursor_temp = connection.cursor() cursor_temp = connection.cursor()
cursor_temp.execute("SET time_zone = '+00:00'") cursor_temp.execute("SET time_zone = '+00:00'")
cursor_temp.close() cursor_temp.close()
cursor = connection.cursor() cursor = connection.cursor()
# SQL запрос для обновления записей
update_query = """ update_query = """
UPDATE events UPDATE events
SET marked_to_publication = 1 SET marked_to_publication = 1
@@ -43,15 +50,11 @@ def prefetch_all_events():
AND marked_to_publication = 0 AND marked_to_publication = 0
""" """
# Выполнение запроса
cursor.execute(update_query) cursor.execute(update_query)
updated_count = cursor.rowcount updated_count = cursor.rowcount
# Фиксация изменений
connection.commit() connection.commit()
print(f"Обновлено записей: {updated_count}") print(f"Обновлено записей: {updated_count}")
return updated_count return updated_count
except Error as e: except Error as e:
@@ -67,5 +70,6 @@ def prefetch_all_events():
cursor.close() cursor.close()
connection.close() connection.close()
if __name__ == "__main__": if __name__ == "__main__":
prefetch_all_events() prefetch_all_events()
@@ -1,7 +1,9 @@
#!/bin/bash #!/bin/bash
# Использование: ./copy_db.sh <source_db> <target_db> <username> # Использование (из корня проекта):
# Пример: ./copy_db.sh old_db new_db appuser # bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
# Пример:
# bash tools/ops/copy_databases.sh old_db new_db appuser
set -e # Завершать выполнение при любой ошибке set -e # Завершать выполнение при любой ошибке
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""Генерация случайного hex-ключа для SECRET_KEY или WEBHOOK_SECRET.
Запуск из корня проекта:
python tools/ops/genkey.py
"""
import secrets
if __name__ == "__main__":
print(secrets.token_hex(32))
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Ручная регистрация Telegram webhook через internal_relay (с secret_token).
Запуск из корня проекта:
./venv/bin/python tools/ops/register_tg_webhook.py
Печатает полный ответ setWebhook — по нему видно, принял ли Telegram секрет.
"""
import json
import sys
import os
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
from dotenv import load_dotenv
import requests
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
load_dotenv()
from telegram_relay import bot_api_method_url, get_relay_url
TOKEN = os.getenv("RESPONDER_BOT_TOKEN")
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
WEBHOOK_SECRET = (os.getenv("WEBHOOK_SECRET") or "").strip().strip('"').strip("'")
def main() -> int:
if not TOKEN:
print("ERROR: RESPONDER_BOT_TOKEN не задан")
return 1
if not WEBHOOK_URL:
print("ERROR: WEBHOOK_URL не задан")
return 1
if not WEBHOOK_SECRET:
print("ERROR: WEBHOOK_SECRET не задан")
return 1
api_url = bot_api_method_url(TOKEN, "setWebhook")
form = {
"url": WEBHOOK_URL,
"secret_token": WEBHOOK_SECRET,
"max_connections": "40",
# Иначе Telegram может сохранить старый список без callback_query
"allowed_updates": json.dumps([
"message",
"edited_message",
"callback_query",
]),
}
print(f"RELAY_URL = {get_relay_url()}")
print(f"WEBHOOK_URL = {WEBHOOK_URL}")
print(f"secret_len = {len(WEBHOOK_SECRET)}")
print(f"POST = {api_url.replace(TOKEN, '<TOKEN>')}")
print(f"form keys = {list(form.keys())}")
resp = requests.post(api_url, data=form, timeout=60)
print(f"HTTP status = {resp.status_code}")
try:
data = resp.json()
print("JSON response:")
print(json.dumps(data, ensure_ascii=False, indent=2))
except Exception:
print("Raw response:")
print(resp.text[:1000])
return 1
if not data.get("ok"):
print("FAILED: Telegram/relay вернул ok=false")
return 1
print("OK: setWebhook принят. Нажмите «Подписка» / отправьте /start боту.")
print("В логе бота не должно быть «Секретный токен отсутствует».")
return 0
if __name__ == "__main__":
sys.exit(main())
+43 -9
View File
@@ -19,6 +19,14 @@ VK_API_VERSION = os.getenv('VK_API_VERSION', '5.131')
IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on') IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on')
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on') AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации
# Минимум букв (isalpha), чтобы пост не считался пустым (видео без описания и т.п.)
try:
VK_POST_MIN_LETTERS = int((os.getenv('VK_POST_MIN_LETTERS') or '5').strip().strip('"').strip("'"))
except ValueError:
VK_POST_MIN_LETTERS = 5
if VK_POST_MIN_LETTERS < 0:
VK_POST_MIN_LETTERS = 0
EMPTY_POST_SHORTNAME = "- = Пустой пост = -"
LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
@@ -86,6 +94,12 @@ def get_vk_posts():
log_message(error_msg) log_message(error_msg)
raise Exception(error_msg) raise Exception(error_msg)
def count_letters(text):
"""Количество букв в тексте (Unicode isalpha), без цифр и знаков."""
return sum(1 for c in (text or "") if c.isalpha())
def process_post(post): def process_post(post):
"""Извлекает необходимые данные из поста VK, включая обработку репостов""" """Извлекает необходимые данные из поста VK, включая обработку репостов"""
post_id = post['id'] post_id = post['id']
@@ -144,11 +158,15 @@ def process_post(post):
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале # Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
post_text = source_post.get('text', '') post_text = source_post.get('text', '')
own_text = post.get('text', '')
letter_count = count_letters(post_text)
if is_repost: if is_repost:
letter_count += count_letters(own_text)
# Если у репоста есть свой текст, добавляем его перед текстом оригинала # Если у репоста есть свой текст, добавляем его перед текстом оригинала
if post.get('text', '').strip(): if own_text.strip():
post_text = f"{post['text']}\n\n---\n\n{post_text}" post_text = f"{own_text}\n\n---\n\n{post_text}"
post_text = repost_prefix + post_text + repost_info post_text = repost_prefix + post_text + repost_info
is_empty_text = letter_count < VK_POST_MIN_LETTERS
return { return {
'vk_post_id': post_id, 'vk_post_id': post_id,
@@ -161,7 +179,9 @@ def process_post(post):
'poll_options': poll_options, 'poll_options': poll_options,
'poll_multiple': poll_multiple, 'poll_multiple': poll_multiple,
'poll_end_date': poll_end_date, 'poll_end_date': poll_end_date,
'is_repost': is_repost 'is_repost': is_repost,
'is_empty_text': is_empty_text,
'letter_count': letter_count,
} }
def save_to_database(posts): def save_to_database(posts):
@@ -175,9 +195,14 @@ def save_to_database(posts):
try: try:
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста # Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
post_text = post.get('text', '') post_text = post.get('text', '')
is_empty_text = post.get('is_empty_text', False)
should_auto_publish = AUTO_PUBLISH should_auto_publish = AUTO_PUBLISH
shortname = None
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text: if is_empty_text:
should_auto_publish = False
shortname = EMPTY_POST_SHORTNAME
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
# Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH # Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH
should_auto_publish = False should_auto_publish = False
@@ -201,16 +226,21 @@ def save_to_database(posts):
int(post['poll_multiple']), int(post['poll_multiple']),
post['poll_end_date'], post['poll_end_date'],
IS_EVENT, IS_EVENT,
int(should_auto_publish), # Значение с учетом проверки тега int(should_auto_publish),
None, # shortname - пока не используется, устанавливаем NULL shortname,
None # action_number - пока не используется, устанавливаем NULL None # action_number
)) ))
if cursor.rowcount > 0: if cursor.rowcount > 0:
new_posts_count += 1 new_posts_count += 1
# Логируем добавление нового поста с указанием причины отключения автопубликации # Логируем добавление нового поста с указанием причины отключения автопубликации
auto_publish_reason = 'Да' auto_publish_reason = 'Да'
if not should_auto_publish: if not should_auto_publish:
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text: if is_empty_text:
auto_publish_reason = (
f'Нет (пустой текст: {post.get("letter_count", 0)} букв '
f'< {VK_POST_MIN_LETTERS})'
)
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')' auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')'
else: else:
auto_publish_reason = 'Нет' auto_publish_reason = 'Нет'
@@ -246,7 +276,11 @@ def vk_load_10():
Возвращает количество добавленных постов Возвращает количество добавленных постов
""" """
# Стартовая информация # Стартовая информация
start_msg = f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH}" start_msg = (
f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | "
f"IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH} | "
f"VK_POST_MIN_LETTERS: {VK_POST_MIN_LETTERS}"
)
print(start_msg) print(start_msg)
log_message(start_msg) log_message(start_msg)
+29 -1
View File
@@ -1,4 +1,32 @@
from tg_mainbot import app # Импортируйте ваше приложение import fcntl
import hashlib
import os
from tg_mainbot import WEBHOOK_SECRET, WEBHOOK_URL, app, setup_webhook
def _ensure_webhook():
"""
Один раз на старте gunicorn регистрируем webhook с secret_token.
Блокирующий flock — без гонки 4 воркеров и без 429.
"""
digest = hashlib.sha256(
f"{WEBHOOK_URL}|{WEBHOOK_SECRET or ''}|allowed_v2".encode("utf-8")
).hexdigest()[:16]
marker_path = f"/tmp/zilant_webhook_ok_{digest}"
lock_path = "/tmp/zilant_webhook_setup.lock"
with open(lock_path, "w") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
if os.path.exists(marker_path):
return
setup_webhook()
with open(marker_path, "w") as marker:
marker.write("ok\n")
_ensure_webhook()
if __name__ == "__main__": if __name__ == "__main__":
setup_webhook()
app.run() app.run()