Compare commits
14
Commits
1b080990fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87ccd23e96 | ||
|
|
f74953e082 | ||
|
|
709178d9ea | ||
|
|
ac17043a08 | ||
|
|
62a77dba78 | ||
|
|
ea6c5e5a7d | ||
|
|
80e0847329 | ||
|
|
c7ba0479ba | ||
|
|
d7e105d986 | ||
|
|
352c0da209 | ||
|
|
4500cf5bf4 | ||
|
|
a1aadb6393 | ||
|
|
251fa0be11 | ||
|
|
adc49c285c |
@@ -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**.
|
||||
@@ -56,17 +56,17 @@ VK_ACCESS_TOKEN = 'e3732a7ee3732a7ee3732a7ea7e044a75fee373e3732a7e8bf4e48a52d012
|
||||
VK_API_VERSION = '5.131'
|
||||
|
||||
# MySQL database
|
||||
# MDBASE = 'testposts'
|
||||
MDBASE = 'testposts'
|
||||
# MDBASE = 'tempzilant' # debug Zilant database
|
||||
# Zilant 2026 database
|
||||
MDBASE = 'zilant_2026'
|
||||
# MDBASE = 'zilant_2026'
|
||||
# MDBASE = 'volk_test' # debug VOLK database
|
||||
# MDBASE = 'volkdata' # VOLK production database
|
||||
MDB_HOST = 'localhost'
|
||||
MDB_USER = 'zilantposter'
|
||||
MDB_PW = 'Z1l@ntp08t3r'
|
||||
# MDB_USER = 'testuser'
|
||||
# MDB_PW = 'T38tU83r69'
|
||||
# MDB_USER = 'zilantposter'
|
||||
# MDB_PW = 'Z1l@ntp08t3r'
|
||||
MDB_USER = 'testuser'
|
||||
MDB_PW = 'T38tU83r69'
|
||||
# MDB_USER = 'volkuser'
|
||||
# MDB_PW = 'V0lK@dM1n23'
|
||||
|
||||
@@ -104,6 +104,8 @@ RESPONDER_BOT_NAME = "posto_1638_bot"
|
||||
ORG_MESSAGE_TAG = "#оргинфаЗиланткона"
|
||||
# tag to detect posts to not repost from VK
|
||||
VK_NO_REPOST_TAG = "#окоЗиланта"
|
||||
# Минимум букв в тексте VK-поста (без учёта цифр/знаков). Меньше — «пустой пост», не в публикацию
|
||||
VK_POST_MIN_LETTERS = 5
|
||||
|
||||
|
||||
# Minutes delay before publishing an event
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# =============================================================================
|
||||
# Шаблон окружения для инстанса проекта.
|
||||
# Скопируйте в .env и заполните значения:
|
||||
# cp .env.example .env
|
||||
# Генерация SECRET_KEY / WEBHOOK_SECRET:
|
||||
# python tools/ops/genkey.py
|
||||
# =============================================================================
|
||||
|
||||
# --- Общие настройки ---------------------------------------------------------
|
||||
SECRET_KEY=CHANGE_ME_hex_64_chars
|
||||
LOG_FILE=./logs/app.log
|
||||
|
||||
# Режим работы: ZILANT | VOLK
|
||||
WORKMODE=ZILANT
|
||||
|
||||
# Код сезона для deep link / callback подписок (zk2026_post_…, subscribe_zk2026_…)
|
||||
SEASON=zk2026
|
||||
|
||||
# --- Telegram через internal_relay (retransbot) — обязательно ----------------
|
||||
# Схема http:// или https:// обязательна (иначе подставится http://)
|
||||
# Примеры: http://127.0.0.1:5100 | https://relay.example.com
|
||||
RELAY_URL=http://127.0.0.1:5100
|
||||
|
||||
# --- Webhook responder-бота --------------------------------------------------
|
||||
# WEBHOOK_URL — адрес локальной доставки апдейтов от relay к gunicorn-боту
|
||||
# (доступен с машины relay). Это не публичный URL для Telegram.
|
||||
# ZILANT (пример): http://bot.example.com:5005/testbot
|
||||
# VOLK (пример): http://bot.example.com:5010/volk_bot
|
||||
WEBHOOK_URL=http://192.168.0.1:5005/testbot
|
||||
WEBHOOK_PORT=5005
|
||||
WEBHOOK_SECRET=CHANGE_ME_webhook_secret
|
||||
|
||||
# Опционально: webhook poster-бота (если используется)
|
||||
# POSTHOOK_URL=https://bot.example.com/poster
|
||||
# POSTHOOK_PORT=5008
|
||||
# POSTHOOK_SECRET=CHANGE_ME
|
||||
|
||||
# --- Веб-контроллер (db_edit) ------------------------------------------------
|
||||
# ZILANT (пример):
|
||||
WEBCTRL_URL=https://bot.example.com/testedit
|
||||
WEBCTRL_PORT=5006
|
||||
WEBCTRL_PREFIX=/testedit
|
||||
# VOLK (пример, раскомментировать при WORKMODE=VOLK):
|
||||
# WEBCTRL_URL=https://bot.example.com/volk
|
||||
# WEBCTRL_PORT=5009
|
||||
# WEBCTRL_PREFIX=/volk
|
||||
|
||||
# Логин/пароль первого администратора веб-UI (создаётся ensure_db при пустой таблице users)
|
||||
DEFNM=admin
|
||||
DEFPW=CHANGE_ME_admin_password
|
||||
|
||||
# --- VK API ------------------------------------------------------------------
|
||||
# ZILANT group (пример): -3342146
|
||||
# VOLK group (пример): -58475527
|
||||
VK_GROUP_ID=-3342146
|
||||
VK_ACCESS_TOKEN=CHANGE_ME_vk_token
|
||||
VK_API_VERSION=5.131
|
||||
|
||||
# --- MariaDB -----------------------------------------------------------------
|
||||
# ZILANT: zilant_2026 | tempzilant | testposts
|
||||
# VOLK: volkdata | volk_test
|
||||
MDBASE=CHANGE_ME_database_name
|
||||
MDB_HOST=localhost
|
||||
MDB_USER=CHANGE_ME_db_user_name
|
||||
MDB_PW=CHANGE_ME_db_password
|
||||
|
||||
# --- Telegram-боты -----------------------------------------------------------
|
||||
# Poster — публикация в канал
|
||||
POSTER_BOT_TOKEN=CHANGE_ME_poster_bot_token
|
||||
# Responder — подписки / webhook
|
||||
RESPONDER_BOT_TOKEN=CHANGE_ME_responder_bot_token
|
||||
RESPONDER_BOT_NAME=your_responder_bot_username
|
||||
|
||||
# --- Канал -------------------------------------------------------------------
|
||||
# ZILANT debug: @posto1638 | production: @zilantkon
|
||||
# VOLK production: @VOLKinformer
|
||||
CHANNEL_ID=@your_channel
|
||||
|
||||
MAX_CAPTION_LENGTH=1000
|
||||
MAX_TEXT_LENGTH=4000
|
||||
|
||||
# --- Теги VK (ZILANT) --------------------------------------------------------
|
||||
# Org messages go without picture
|
||||
# to fit the long text into TG message limit
|
||||
ORG_MESSAGE_TAG=#оргинфаЗиланткона
|
||||
# VK posts with no repost tag are skipped by poster
|
||||
VK_NO_REPOST_TAG=#окоЗиланта
|
||||
# Минимум букв (isalpha) в тексте VK-поста. Меньше — в БД как «- = Пустой пост = -», не для публикации
|
||||
VK_POST_MIN_LETTERS=5
|
||||
|
||||
# --- Публикация событий ------------------------------------------------------
|
||||
# Задержка перед публикацией события (минуты). В БД время в UTC.
|
||||
# Ближе к событию: 60; далеко: 1440
|
||||
EVENT_POST_DELAY=1440
|
||||
|
||||
# --- Ссылки ZILANT (План Захвата / карточки событий) --------------------------
|
||||
# В конце обычно trailing slash; к URL добавляется номер события
|
||||
PZK_PREFIX=https://reg.konvent.ru/zk2026/draft/plan/api/add/
|
||||
EVT_PREFIX=https://reg.konvent.ru/zk2026/events/actions/api/
|
||||
DESC_PREFIX=https://reg.konvent.ru/zk2026/events/actions/
|
||||
|
||||
# --- API VOLK (нужны при WORKMODE=VOLK) --------------------------------------
|
||||
# Раскомментируйте и заполните для режима VOLK:
|
||||
# VOLK_API_VERSION=1.0
|
||||
# VOLK_CATEGORY_LIST=https://base.volk-nn.ru/api/categories?event=volk26&form=volk26_event
|
||||
# VOLK_CATEGORY_DESC_PREFIX=https://base.volk-nn.ru/api/category?event=volk26&form=volk26_event&category=
|
||||
# VOLK_EVENT_LIST_PREFIX=https://base.volk-nn.ru/api/applications?event=volk26&form=volk26_event&apps_on_page=200&page=0&category=
|
||||
|
||||
# --- Флаги поведения ---------------------------------------------------------
|
||||
# IS_EVENT=true - каждый пост в ВК считать пригодным для репоста в канал
|
||||
# AUTO_PUBLISH=true - автоматическая публикация потсов из ВК в канал
|
||||
# AUTO_PUB_EVT=true - автоматическая публикация событий в канал
|
||||
# PROCESS_EVENT_UPDATES=false - не репостить уже опубликованные события при обновлениях
|
||||
# PUBLISH_SILENTLY=true - публиковать в канал в режиме "без звука"
|
||||
# USE_SUBSCRIPTION_BOT=true - использовать бота подписок
|
||||
# LOG_DEBUG_DATA=false - не вести расширенного журналирования отладочных данных
|
||||
IS_EVENT=true
|
||||
AUTO_PUBLISH=true
|
||||
AUTO_PUB_EVT=true
|
||||
PROCESS_EVENT_UPDATES=false
|
||||
PUBLISH_SILENTLY=true
|
||||
USE_SUBSCRIPTION_BOT=true
|
||||
LOG_DEBUG_DATA=false
|
||||
|
||||
# --- RouterAI (генерация shortname через «Назвать») ---------------------------
|
||||
RA_KEY=CHANGE_ME_routerai_key
|
||||
RA_MODEL=moonshotai/kimi-k2-thinking
|
||||
|
||||
+8
-1
@@ -1,4 +1,11 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
__pycache__/season_links.cpython-314.pyc
|
||||
|
||||
# Секреты инстанса — только шаблон .env.example в репозитории
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Логи инстанса
|
||||
logs/
|
||||
|
||||
@@ -1,4 +1,68 @@
|
||||
# Zilant2025
|
||||
# Zilant / VOLK — Telegram poster и подписки
|
||||
|
||||
Zilantkon bot testarea
|
||||
test update #8
|
||||
**Версия:** 1.1.4 ([`VERSION`](VERSION))
|
||||
|
||||
Автоматическая публикация постов и событий в Telegram-канал, веб-редактор, бот подписок с сезонными deep link.
|
||||
|
||||
## Режимы
|
||||
|
||||
| `WORKMODE` | Описание |
|
||||
|------------|----------|
|
||||
| `ZILANT` | VK + Zilant JSON → канал (основной пайплайн) |
|
||||
| `VOLK` | VOLK API → категории и события → канал |
|
||||
|
||||
Подробнее: [`docs/volk-mode.md`](docs/volk-mode.md).
|
||||
|
||||
## Быстрый старт (сервер)
|
||||
|
||||
```bash
|
||||
# Deploy через Woodpecker → rsync + post-deploy.sh
|
||||
# Сервисы:
|
||||
sudo systemctl status testbot-git.service # бот :5005
|
||||
sudo systemctl status testedit-git.service # UI :5006
|
||||
sudo systemctl status runner.timer # hourly pipeline
|
||||
```
|
||||
|
||||
Конфигурация — скопируйте [`.env.example`](.env.example) в `.env` и заполните значения
|
||||
(файл `.env` в git не коммитится). Ключевые переменные: `WORKMODE`, `RELAY_URL`, `WEBHOOK_URL`, `SEASON`, `MDB_*`.
|
||||
Генерация секретов: `python tools/ops/genkey.py`.
|
||||
|
||||
## Пайплайн (ZILANT)
|
||||
|
||||
`runner.sh` по расписанию:
|
||||
|
||||
`vk_load` → `zk_load` → `db_update_shortname` → `tg_publish` → `evtg_publish`
|
||||
|
||||
Настройка timer: [`docs/runner-setup.md`](docs/runner-setup.md).
|
||||
|
||||
## Документация
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|--------|
|
||||
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Компоненты, карта файлов, deploy |
|
||||
| [`docs/volk-mode.md`](docs/volk-mode.md) | Режим VOLK |
|
||||
| [`docs/telegram-messaging.md`](docs/telegram-messaging.md) | API отправки сообщений пользователям |
|
||||
| [`docs/runner-setup.md`](docs/runner-setup.md) | Systemd timer / cron для runner |
|
||||
| [`docs/CHANGELOG.md`](docs/CHANGELOG.md) | История версий |
|
||||
| [`tools/README.md`](tools/README.md) | Вспомогательные и legacy-скрипты |
|
||||
|
||||
## Версионирование
|
||||
|
||||
SemVer в [`VERSION`](VERSION). При изменениях обновлять `VERSION` и [`docs/CHANGELOG.md`](docs/CHANGELOG.md).
|
||||
|
||||
## Структура каталогов
|
||||
|
||||
```
|
||||
├── tg_mainbot.py, wsgi_bot.py # responder-бот
|
||||
├── db_edit.py, wsgi_edit.py # веб-UI
|
||||
├── tg_publish.py, evtg_publish.py # публикация (ZILANT)
|
||||
├── volk_load.py, volk_cat_publish.py # VOLK
|
||||
├── runner.sh, backupdb.sh
|
||||
├── services/ # systemd units, post-deploy
|
||||
├── templates/ # HTML UI
|
||||
├── docs/ # документация
|
||||
└── tools/
|
||||
├── ops/ # genkey, register_tg_webhook, copy_databases
|
||||
├── dev/ # send_telegram_example, volk_*_testing
|
||||
└── legacy/ # evt_prefetch
|
||||
```
|
||||
|
||||
+2
-204
@@ -1,205 +1,3 @@
|
||||
# Отправка сообщений через Telegram бота
|
||||
# Перенесено
|
||||
|
||||
## Описание
|
||||
|
||||
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
|
||||
|
||||
1. **API endpoint** для отправки сообщений из других скриптов
|
||||
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
|
||||
3. **Обработку ошибок** и логирование всех операций
|
||||
|
||||
## Архитектура системы
|
||||
|
||||
Система состоит из двух основных компонентов:
|
||||
|
||||
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
|
||||
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
|
||||
|
||||
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
|
||||
|
||||
### Конфигурация портов
|
||||
|
||||
Порты и URL настраиваются в файле `.env`:
|
||||
- `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 запросов
|
||||
- Проверка корректности входных данных
|
||||
- Логирование всех попыток отправки для аудита
|
||||
- Обработка ошибок без раскрытия внутренней информации системы
|
||||
Документация: [`docs/telegram-messaging.md`](docs/telegram-messaging.md)
|
||||
|
||||
Binary file not shown.
+109
-2
@@ -30,6 +30,79 @@ ROUTERAI_CONFIG = {
|
||||
# Настройки журналирования
|
||||
LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу
|
||||
LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта
|
||||
LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе
|
||||
|
||||
|
||||
def _truncate_for_log(value, max_len=None):
|
||||
"""Обрезает длинные значения для журнала, не теряя информацию о размере."""
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||
limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return f"{value[:limit]}... [обрезано, всего {len(value)} символов]"
|
||||
|
||||
|
||||
def _message_fields_for_log(message):
|
||||
"""Все значимые поля message из ответа chat/completions."""
|
||||
if not message:
|
||||
return {}
|
||||
logged = {}
|
||||
for key in (
|
||||
'role', 'content', 'reasoning_content', 'reasoning',
|
||||
'refusal', 'tool_calls', 'function_call', 'audio',
|
||||
):
|
||||
if key not in message:
|
||||
continue
|
||||
value = message.get(key)
|
||||
if value in (None, '', [], {}):
|
||||
logged[key] = value
|
||||
elif isinstance(value, str):
|
||||
logged[key] = _truncate_for_log(value)
|
||||
else:
|
||||
logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str))
|
||||
return logged
|
||||
|
||||
|
||||
def _log_routerai_request(text, prompt):
|
||||
"""Журнал параметров исходящего запроса (без API-ключа)."""
|
||||
log_message(
|
||||
"Запрос RouterAI: "
|
||||
f"model={ROUTERAI_CONFIG['model']}, "
|
||||
f"text_len={len(text)}, prompt_len={len(prompt)}, "
|
||||
f"text_preview={_truncate_for_log(text.strip(), 300)}"
|
||||
)
|
||||
|
||||
|
||||
def _log_routerai_response(response, result=None, error_body=None):
|
||||
"""Подробный журнал HTTP-ответа RouterAI."""
|
||||
payload = {
|
||||
"http_status": getattr(response, 'status_code', None),
|
||||
"model": ROUTERAI_CONFIG.get('model'),
|
||||
}
|
||||
if result is not None:
|
||||
payload["response_id"] = result.get("id")
|
||||
payload["object"] = result.get("object")
|
||||
payload["created"] = result.get("created")
|
||||
payload["usage"] = result.get("usage")
|
||||
payload["system_fingerprint"] = result.get("system_fingerprint")
|
||||
choices = result.get("choices") or []
|
||||
payload["choices_count"] = len(choices)
|
||||
payload["choices"] = []
|
||||
for i, choice in enumerate(choices):
|
||||
payload["choices"].append({
|
||||
"index": choice.get("index", i),
|
||||
"finish_reason": choice.get("finish_reason"),
|
||||
"message": _message_fields_for_log(choice.get("message") or {}),
|
||||
"logprobs": choice.get("logprobs"),
|
||||
})
|
||||
if result.get("error"):
|
||||
payload["api_error"] = result.get("error")
|
||||
if error_body is not None:
|
||||
payload["error_body"] = _truncate_for_log(error_body)
|
||||
log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}")
|
||||
|
||||
def log_message(message, max_retries=5, retry_delay=0.1):
|
||||
"""
|
||||
@@ -90,6 +163,8 @@ def _generate_shortname_by_api(text):
|
||||
"temperature": 0.3,
|
||||
}
|
||||
|
||||
_log_routerai_request(text, prompt)
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data, timeout=180)
|
||||
|
||||
@@ -107,20 +182,36 @@ def _generate_shortname_by_api(text):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_log_routerai_response(response, error_body=error_info.get("body"))
|
||||
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
||||
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
||||
# Не sys.exit — эта функция вызывается и из веб-API Flask
|
||||
raise RuntimeError("RouterAI 429: превышен лимит запросов")
|
||||
|
||||
if not response.ok:
|
||||
_log_routerai_response(response, error_body=response.text)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
_log_routerai_response(response, result=result)
|
||||
|
||||
# Извлекаем только текст ответа (у thinking-моделей ответ в content,
|
||||
# рассуждения — в reasoning_content / reasoning)
|
||||
message = result['choices'][0]['message']
|
||||
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:
|
||||
log_message(f"Пустой content в ответе RouterAI: {json.dumps(result, ensure_ascii=False)[:1000]}")
|
||||
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
|
||||
|
||||
# Удаляем возможные кавычки и лишние символы
|
||||
@@ -149,14 +240,30 @@ def _generate_shortname_by_api(text):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_log_routerai_response(e.response, error_body=error_info.get("body"))
|
||||
log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}")
|
||||
print("Ошибка 429: Превышен лимит запросов. Подробности в логе.")
|
||||
raise RuntimeError("RouterAI 429: превышен лимит запросов")
|
||||
else:
|
||||
if e.response is not None:
|
||||
try:
|
||||
_log_routerai_response(e.response, error_body=e.response.text)
|
||||
except Exception:
|
||||
pass
|
||||
error_msg = f"HTTP ошибка при генерации названия: {e}"
|
||||
log_message(error_msg)
|
||||
print(error_msg)
|
||||
return None
|
||||
except requests.exceptions.Timeout as e:
|
||||
error_msg = f"Таймаут запроса RouterAI ({e})"
|
||||
log_message(error_msg)
|
||||
print(error_msg)
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"Сетевая ошибка RouterAI: {e}"
|
||||
log_message(error_msg)
|
||||
print(error_msg)
|
||||
return None
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Архитектура проекта
|
||||
|
||||
Версия: см. [`VERSION`](../VERSION). История изменений: [`docs/CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Назначение
|
||||
|
||||
Система загружает посты и события из внешних источников, хранит их в MariaDB, публикует анонсы в Telegram-канал и даёт веб-интерфейс для редактирования и управления подписками через responder-бота.
|
||||
|
||||
Поддерживаются два режима работы (`WORKMODE` в `.env`):
|
||||
|
||||
| Режим | Источники данных | Публикация |
|
||||
|-------|------------------|------------|
|
||||
| **ZILANT** | VK (`vk_load.py`), JSON Zilant (`zk_load.py`) | `tg_publish.py`, `evtg_publish.py` |
|
||||
| **VOLK** | VOLK API (`volk_load.py`) | `volk_cat_publish.py` |
|
||||
|
||||
Подробнее о VOLK: [`docs/volk-mode.md`](volk-mode.md).
|
||||
|
||||
## Компоненты runtime
|
||||
|
||||
```
|
||||
┌─────────────────┐ rsync ┌──────────────────┐
|
||||
│ Woodpecker CI │ ─────────────► │ /opt/testbot-git │
|
||||
└─────────────────┘ └────────┬─────────┘
|
||||
│
|
||||
┌──────────────────────────────────┼──────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
runner.timer testbot-git.service testedit-git.service
|
||||
→ runner.sh gunicorn :5005 gunicorn :5006
|
||||
vk_load, zk_load, wsgi_bot → tg_mainbot wsgi_edit → db_edit
|
||||
db_update_shortname,
|
||||
tg_publish, evtg_publish
|
||||
```
|
||||
|
||||
### Responder-бот (подписки)
|
||||
|
||||
- **`tg_mainbot.py`** — Flask + webhook, обработка `/start`, callback-кнопок подписки.
|
||||
- **`wsgi_bot.py`** — точка входа gunicorn; однократная регистрация webhook с file lock.
|
||||
- **`season_links.py`** — сезонные payload (`zk2026_post_*`, `subscribe_zk2026_*` и т.д.).
|
||||
- Старые ссылки без префикса сезона → сообщение «сезон завершён» + кнопка в канал.
|
||||
|
||||
### Poster (публикация в канал)
|
||||
|
||||
- **`tg_publish.py`** — посты VK → Telegram.
|
||||
- **`evtg_publish.py`** — события → Telegram.
|
||||
- **`volk_cat_publish.py`** — категории VOLK → Telegram (режим VOLK).
|
||||
- Все исходящие вызовы Bot API — через **`telegram_relay.py`** (`RELAY_URL`).
|
||||
|
||||
### Веб-интерфейс
|
||||
|
||||
- **`db_edit.py`** — CRUD постов/событий, кнопки рескана, публикации, «Назвать» (AI shortname).
|
||||
- **`db_update_shortname.py`** — генерация коротких названий через RouterAI.
|
||||
- **`test_tg_poster.py`** — тестовый пост в канал (кнопка «Тест» в UI).
|
||||
- Шаблоны: `templates/`.
|
||||
|
||||
### База данных
|
||||
|
||||
- **`ensure_db.py`** — создание/миграция схемы (вызывается из loader-скриптов).
|
||||
- **`backupdb.sh`** — дамп БД при deploy (`post-deploy.sh`).
|
||||
|
||||
## Пайплайн runner.sh (ZILANT)
|
||||
|
||||
Порядок выполнения (systemd timer `runner.timer`):
|
||||
|
||||
1. `vk_load.py` — загрузка постов из VK
|
||||
2. `zk_load.py` — загрузка событий из JSON
|
||||
3. `db_update_shortname.py` — AI shortname для новых записей
|
||||
4. `tg_publish.py` — публикация постов
|
||||
5. `evtg_publish.py` — публикация событий
|
||||
|
||||
> **`tools/legacy/evt_prefetch.py`** раньше стоял перед `evtg_publish.py`; с 1.0.0 снят с пайплайна.
|
||||
|
||||
## Deploy
|
||||
|
||||
- **`.woodpecker.yml`** — rsync на сервер, затем `services/post-deploy.sh`.
|
||||
- **`post-deploy.sh`** — venv, pip, backup БД, обновление systemd units, restart сервисов.
|
||||
|
||||
## Конфигурация (.env)
|
||||
|
||||
Шаблон для нового инстанса: [`.env.example`](../.env.example).
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# заполнить CHANGE_ME_*; секреты: python tools/ops/genkey.py
|
||||
```
|
||||
|
||||
| Переменная | Назначение |
|
||||
|------------|------------|
|
||||
| `WORKMODE` | `ZILANT` или `VOLK` |
|
||||
| `SEASON` | Префикс сезона для deep link (`zk2026`) |
|
||||
| `RELAY_URL` | Internal relay для Bot API |
|
||||
| `WEBHOOK_URL`, `WEBHOOK_SECRET` | Доставка апдейтов боту |
|
||||
| `MDB_*`, `MDBASE` | MariaDB |
|
||||
| `POSTER_BOT_TOKEN`, `RESPONDER_BOT_TOKEN` | Токены ботов |
|
||||
| `PZK_PREFIX`, `EVT_PREFIX`, `DESC_PREFIX` | Ссылки ZILANT |
|
||||
| `VK_NO_REPOST_TAG` | Тег VK, отключающий автопубликацию |
|
||||
| `VK_POST_MIN_LETTERS` | Минимум букв в тексте VK-поста (иначе «пустой пост», не публиковать) |
|
||||
| `VOLK_*` | API VOLK (при `WORKMODE=VOLK`) |
|
||||
| `RA_KEY`, `RA_MODEL` | RouterAI для shortname |
|
||||
|
||||
## Карта файлов
|
||||
|
||||
### Ядро (runtime)
|
||||
|
||||
| Файл | Роль |
|
||||
|------|------|
|
||||
| `tg_mainbot.py` | Responder-бот, webhook |
|
||||
| `wsgi_bot.py` | Gunicorn entry (бот) |
|
||||
| `db_edit.py` | Веб-UI |
|
||||
| `wsgi_edit.py` | Gunicorn entry (UI) |
|
||||
| `tg_publish.py` | Публикация постов |
|
||||
| `evtg_publish.py` | Публикация событий |
|
||||
| `vk_load.py` | Загрузка VK |
|
||||
| `zk_load.py` | Загрузка Zilant JSON |
|
||||
| `volk_load.py` | Загрузка VOLK (режим VOLK) |
|
||||
| `volk_cat_publish.py` | Публикация VOLK (режим VOLK) |
|
||||
| `db_update_shortname.py` | AI shortname |
|
||||
| `formatter.py` | Форматирование текста постов/событий |
|
||||
| `ensure_db.py` | Схема БД |
|
||||
| `telegram_relay.py` | Relay для Bot API |
|
||||
| `season_links.py` | Сезонные ссылки и callback |
|
||||
| `test_tg_poster.py` | Тестовая публикация (UI) |
|
||||
| `runner.sh` | Пайплайн по расписанию |
|
||||
| `backupdb.sh` | Бэкап БД |
|
||||
| `gunicorn_bot.conf.py` | Stub-конфиг gunicorn (бот) |
|
||||
|
||||
### Инфраструктура
|
||||
|
||||
| Путь | Роль |
|
||||
|------|------|
|
||||
| `services/testbot-git.service` | systemd: бот :5005 |
|
||||
| `services/testedit-git.service` | systemd: UI :5006 |
|
||||
| `services/runner.service`, `runner.timer` | systemd: hourly runner |
|
||||
| `services/post-deploy.sh` | Post-deploy |
|
||||
| `.woodpecker.yml` | CI deploy |
|
||||
|
||||
### Вспомогательные (`tools/`)
|
||||
|
||||
| Путь | Роль |
|
||||
|------|------|
|
||||
| `tools/ops/genkey.py` | Генерация секретов |
|
||||
| `tools/ops/register_tg_webhook.py` | Ручная регистрация webhook |
|
||||
| `tools/ops/copy_databases.sh` | Клонирование MariaDB |
|
||||
| `tools/dev/send_telegram_example.py` | CLI-пример `/send_message` |
|
||||
| `tools/dev/volk_telegram_testing.py` | Отладка публикации в Telegram |
|
||||
| `tools/dev/volk_category_testing.py` | Отладка загрузки категории VOLK |
|
||||
| `tools/legacy/evt_prefetch.py` | Legacy: prefetch событий |
|
||||
|
||||
См. [`tools/README.md`](../tools/README.md).
|
||||
|
||||
### Документация
|
||||
|
||||
| Файл | Содержание |
|
||||
|------|------------|
|
||||
| `README.md` | Быстрый старт |
|
||||
| `docs/ARCHITECTURE.md` | Этот файл |
|
||||
| `docs/volk-mode.md` | Режим VOLK |
|
||||
| `docs/telegram-messaging.md` | API `/send_message` |
|
||||
| `docs/runner-setup.md` | Настройка runner timer |
|
||||
| `docs/CHANGELOG.md` | История версий |
|
||||
|
||||
## Версионирование
|
||||
|
||||
- Версия в корне: **`VERSION`** (SemVer: `MAJOR.MINOR.PATCH`).
|
||||
- При каждом значимом изменении обновлять `VERSION` и запись в `docs/CHANGELOG.md`.
|
||||
- Правило для агентов: `.cursor/rules/version-and-docs.mdc`.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Changelog
|
||||
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/).
|
||||
Версия проекта — в файле [`VERSION`](../VERSION) (SemVer).
|
||||
|
||||
## [1.1.4] - 2026-09-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Проверка footer-ссылок после edit: убрано сравнение `min_length` (HTML длиннее plain text от Telegram) — лишние retry, мигание ссылок и обрыв пакетной публикации из UI.
|
||||
|
||||
### Changed
|
||||
|
||||
- Проверка footer-ссылок: убрано сравнение `min_length` (HTML длиннее plain text от Telegram).
|
||||
|
||||
## [1.1.3] - 2026-09-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Проверка footer-ссылок после edit: сравнение по конкретным URL (Инфо/Иду/Подписка, ВК), а не по любому `text_link` или словам в тексте тела события — устраняет ложный «успех» при пакетной публикации.
|
||||
|
||||
## [1.1.2] - 2026-09-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Проверка успешного edit ссылок: Telegram отдаёт plain text и `text_link` entities, а не `href=` в caption; `message is not modified` трактуется как успех (ссылки уже применены). Устраняет ложные ошибки при успешной публикации.
|
||||
|
||||
## [1.1.1] - 2026-09-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Публикация постов и событий: второй шаг (edit со строкой ссылок) повторяется до появления `href=` в ответе API; без подтверждённых ссылок запись не помечается опубликованной. Устраняет редкие случаи, когда edit логировался как успех, а ссылки в канале отсутствовали.
|
||||
|
||||
## [1.1.0] - 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- Импорт VK (`vk_load.py`): посты с числом букв меньше `VK_POST_MIN_LETTERS` (по умолчанию 5) сохраняются с названием `- = Пустой пост = -` и без отметки «к публикации».
|
||||
|
||||
### Documentation
|
||||
|
||||
- Параметр `VK_POST_MIN_LETTERS` в `.env.example` и `.env`.
|
||||
|
||||
## [1.0.4] - 2026-08-30
|
||||
|
||||
### Changed
|
||||
|
||||
- `runner.sh` пишет лог в `logs/runner.log` (каталог создаётся при первом запуске).
|
||||
|
||||
## [1.0.3] - 2026-08-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Публикация событий (`evtg_publish.py`): картинка скачивается на сервере и уходит в `sendPhoto` файлом, как у постов VK. Раньше в Bot API передавался URL (`http://reg.konvent.ru/...`), Telegram не мог его забрать (`failed to get HTTP URL content`) и пост уходил текстом.
|
||||
|
||||
## [1.0.2] - 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- Шаблон окружения [`.env.example`](../.env.example) для ZILANT и VOLK (секреты — плейсхолдеры).
|
||||
- `.env` добавлен в [`.gitignore`](../.gitignore).
|
||||
|
||||
### Documentation
|
||||
|
||||
- README и `docs/ARCHITECTURE.md`: копирование `.env.example` → `.env` при настройке инстанса.
|
||||
|
||||
## [1.0.1] - 2026-08-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Утилиты перенесены в `tools/`:
|
||||
- `tools/ops/` — `register_tg_webhook.py`, `copy_databases.sh` (рядом с `genkey.py`)
|
||||
- `tools/dev/` — `send_telegram_example.py`, `volk_telegram_testing.py`, `volk_category_testing.py`
|
||||
- Скрипты в `tools/` загружают `.env` из корня проекта и добавляют корень в `sys.path` при импорте модулей ядра.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Обновлены `tools/README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`, `docs/telegram-messaging.md`, `README.md`.
|
||||
|
||||
## [1.0.0] - 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- Файл версии `VERSION` (SemVer).
|
||||
- Документация: `README.md`, `docs/ARCHITECTURE.md`, `docs/volk-mode.md`.
|
||||
- Каталог `tools/` с README; `tools/ops/genkey.py`, `tools/legacy/evt_prefetch.py`.
|
||||
- Сезонные deep link и callback с префиксом `SEASON` (`season_links.py`, по умолчанию `zk2026`).
|
||||
- Интеграция Telegram Bot API через internal relay (`telegram_relay.py`, `RELAY_URL`).
|
||||
|
||||
### Changed
|
||||
|
||||
- `evt_prefetch.py` перенесён в `tools/legacy/` — не входит в `runner.sh`.
|
||||
- `genkey.py` перенесён в `tools/ops/`.
|
||||
- Документация по runner и отправке сообщений — в `docs/`.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Правило Cursor `.cursor/rules/version-and-docs.mdc` — поддержка версии и changelog при изменениях.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Настройка автоматического запуска runner.sh
|
||||
|
||||
См. также [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) — состав пайплайна.
|
||||
|
||||
Есть два способа настроить запуск `runner.sh` каждый час:
|
||||
|
||||
## Вариант 1: Systemd Timer (рекомендуется)
|
||||
|
||||
В проекте уже используются systemd-сервисы.
|
||||
|
||||
### Установка
|
||||
|
||||
1. Скопируйте файлы в systemd:
|
||||
|
||||
```bash
|
||||
sudo cp services/runner.service /etc/systemd/system/
|
||||
sudo cp services/runner.timer /etc/systemd/system/
|
||||
```
|
||||
|
||||
2. Или используйте скрипт установки:
|
||||
|
||||
```bash
|
||||
chmod +x services/install_runner_timer.sh
|
||||
sudo bash services/install_runner_timer.sh
|
||||
```
|
||||
|
||||
3. Вручную:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable runner.timer
|
||||
sudo systemctl start runner.timer
|
||||
```
|
||||
|
||||
### Управление
|
||||
|
||||
- Статус: `sudo systemctl status runner.timer`
|
||||
- Остановить: `sudo systemctl stop runner.timer`
|
||||
- Запустить: `sudo systemctl start runner.timer`
|
||||
- Отключить: `sudo systemctl disable runner.timer`
|
||||
- Логи: `sudo journalctl -u runner.service -f`
|
||||
- Список timers: `sudo systemctl list-timers`
|
||||
|
||||
### Расписание
|
||||
|
||||
Файл `/etc/systemd/system/runner.timer`:
|
||||
|
||||
- `OnCalendar=hourly` — каждый час
|
||||
- `OnCalendar=*-*-* *:00:00` — в начале каждого часа
|
||||
- `OnCalendar=*-*-* 0,6,12,18:00:00` — в 0, 6, 12, 18 часов
|
||||
- `OnCalendar=Mon..Fri 09:00:00` — будни в 9:00
|
||||
|
||||
После изменения: `sudo systemctl daemon-reload`
|
||||
|
||||
---
|
||||
|
||||
## Вариант 2: Cron
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
```bash
|
||||
0 * * * * /bin/bash /opt/testbot-git/runner.sh
|
||||
```
|
||||
|
||||
Другие варианты: `30 * * * *`, `*/30 * * * *`, `0 9-17 * * *`.
|
||||
|
||||
---
|
||||
|
||||
## Состав runner.sh (ZILANT)
|
||||
|
||||
`vk_load.py` → `zk_load.py` → `db_update_shortname.py` → `tg_publish.py` → `evtg_publish.py`
|
||||
|
||||
Legacy `tools/legacy/evt_prefetch.py` в пайплайн не входит (с 1.0.0).
|
||||
|
||||
Лог пайплайна: `/opt/testbot-git/logs/runner.log` (каталог `logs/` создаётся автоматически).
|
||||
@@ -0,0 +1,94 @@
|
||||
# Отправка сообщений через Telegram бота
|
||||
|
||||
## Описание
|
||||
|
||||
В проект добавлена возможность отправки сообщений пользователям Telegram через бота по их ID. Функциональность включает:
|
||||
|
||||
1. **API endpoint** для отправки сообщений из других скриптов
|
||||
2. **Интерфейс в веб-приложении** для отправки сообщений через браузер
|
||||
3. **Обработку ошибок** и логирование всех операций
|
||||
|
||||
## Архитектура системы
|
||||
|
||||
Система состоит из двух основных компонентов:
|
||||
|
||||
- **Веб-интерфейс** (`db_edit.py`) - работает на порту **5006** (WEBCTRL_PORT)
|
||||
- **Telegram бот** (`tg_mainbot.py`) - работает на порту **5005** (WEBHOOK_PORT)
|
||||
|
||||
API endpoint `/send_message` находится в Telegram боте, поэтому все запросы должны отправляться на порт **5005**.
|
||||
|
||||
### Конфигурация портов
|
||||
|
||||
Порты и URL настраиваются в файле `.env`:
|
||||
|
||||
- `RELAY_URL` - базовый URL **internal_relay** (все исходящие вызовы Bot API идут только через него)
|
||||
- `WEBHOOK_PORT` - порт для Telegram бота (по умолчанию 5005)
|
||||
- `WEBHOOK_URL` - URL доставки апдейтов от internal_relay к боту
|
||||
- `WEBCTRL_PORT` - порт для веб-интерфейса (по умолчанию 5006)
|
||||
|
||||
## Использование через веб-интерфейс
|
||||
|
||||
В заголовке страницы добавлены поля **ID пользователя**, **Сообщение** и кнопка **Отправить**.
|
||||
|
||||
## Использование через API
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST /send_message
|
||||
POST {WEBHOOK_PATH}/send_message
|
||||
```
|
||||
|
||||
### Заголовки
|
||||
|
||||
```
|
||||
Content-Type: application/json
|
||||
X-API-Secret-Token: <WEBHOOK_SECRET>
|
||||
```
|
||||
|
||||
### Тело запроса
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": 123456789,
|
||||
"message": "Текст сообщения"
|
||||
}
|
||||
```
|
||||
|
||||
### Пример (Python)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
def send_telegram_message(user_id, message):
|
||||
url = "http://192.168.0.109:5005/testbot/send_message"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Secret-Token": "ваш_секретный_токен",
|
||||
}
|
||||
response = requests.post(url, json={"user_id": user_id, "message": message}, headers=headers)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
### Пример (curl)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5005/testbot/send_message \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Secret-Token: ваш_секретный_токен" \
|
||||
-d '{"user_id": 123456789, "message": "Привет!"}'
|
||||
```
|
||||
|
||||
Опционально: CLI-скрипт `tools/dev/send_telegram_example.py`.
|
||||
|
||||
## Коды ошибок API
|
||||
|
||||
- `INVALID_TOKEN`, `NO_DATA`, `NO_USER_ID`, `NO_MESSAGE`, `INVALID_USER_ID`
|
||||
- `TELEGRAM_ERROR`, `INTERNAL_ERROR`
|
||||
|
||||
## Требования
|
||||
|
||||
- Бот запущен (webhook через `wsgi_bot.py`)
|
||||
- Пользователь начал диалог с ботом (`/start`)
|
||||
|
||||
См. также [`ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Режим VOLK
|
||||
|
||||
Режим включается в `.env` (скопируйте из [`.env.example`](../.env.example)):
|
||||
|
||||
```env
|
||||
WORKMODE = "VOLK"
|
||||
```
|
||||
|
||||
Проект поддерживает оба режима — **ZILANT** и **VOLK**; переключение только через `WORKMODE`, без смены кодовой базы.
|
||||
|
||||
## Отличия от ZILANT
|
||||
|
||||
| Аспект | ZILANT | VOLK |
|
||||
|--------|--------|------|
|
||||
| Загрузка данных | `vk_load.py`, `zk_load.py` | `volk_load.py` |
|
||||
| Публикация | `tg_publish.py`, `evtg_publish.py` | `volk_cat_publish.py` |
|
||||
| Runner | Полный пайплайн ZILANT | Отдельные кнопки рескана в UI |
|
||||
| UI | Посты + события VK/Zilant | Категории (площадки) + события VOLK |
|
||||
|
||||
## Модули VOLK
|
||||
|
||||
### `volk_load.py`
|
||||
|
||||
- Загружает список категорий и событий с VOLK API.
|
||||
- Переменные: `VOLK_CATEGORY_LIST`, `VOLK_CATEGORY_DESC_PREFIX`, `VOLK_EVENT_LIST_PREFIX`, `VOLK_API_VERSION`.
|
||||
- Вызывается из `db_edit.py` (API `/api/run_volk_rescan`, кнопка «Рескан ВОЛК»).
|
||||
|
||||
### `volk_cat_publish.py`
|
||||
|
||||
- Публикует категории (площадки) в Telegram-канал.
|
||||
- Функции `tg_post_category_by_id`, `tg_post_all_categories` — из веб-UI.
|
||||
|
||||
### Шаблоны
|
||||
|
||||
- `templates/edit_category.html` — редактирование площадки (только VOLK).
|
||||
- Блоки `{% if g.workmode == 'VOLK' %}` в `templates/index.html`.
|
||||
|
||||
## Отладочные скрипты (не в production-пайплайне)
|
||||
|
||||
| Файл | Запуск |
|
||||
|------|--------|
|
||||
| `tools/dev/volk_category_testing.py` | `python tools/dev/volk_category_testing.py <category_id>` |
|
||||
| `tools/dev/volk_telegram_testing.py` | `python tools/dev/volk_telegram_testing.py` |
|
||||
|
||||
См. [`tools/README.md`](../tools/README.md).
|
||||
|
||||
## Переключение режима на сервере
|
||||
|
||||
1. Изменить `WORKMODE` в `/opt/testbot-git/.env`.
|
||||
2. Перезапустить UI: `sudo systemctl restart testedit-git.service`.
|
||||
3. Responder-бот (`testbot-git`) от режима не зависит; сезонные ссылки общие (`SEASON`).
|
||||
|
||||
При смене режима проверьте переменные VOLK/ZILANT в `.env` (префиксы URL, API, `LOG_FILE`).
|
||||
+198
-203
@@ -6,9 +6,17 @@ import time
|
||||
import html
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from urllib.parse import urlparse, unquote
|
||||
from dotenv import load_dotenv
|
||||
from formatter import get_event_text
|
||||
from telegram_relay import bot_api_method_url
|
||||
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
|
||||
|
||||
# Загрузка переменных окружения
|
||||
@@ -92,6 +100,154 @@ def prepare_text(text):
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _filename_from_image_url(image_url):
|
||||
path = unquote(urlparse(image_url).path)
|
||||
name = os.path.basename(path) or "image.jpg"
|
||||
if "." not in name:
|
||||
name = f"{name}.jpg"
|
||||
return name
|
||||
|
||||
|
||||
async def download_image(image_url, httpx_client):
|
||||
"""Скачивает картинку самим сервером (как tg_publish), не отдаёт URL Telegram.
|
||||
|
||||
sendPhoto с photo=http://... Telegram качает сам и часто отвечает
|
||||
«failed to get HTTP URL content» (http, недоступность с DC Telegram).
|
||||
"""
|
||||
logger.info(f"Скачивание изображения: {image_url}")
|
||||
started = time.time()
|
||||
response = await httpx_client.get(
|
||||
image_url,
|
||||
timeout=httpx.Timeout(connect=10.0, read=60.0, write=30.0, pool=10.0),
|
||||
)
|
||||
response.raise_for_status()
|
||||
image_data = response.content
|
||||
content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0].strip()
|
||||
if not content_type.startswith("image/"):
|
||||
content_type = "image/jpeg"
|
||||
logger.info(
|
||||
f"Изображение скачано за {time.time() - started:.2f} с, "
|
||||
f"размер: {len(image_data)} байт, type={content_type}"
|
||||
)
|
||||
return image_data, content_type, _filename_from_image_url(image_url)
|
||||
|
||||
|
||||
async def _edit_event_add_links(
|
||||
httpx_client, has_image, message_id, final_text, entity_label, expected_urls
|
||||
):
|
||||
"""Edit caption/text со ссылками; повтор до подтверждения всех expected_urls в text_link."""
|
||||
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
|
||||
method = "editMessageCaption" if has_image else "editMessageText"
|
||||
field = "caption" if has_image else "text"
|
||||
entities_field = "caption_entities" if has_image else "entities"
|
||||
api_url = bot_api_method_url(BOT_TOKEN, method)
|
||||
|
||||
logger.info(
|
||||
f"Попытка редактирования {entity_label}: message_id={message_id}, "
|
||||
f"final_text_length={len(final_text)}"
|
||||
)
|
||||
|
||||
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
|
||||
payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'message_id': message_id,
|
||||
field: final_text,
|
||||
'parse_mode': 'HTML',
|
||||
}
|
||||
if not has_image:
|
||||
payload['disable_web_page_preview'] = True
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(api_url, json=payload)
|
||||
except httpx.TimeoutException as e:
|
||||
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
f"Таймаут edit ссылок для {entity_label} после "
|
||||
f"{EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
|
||||
)
|
||||
return False
|
||||
logger.warning(
|
||||
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}"
|
||||
)
|
||||
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||
continue
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('ok'):
|
||||
result = data.get('result') or {}
|
||||
result_text = result.get(field) or ""
|
||||
entities = result.get(entities_field) or []
|
||||
if published_message_has_expected_links(entities, expected_urls):
|
||||
logger.info(
|
||||
f"Ссылки добавлены для {entity_label} "
|
||||
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
found = extract_text_link_urls(entities)
|
||||
logger.warning(
|
||||
f"edit ok, но footer-ссылки не подтверждены "
|
||||
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для {entity_label}; "
|
||||
f"ожидались {expected_urls}, найдены text_link: {found}"
|
||||
)
|
||||
else:
|
||||
error_code = data.get('error_code')
|
||||
error_description = (data.get('description') or '').lower()
|
||||
if error_code == 429:
|
||||
retry_after = data.get('parameters', {}).get('retry_after', 60)
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
if error_code == 400 and "not modified" in error_description:
|
||||
logger.info(
|
||||
f"Caption уже содержит ссылки для {entity_label} "
|
||||
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
f"API error edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {data.get('description')}"
|
||||
)
|
||||
elif response.status_code == 429:
|
||||
retry_after = 60
|
||||
try:
|
||||
retry_after = response.json().get('parameters', {}).get('retry_after', 60)
|
||||
except Exception:
|
||||
pass
|
||||
raise Exception(f"RetryAfter:{retry_after}")
|
||||
elif response.status_code == 400:
|
||||
try:
|
||||
error_description = (response.json().get('description') or '').lower()
|
||||
if "not modified" in error_description:
|
||||
logger.info(
|
||||
f"Caption уже содержит ссылки для {entity_label} "
|
||||
f"(message is not modified HTTP 400, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {response.text[:300]}"
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"HTTP 400 edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {response.text[:300]}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"HTTP {response.status_code} edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для {entity_label}: {response.text[:300]}"
|
||||
)
|
||||
|
||||
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
|
||||
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||
|
||||
logger.error(
|
||||
f"Не удалось добавить строку ссылок для {entity_label} "
|
||||
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def tg_post_event(httpx_client, event_data):
|
||||
"""Публикация одного события"""
|
||||
try:
|
||||
@@ -194,24 +350,35 @@ async def tg_post_event(httpx_client, event_data):
|
||||
# Публикация первоначального сообщения без ссылок
|
||||
send_photo_url = bot_api_method_url(BOT_TOKEN, "sendPhoto")
|
||||
send_message_url = bot_api_method_url(BOT_TOKEN, "sendMessage")
|
||||
message_is_photo = False
|
||||
|
||||
if has_image:
|
||||
try:
|
||||
logger.info(f"Попытка отправки фото для события {event_data['number']}: chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, image_url_length={len(image_url)}")
|
||||
photo_payload = {
|
||||
logger.info(
|
||||
f"Попытка отправки фото для события {event_data['number']}: "
|
||||
f"chat_id={CHANNEL_ID}, caption_length={len(initial_text)}, "
|
||||
f"image_url={image_url}"
|
||||
)
|
||||
image_data, content_type, filename = await download_image(image_url, httpx_client)
|
||||
photo_data = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'photo': image_url,
|
||||
'caption': initial_text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_notification': PUBLISH_SILENTLY
|
||||
'disable_notification': 'true' if PUBLISH_SILENTLY else 'false',
|
||||
}
|
||||
photo_response = await httpx_client.post(send_photo_url, json=photo_payload)
|
||||
photo_files = {
|
||||
'photo': (filename, image_data, content_type),
|
||||
}
|
||||
photo_response = await httpx_client.post(
|
||||
send_photo_url, data=photo_data, files=photo_files
|
||||
)
|
||||
|
||||
if photo_response.status_code == 200:
|
||||
photo_result = photo_response.json()
|
||||
if photo_result.get('ok'):
|
||||
message_data = photo_result.get('result', {})
|
||||
message_id = message_data.get('message_id')
|
||||
message_is_photo = True
|
||||
logger.info(f"Фото успешно отправлено для события {event_data['number']}, message_id={message_id}")
|
||||
else:
|
||||
# Ошибка в ответе API - пробуем отправить текстовое сообщение
|
||||
@@ -370,208 +537,36 @@ async def tg_post_event(httpx_client, event_data):
|
||||
else:
|
||||
final_text = initial_text
|
||||
|
||||
expected_urls = []
|
||||
if DESC_PREFIX:
|
||||
expected_urls.append(f"{DESC_PREFIX}{number}/")
|
||||
if USE_SUBSCRIPTION_BOT:
|
||||
expected_urls.append(f"{PZK_PREFIX}{number}/")
|
||||
expected_urls.append(
|
||||
subscription_start_link(RESPONDER_BOT_NAME, "event", message_id)
|
||||
)
|
||||
|
||||
# Проверяем, изменился ли текст. Если нет - не редактируем сообщение
|
||||
if final_text == initial_text:
|
||||
logger.info(f"Финальный текст идентичен исходному для события {event_data['number']}, редактирование не требуется")
|
||||
return message_id
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Редактируем сообщение, добавляя ссылки
|
||||
logger.info(f"Попытка редактирования сообщения для события {event_data['number']}: message_id={message_id}, final_text_length={len(final_text)}")
|
||||
edit_caption_url = bot_api_method_url(BOT_TOKEN, "editMessageCaption")
|
||||
edit_text_url = bot_api_method_url(BOT_TOKEN, "editMessageText")
|
||||
|
||||
if has_image:
|
||||
try:
|
||||
edit_caption_payload = {
|
||||
'chat_id': CHANNEL_ID,
|
||||
'message_id': message_id,
|
||||
'caption': final_text,
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
edit_caption_response = await httpx_client.post(edit_caption_url, json=edit_caption_payload)
|
||||
|
||||
if edit_caption_response.status_code == 200:
|
||||
edit_result = edit_caption_response.json()
|
||||
if edit_result.get('ok'):
|
||||
logger.info(f"Подпись успешно отредактирована для события {event_data['number']}")
|
||||
else:
|
||||
# Ошибка при редактировании подписи
|
||||
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
|
||||
entity_label = f"события {event_data['number']}"
|
||||
links_ok = await _edit_event_add_links(
|
||||
httpx_client, message_is_photo, message_id, final_text, entity_label,
|
||||
expected_urls,
|
||||
)
|
||||
if not links_ok and message_is_photo:
|
||||
links_ok = await _edit_event_add_links(
|
||||
httpx_client, False, message_id, final_text, entity_label,
|
||||
expected_urls,
|
||||
)
|
||||
if not links_ok:
|
||||
logger.error(
|
||||
f"Событие {event_data['number']}: сообщение отправлено (id={message_id}), "
|
||||
f"но ссылки не добавлены"
|
||||
)
|
||||
return None
|
||||
|
||||
return message_id # Возвращаем только message_id
|
||||
|
||||
|
||||
@@ -6,15 +6,18 @@ SCRIPTS_DIR="/opt/testbot-git"
|
||||
VENV_ACTIVATE="$SCRIPTS_DIR/venv/bin/activate"
|
||||
|
||||
# Список скриптов для запуска в порядке выполнения
|
||||
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
|
||||
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py")
|
||||
# Legacy prefetch (не в пайплайне): tools/legacy/evt_prefetch.py
|
||||
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tools/legacy/evt_prefetch.py" "tg_publish.py" "evtg_publish.py")
|
||||
# SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py" "evtg_publish.py")
|
||||
SCRIPTS=("vk_load.py" "zk_load.py" "db_update_shortname.py" "tg_publish.py")
|
||||
|
||||
# Интервалы между скриптами (в секундах)
|
||||
# DELAYS=(2 2 2 2 30)
|
||||
DELAYS=(2 2 2 30)
|
||||
DELAYS=(2 2 2)
|
||||
|
||||
# Лог-файл
|
||||
LOG_FILE="$SCRIPTS_DIR/runner.log"
|
||||
LOG_DIR="$SCRIPTS_DIR/logs"
|
||||
LOG_FILE="$LOG_DIR/runner.log"
|
||||
|
||||
# Функция для логирования
|
||||
log_message() {
|
||||
@@ -24,11 +27,20 @@ log_message() {
|
||||
|
||||
# Проверка и настройка лог-файла
|
||||
setup_logfile() {
|
||||
# Проверяем, существует ли файл и можно ли в него писать
|
||||
if [ ! -d "$LOG_DIR" ]; then
|
||||
if ! mkdir -p "$LOG_DIR" 2>/dev/null; then
|
||||
if sudo mkdir -p "$LOG_DIR" 2>/dev/null; then
|
||||
sudo chown "$(whoami)" "$LOG_DIR"
|
||||
sudo chmod 755 "$LOG_DIR"
|
||||
else
|
||||
echo "ОШИБКА: Не удалось создать каталог $LOG_DIR!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
# Пытаемся создать файл
|
||||
if ! touch "$LOG_FILE" 2>/dev/null; then
|
||||
# Если не получается, пробуем с sudo
|
||||
if sudo touch "$LOG_FILE" 2>/dev/null; then
|
||||
sudo chown $(whoami) "$LOG_FILE"
|
||||
sudo chmod 644 "$LOG_FILE"
|
||||
@@ -40,9 +52,7 @@ setup_logfile() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Проверяем права на запись
|
||||
if [ ! -w "$LOG_FILE" ]; then
|
||||
# Пытаемся изменить права
|
||||
if sudo chown $(whoami) "$LOG_FILE" 2>/dev/null || chmod 644 "$LOG_FILE" 2>/dev/null; then
|
||||
log_message "Изменены права на лог-файл: $LOG_FILE"
|
||||
else
|
||||
@@ -112,4 +122,3 @@ done
|
||||
|
||||
log_message "ВСЕ СКРИПТЫ ВЫПОЛНЕНЫ УСПЕШНО!"
|
||||
log_message "=== ЗАВЕРШЕНИЕ ==="
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,3 +50,49 @@ def get_ptb_base_urls() -> Tuple[str, str]:
|
||||
def bot_api_method_url(token: str, method: str) -> str:
|
||||
"""Полный URL метода Bot API через relay (для сырых httpx/requests вызовов)."""
|
||||
return f"{get_relay_url()}/bot{token}/{method}"
|
||||
|
||||
|
||||
# Публикация в два шага (send → edit ссылок): пауза и повторы при гонке с Telegram
|
||||
EDIT_LINKS_INITIAL_DELAY_SEC = 3
|
||||
EDIT_LINKS_MAX_ATTEMPTS = 5
|
||||
EDIT_LINKS_RETRY_INTERVAL_SEC = 2
|
||||
|
||||
|
||||
def extract_text_link_urls(entities) -> list:
|
||||
"""URL из entities типа text_link (ответ Telegram Bot API или python-telegram-bot)."""
|
||||
urls = []
|
||||
for ent in entities or []:
|
||||
if isinstance(ent, dict):
|
||||
if ent.get("type") == "text_link" and ent.get("url"):
|
||||
urls.append(ent["url"])
|
||||
elif getattr(ent, "type", None) == "text_link":
|
||||
url = getattr(ent, "url", None)
|
||||
if url:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def published_message_has_expected_links(entities, expected_urls) -> bool:
|
||||
"""
|
||||
Проверка, что в сообщении есть именно footer-ссылки публикации.
|
||||
|
||||
Сравнение только по URL в text_link entities (plain text короче HTML).
|
||||
"""
|
||||
expected_urls = [u for u in (expected_urls or []) if u]
|
||||
if not expected_urls:
|
||||
return True
|
||||
|
||||
found = set(extract_text_link_urls(entities))
|
||||
return all(url in found for url in expected_urls)
|
||||
|
||||
|
||||
def published_message_has_links(text: str, entities=None, expected_urls=None) -> bool:
|
||||
"""Проверка footer-ссылок; expected_urls обязателен при вызове из publisher."""
|
||||
if expected_urls is not None:
|
||||
return published_message_has_expected_links(entities, expected_urls)
|
||||
return bool(extract_text_link_urls(entities))
|
||||
|
||||
|
||||
def published_text_has_links(text: str) -> bool:
|
||||
"""Устаревший вызов без expected_urls — только для совместимости."""
|
||||
return published_message_has_links(text)
|
||||
|
||||
+111
-72
@@ -16,7 +16,15 @@ from formatter import get_event_text
|
||||
from telegram import Bot
|
||||
from telegram.error import TelegramError, TimedOut
|
||||
from telegram.request import HTTPXRequest
|
||||
from telegram_relay import get_ptb_base_urls, get_relay_url
|
||||
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 для правильной работы в отдельном потоке
|
||||
@@ -146,6 +154,94 @@ async def download_image(image_url, httpx_client):
|
||||
logger.error(f"Ошибка при скачивании изображения {image_url}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def _edit_post_add_links(
|
||||
bot, message_id, message_has_image, final_text, vk_post_id, expected_urls
|
||||
):
|
||||
"""
|
||||
Второй шаг публикации: editMessageCaption/Text со ссылками.
|
||||
Повторяет попытку, пока в ответе API нет всех expected_urls в text_link entities.
|
||||
"""
|
||||
await asyncio.sleep(EDIT_LINKS_INITIAL_DELAY_SEC)
|
||||
|
||||
for attempt in range(1, EDIT_LINKS_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
if message_has_image:
|
||||
edited = await bot.edit_message_caption(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
caption=final_text,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
published_text = edited.caption or ""
|
||||
entities = edited.caption_entities or []
|
||||
else:
|
||||
edited = await bot.edit_message_text(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
text=final_text,
|
||||
parse_mode='HTML',
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
published_text = edited.text or ""
|
||||
entities = edited.entities or []
|
||||
|
||||
if published_message_has_expected_links(entities, expected_urls):
|
||||
logger.info(
|
||||
f"Ссылки добавлены для записи VK ID {vk_post_id} "
|
||||
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
|
||||
found = extract_text_link_urls(entities)
|
||||
logger.warning(
|
||||
f"editMessage* ok, но footer-ссылки не подтверждены "
|
||||
f"(попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) для VK ID {vk_post_id}; "
|
||||
f"ожидались {expected_urls}, найдены text_link: {found}"
|
||||
)
|
||||
except TimedOut as e:
|
||||
if attempt >= EDIT_LINKS_MAX_ATTEMPTS:
|
||||
logger.error(
|
||||
f"Таймаут редактирования ссылок для VK ID {vk_post_id} "
|
||||
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток: {e}"
|
||||
)
|
||||
return False
|
||||
logger.warning(
|
||||
f"Таймаут edit (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для VK ID {vk_post_id}, повтор"
|
||||
)
|
||||
except TelegramError as e:
|
||||
if hasattr(e, 'retry_after') or '429' in str(e):
|
||||
raise RetryAfterException(getattr(e, 'retry_after', 60))
|
||||
err = str(e).lower()
|
||||
if "not modified" in err:
|
||||
logger.info(
|
||||
f"Caption уже содержит ссылки для VK ID {vk_post_id} "
|
||||
f"(message is not modified, попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS})"
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
f"Ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для VK ID {vk_post_id}: {e}"
|
||||
)
|
||||
except RetryAfterException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Неожиданная ошибка edit ссылок (попытка {attempt}/{EDIT_LINKS_MAX_ATTEMPTS}) "
|
||||
f"для VK ID {vk_post_id}: {e}"
|
||||
)
|
||||
|
||||
if attempt < EDIT_LINKS_MAX_ATTEMPTS:
|
||||
await asyncio.sleep(EDIT_LINKS_RETRY_INTERVAL_SEC)
|
||||
|
||||
logger.error(
|
||||
f"Не удалось добавить строку ссылок для VK ID {vk_post_id} "
|
||||
f"после {EDIT_LINKS_MAX_ATTEMPTS} попыток"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def publish_to_tg(vk_post_id):
|
||||
"""Публикация одной записи по VK post ID"""
|
||||
logger.info(f"Запуск публикации для записи VK ID {vk_post_id}")
|
||||
@@ -428,79 +524,22 @@ async def publish_to_tg(vk_post_id):
|
||||
|
||||
links_line = " | ".join(updated_links)
|
||||
final_text = f"{text}\n\n{links_line}"
|
||||
|
||||
await asyncio.sleep(2) # задержка между обращениями к телеграм
|
||||
|
||||
# Редактируем сообщение, добавляя ссылки
|
||||
# Используем message_has_image для определения типа сообщения
|
||||
# Если было отправлено изображение, редактируем caption, иначе - текст
|
||||
if message_has_image:
|
||||
# Сообщение с изображением - редактируем caption с повторными попытками
|
||||
edit_success = False
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
await bot.edit_message_caption(
|
||||
chat_id=CHANNEL_ID,
|
||||
message_id=message_id,
|
||||
caption=final_text,
|
||||
parse_mode='HTML'
|
||||
expected_urls = []
|
||||
if post['vk_post_url'] and (post['vk_post_url'].startswith('http://') or post['vk_post_url'].startswith('https://')):
|
||||
expected_urls.append(post['vk_post_url'])
|
||||
if post['is_event'] and USE_SUBSCRIPTION_BOT:
|
||||
expected_urls.append(
|
||||
subscription_start_link(RESPONDER_BOT_NAME, "post", message_id)
|
||||
)
|
||||
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
|
||||
if not await _edit_post_add_links(
|
||||
bot, message_id, message_has_image, final_text, vk_post_id,
|
||||
expected_urls,
|
||||
):
|
||||
logger.error(
|
||||
f"Запись VK ID {vk_post_id}: сообщение отправлено (id={message_id}), "
|
||||
f"но ссылки не добавлены — published_in_tg не обновляем"
|
||||
)
|
||||
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
|
||||
return False
|
||||
|
||||
await asyncio.sleep(2) # задержка между обращениями к телеграм
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Вспомогательные скрипты
|
||||
|
||||
Скрипты вне основного пайплайна (`runner.sh`) и systemd-сервисов. Запускать из **корня проекта** с активированным venv.
|
||||
|
||||
## ops/ — эксплуатация
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `genkey.py` | Генерация hex-ключа для `SECRET_KEY` / `WEBHOOK_SECRET` |
|
||||
| `register_tg_webhook.py` | Ручная регистрация webhook (в норме — `wsgi_bot.py` при старте) |
|
||||
| `copy_databases.sh` | Клонирование MariaDB между базами |
|
||||
|
||||
```bash
|
||||
python tools/ops/genkey.py
|
||||
./venv/bin/python tools/ops/register_tg_webhook.py
|
||||
bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
|
||||
```
|
||||
|
||||
## dev/ — отладка и примеры
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `send_telegram_example.py` | CLI-пример API `/send_message` |
|
||||
| `volk_telegram_testing.py` | Отладочный тест публикации в Telegram |
|
||||
| `volk_category_testing.py` | Отладочный тест загрузки категории VOLK |
|
||||
|
||||
```bash
|
||||
python tools/dev/send_telegram_example.py <user_id> "<message>"
|
||||
python tools/dev/volk_telegram_testing.py
|
||||
python tools/dev/volk_category_testing.py <category_id>
|
||||
```
|
||||
|
||||
## legacy/ — снятые с пайплайна
|
||||
|
||||
| Файл | Назначение |
|
||||
|------|------------|
|
||||
| `evt_prefetch.py` | Массовая пометка видимых событий `marked_to_publication=1`. Убран из `runner.sh` в 1.0.0 |
|
||||
|
||||
```bash
|
||||
./venv/bin/python tools/legacy/evt_prefetch.py
|
||||
```
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Пример скрипта для отправки сообщений через Telegram бота
|
||||
Использование: python send_telegram_example.py <user_id> <message>
|
||||
Пример скрипта для отправки сообщений через Telegram бота.
|
||||
|
||||
Использование (из корня проекта):
|
||||
python tools/dev/send_telegram_example.py <user_id> <message>
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -9,6 +11,12 @@ import requests
|
||||
import json
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||
load_dotenv()
|
||||
|
||||
def send_message(user_id, message_text, bot_url=None, api_token=None):
|
||||
"""
|
||||
Отправляет сообщение пользователю через Telegram бота
|
||||
@@ -64,8 +72,8 @@ def send_message(user_id, message_text, bot_url=None, api_token=None):
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("Использование: python send_telegram_example.py <user_id> <message>")
|
||||
print("Пример: python send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
|
||||
print("Использование: python tools/dev/send_telegram_example.py <user_id> <message>")
|
||||
print("Пример: python tools/dev/send_telegram_example.py 123456789 'Привет! Это тестовое сообщение.'")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Тестовый модуль для проверки загрузки описания категории.
|
||||
Использование: python volk_category_testing.py <category_id>
|
||||
|
||||
Использование (из корня проекта):
|
||||
python tools/dev/volk_category_testing.py <category_id>
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -15,7 +17,8 @@ from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError, HTTPError
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загрузка переменных окружения
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||
load_dotenv()
|
||||
|
||||
# Параметры из .env
|
||||
@@ -260,8 +263,8 @@ def main():
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Примеры использования:
|
||||
python volk_category_testing.py volk26_nri
|
||||
python volk_category_testing.py volk26_nri volk26_other volk26_third
|
||||
python tools/dev/volk_category_testing.py volk26_nri
|
||||
python tools/dev/volk_category_testing.py volk26_nri volk26_other volk26_third
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -7,12 +7,17 @@ import sys
|
||||
import json
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||
load_dotenv()
|
||||
|
||||
from telegram_relay import bot_api_method_url, get_relay_url
|
||||
from season_links import subscription_start_link
|
||||
|
||||
# Загрузка переменных окружения
|
||||
load_dotenv()
|
||||
|
||||
# Настройки из переменных окружения
|
||||
POSTER_BOT_TOKEN = os.getenv('POSTER_BOT_TOKEN')
|
||||
RESPONDER_BOT_NAME = os.getenv('RESPONDER_BOT_NAME')
|
||||
@@ -1,10 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LEGACY — не входит в пайплайн runner.sh с версии 1.0.0.
|
||||
|
||||
Проставляет marked_to_publication = 1 видимым событиям (is_visible = 1),
|
||||
у которых флаг ещё не установлен.
|
||||
|
||||
Запуск вручную из корня проекта:
|
||||
./venv/bin/python tools/legacy/evt_prefetch.py
|
||||
"""
|
||||
import os
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
from datetime import datetime, timezone
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загрузка переменных окружения
|
||||
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||
load_dotenv()
|
||||
|
||||
MDB_HOST = os.getenv('MDB_HOST')
|
||||
@@ -12,6 +21,7 @@ MDB_USER = os.getenv('MDB_USER')
|
||||
MDB_PW = os.getenv('MDB_PW')
|
||||
MDBASE = os.getenv('MDBASE')
|
||||
|
||||
|
||||
def prefetch_all_events():
|
||||
"""
|
||||
Проверяет записи в таблице events и устанавливает флаг marked_to_publication
|
||||
@@ -20,7 +30,6 @@ def prefetch_all_events():
|
||||
- не имеют флага marked_to_publication = 1
|
||||
"""
|
||||
try:
|
||||
# Подключение к базе данных
|
||||
connection = mysql.connector.connect(
|
||||
host=MDB_HOST,
|
||||
user=MDB_USER,
|
||||
@@ -28,14 +37,12 @@ def prefetch_all_events():
|
||||
database=MDBASE
|
||||
)
|
||||
|
||||
# Установка временной зоны соединения в UTC
|
||||
cursor_temp = connection.cursor()
|
||||
cursor_temp.execute("SET time_zone = '+00:00'")
|
||||
cursor_temp.close()
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
# SQL запрос для обновления записей
|
||||
update_query = """
|
||||
UPDATE events
|
||||
SET marked_to_publication = 1
|
||||
@@ -43,15 +50,11 @@ def prefetch_all_events():
|
||||
AND marked_to_publication = 0
|
||||
"""
|
||||
|
||||
# Выполнение запроса
|
||||
cursor.execute(update_query)
|
||||
updated_count = cursor.rowcount
|
||||
|
||||
# Фиксация изменений
|
||||
connection.commit()
|
||||
|
||||
print(f"Обновлено записей: {updated_count}")
|
||||
|
||||
return updated_count
|
||||
|
||||
except Error as e:
|
||||
@@ -67,5 +70,6 @@ def prefetch_all_events():
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
prefetch_all_events()
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Использование: ./copy_db.sh <source_db> <target_db> <username>
|
||||
# Пример: ./copy_db.sh old_db new_db appuser
|
||||
# Использование (из корня проекта):
|
||||
# bash tools/ops/copy_databases.sh <source_db> <target_db> <username>
|
||||
# Пример:
|
||||
# bash tools/ops/copy_databases.sh old_db new_db appuser
|
||||
|
||||
set -e # Завершать выполнение при любой ошибке
|
||||
|
||||
@@ -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))
|
||||
@@ -2,21 +2,24 @@
|
||||
"""
|
||||
Ручная регистрация Telegram webhook через internal_relay (с secret_token).
|
||||
|
||||
Запуск на сервере бота:
|
||||
cd /opt/testbot-git
|
||||
./venv/bin/python register_tg_webhook.py
|
||||
Запуск из корня проекта:
|
||||
./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 os
|
||||
import requests
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
load_dotenv(os.path.join(_PROJECT_ROOT, ".env"))
|
||||
load_dotenv()
|
||||
|
||||
from telegram_relay import bot_api_method_url, get_relay_url
|
||||
+43
-9
@@ -19,6 +19,14 @@ VK_API_VERSION = os.getenv('VK_API_VERSION', '5.131')
|
||||
IS_EVENT = os.getenv('IS_EVENT', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||
AUTO_PUBLISH = os.getenv('AUTO_PUBLISH', 'false').lower() in ('true', '1', 'yes', 'on')
|
||||
VK_NO_REPOST_TAG = os.getenv('VK_NO_REPOST_TAG', '') # Тег для отключения автопубликации
|
||||
# Минимум букв (isalpha), чтобы пост не считался пустым (видео без описания и т.п.)
|
||||
try:
|
||||
VK_POST_MIN_LETTERS = int((os.getenv('VK_POST_MIN_LETTERS') or '5').strip().strip('"').strip("'"))
|
||||
except ValueError:
|
||||
VK_POST_MIN_LETTERS = 5
|
||||
if VK_POST_MIN_LETTERS < 0:
|
||||
VK_POST_MIN_LETTERS = 0
|
||||
EMPTY_POST_SHORTNAME = "- = Пустой пост = -"
|
||||
LOG_FILE = os.getenv('LOG_FILE', 'vk_loader.log') # Путь к лог-файлу
|
||||
LOG_PREFIX = "VK_loader" # Уникальный префикс для идентификации скрипта
|
||||
|
||||
@@ -86,6 +94,12 @@ def get_vk_posts():
|
||||
log_message(error_msg)
|
||||
raise Exception(error_msg)
|
||||
|
||||
|
||||
def count_letters(text):
|
||||
"""Количество букв в тексте (Unicode isalpha), без цифр и знаков."""
|
||||
return sum(1 for c in (text or "") if c.isalpha())
|
||||
|
||||
|
||||
def process_post(post):
|
||||
"""Извлекает необходимые данные из поста VK, включая обработку репостов"""
|
||||
post_id = post['id']
|
||||
@@ -144,11 +158,15 @@ def process_post(post):
|
||||
|
||||
# Формируем текст поста: если это репост, добавляем префикс и информацию об оригинале
|
||||
post_text = source_post.get('text', '')
|
||||
own_text = post.get('text', '')
|
||||
letter_count = count_letters(post_text)
|
||||
if is_repost:
|
||||
letter_count += count_letters(own_text)
|
||||
# Если у репоста есть свой текст, добавляем его перед текстом оригинала
|
||||
if post.get('text', '').strip():
|
||||
post_text = f"{post['text']}\n\n---\n\n{post_text}"
|
||||
if own_text.strip():
|
||||
post_text = f"{own_text}\n\n---\n\n{post_text}"
|
||||
post_text = repost_prefix + post_text + repost_info
|
||||
is_empty_text = letter_count < VK_POST_MIN_LETTERS
|
||||
|
||||
return {
|
||||
'vk_post_id': post_id,
|
||||
@@ -161,7 +179,9 @@ def process_post(post):
|
||||
'poll_options': poll_options,
|
||||
'poll_multiple': poll_multiple,
|
||||
'poll_end_date': poll_end_date,
|
||||
'is_repost': is_repost
|
||||
'is_repost': is_repost,
|
||||
'is_empty_text': is_empty_text,
|
||||
'letter_count': letter_count,
|
||||
}
|
||||
|
||||
def save_to_database(posts):
|
||||
@@ -175,9 +195,14 @@ def save_to_database(posts):
|
||||
try:
|
||||
# Проверка наличия тега VK_NO_REPOST_TAG в тексте поста
|
||||
post_text = post.get('text', '')
|
||||
is_empty_text = post.get('is_empty_text', False)
|
||||
should_auto_publish = AUTO_PUBLISH
|
||||
shortname = None
|
||||
|
||||
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
|
||||
if is_empty_text:
|
||||
should_auto_publish = False
|
||||
shortname = EMPTY_POST_SHORTNAME
|
||||
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
|
||||
# Если тег найден, отключаем автопубликацию независимо от AUTO_PUBLISH
|
||||
should_auto_publish = False
|
||||
|
||||
@@ -201,16 +226,21 @@ def save_to_database(posts):
|
||||
int(post['poll_multiple']),
|
||||
post['poll_end_date'],
|
||||
IS_EVENT,
|
||||
int(should_auto_publish), # Значение с учетом проверки тега
|
||||
None, # shortname - пока не используется, устанавливаем NULL
|
||||
None # action_number - пока не используется, устанавливаем NULL
|
||||
int(should_auto_publish),
|
||||
shortname,
|
||||
None # action_number
|
||||
))
|
||||
if cursor.rowcount > 0:
|
||||
new_posts_count += 1
|
||||
# Логируем добавление нового поста с указанием причины отключения автопубликации
|
||||
auto_publish_reason = 'Да'
|
||||
if not should_auto_publish:
|
||||
if VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
|
||||
if is_empty_text:
|
||||
auto_publish_reason = (
|
||||
f'Нет (пустой текст: {post.get("letter_count", 0)} букв '
|
||||
f'< {VK_POST_MIN_LETTERS})'
|
||||
)
|
||||
elif VK_NO_REPOST_TAG and VK_NO_REPOST_TAG in post_text:
|
||||
auto_publish_reason = 'Нет (найден тег ' + VK_NO_REPOST_TAG + ')'
|
||||
else:
|
||||
auto_publish_reason = 'Нет'
|
||||
@@ -246,7 +276,11 @@ def vk_load_10():
|
||||
Возвращает количество добавленных постов
|
||||
"""
|
||||
# Стартовая информация
|
||||
start_msg = f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH}"
|
||||
start_msg = (
|
||||
f"Старт скрипта | База: {DB_CONFIG['database']}@{DB_CONFIG['host']} | "
|
||||
f"IS_EVENT: {IS_EVENT} | AUTO_PUBLISH: {AUTO_PUBLISH} | "
|
||||
f"VK_POST_MIN_LETTERS: {VK_POST_MIN_LETTERS}"
|
||||
)
|
||||
print(start_msg)
|
||||
log_message(start_msg)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user