diff --git a/.env b/.env index 923c874..72d86d2 100644 --- a/.env +++ b/.env @@ -6,6 +6,9 @@ LOG_FILE = /opt/testbot-git/logs/testzilantposter.log WORKMODE = "ZILANT" # WORKMODE = "VOLK" +# Код сезона для deep link / callback подписок (zk2026_post_…, zk2026_event_…) +SEASON = "zk2026" + # Telegram API через internal_relay (retransbot) — обязательно # Нужна схема http:// или https:// (если без схемы — подставится http://) # Локально: http://127.0.0.1:5100 или http://192.168.0.109:5100 diff --git a/__pycache__/season_links.cpython-314.pyc b/__pycache__/season_links.cpython-314.pyc new file mode 100644 index 0000000..7e6f160 Binary files /dev/null and b/__pycache__/season_links.cpython-314.pyc differ diff --git a/evtg_publish.py b/evtg_publish.py index 8a0a2f7..07030a1 100644 --- a/evtg_publish.py +++ b/evtg_publish.py @@ -9,6 +9,7 @@ from datetime import datetime, timezone, timedelta from dotenv import load_dotenv from formatter import get_event_text from telegram_relay import bot_api_method_url +from season_links import subscription_start_link # Загрузка переменных окружения load_dotenv() @@ -149,7 +150,9 @@ async def tg_post_event(httpx_client, event_data): links_parts.append(f'🔎 Инфо') if USE_SUBSCRIPTION_BOT: links_parts.append(f'📌 Иду') - links_parts.append(f'🔜 Подписка') + links_parts.append( + f'🔜 Подписка' + ) if links_parts: links_line_placeholder = ' | '.join(links_parts) @@ -357,7 +360,9 @@ async def tg_post_event(httpx_client, event_data): links_parts.append(f'🔎 Инфо') if USE_SUBSCRIPTION_BOT: links_parts.append(f'📌 Иду') - links_parts.append(f'🔜 Подписка') + links_parts.append( + f'🔜 Подписка' + ) if links_parts: links_line = ' | '.join(links_parts) diff --git a/season_links.py b/season_links.py new file mode 100644 index 0000000..a9ef31a --- /dev/null +++ b/season_links.py @@ -0,0 +1,127 @@ +""" +Сезонные deep link и callback для подписок. + +Формат текущего сезона (SEASON=zk2026): + start=zk2026_post_{tg_message_id} + start=zk2026_event_{tg_message_id} + callback: subscribe_zk2026_{id} / unsubscribe_zk2026_{id} + callback: subscribe_evt_zk2026_{id} / unsubscribe_evt_zk2026_{id} + +Старые форматы (post_123, event_123, subscribe_123, …) и чужой сезон +считаются завершённым прошлым сезоном. +""" + +import os +import re +from typing import Optional, Tuple + +from dotenv import load_dotenv + +_ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +load_dotenv(_ENV_PATH) +load_dotenv() + +SEASON = (os.getenv("SEASON") or "zk2026").strip().strip('"').strip("'") + +# start: zk2026_post_123 / zk2026_event_123 +_START_RE = re.compile( + rf"^(?P[A-Za-z0-9]+)_((?Ppost|event)_)(?P\d+)$" +) +# legacy start: post_123 / event_123 +_START_LEGACY_RE = re.compile(r"^(?Ppost|event)_(?P\d+)$") + +# callback posts: subscribe_zk2026_123 +_CB_POST_RE = re.compile( + r"^(?Psubscribe|unsubscribe)_(?P[A-Za-z0-9]+)_(?P\d+)$" +) +# legacy: subscribe_123 +_CB_POST_LEGACY_RE = re.compile(r"^(?Psubscribe|unsubscribe)_(?P\d+)$") + +# callback events: subscribe_evt_zk2026_123 +_CB_EVT_RE = re.compile( + r"^(?Psubscribe|unsubscribe)_evt_(?P[A-Za-z0-9]+)_(?P\d+)$" +) +# legacy: subscribe_evt_123 +_CB_EVT_LEGACY_RE = re.compile(r"^(?Psubscribe|unsubscribe)_evt_(?P\d+)$") + + +def subscription_start_link(bot_name: str, kind: str, message_id) -> str: + """HTTPS deep link на бота: kind = 'post' | 'event'.""" + payload = f"{SEASON}_{kind}_{message_id}" + return f"https://t.me/{bot_name}?start={payload}" + + +def callback_subscribe_post(message_id) -> str: + return f"subscribe_{SEASON}_{message_id}" + + +def callback_unsubscribe_post(message_id) -> str: + return f"unsubscribe_{SEASON}_{message_id}" + + +def callback_subscribe_event(message_id) -> str: + return f"subscribe_evt_{SEASON}_{message_id}" + + +def callback_unsubscribe_event(message_id) -> str: + return f"unsubscribe_evt_{SEASON}_{message_id}" + + +def parse_start_payload(payload: str) -> Tuple[Optional[str], Optional[str], bool]: + """ + Разбор аргумента /start. + + Returns: + (kind, id, is_current_season) + kind: 'post' | 'event' | None + is_current_season: False → показать «сезон завершён» + """ + if not payload: + return None, None, True + + m = _START_RE.match(payload) + if m: + return m.group("kind"), m.group("id"), m.group("season") == SEASON + + m = _START_LEGACY_RE.match(payload) + if m: + return m.group("kind"), m.group("id"), False + + # Неизвестный payload — считаем устаревшим/чужим, без действия по id + return None, None, False + + +def parse_post_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]: + """ + Returns: + (action, id, is_current_season) для кнопок подписки на посты. + action: 'subscribe' | 'unsubscribe' | None + """ + if data.startswith("subscribe_evt_") or data.startswith("unsubscribe_evt_"): + return None, None, True + + m = _CB_POST_RE.match(data) + if m: + return m.group("action"), m.group("id"), m.group("season") == SEASON + + m = _CB_POST_LEGACY_RE.match(data) + if m: + return m.group("action"), m.group("id"), False + + return None, None, True + + +def parse_event_callback(data: str) -> Tuple[Optional[str], Optional[str], bool]: + """ + Returns: + (action, id, is_current_season) для кнопок подписки на события. + """ + m = _CB_EVT_RE.match(data) + if m: + return m.group("action"), m.group("id"), m.group("season") == SEASON + + m = _CB_EVT_LEGACY_RE.match(data) + if m: + return m.group("action"), m.group("id"), False + + return None, None, True diff --git a/tg_mainbot.py b/tg_mainbot.py index d5c7d6e..5996a5f 100644 --- a/tg_mainbot.py +++ b/tg_mainbot.py @@ -16,6 +16,16 @@ from io import BytesIO from flask import Flask, request from formatter import get_post_text, get_event_text from telegram_relay import configure_telebot_api, get_relay_url, bot_api_method_url +from season_links import ( + SEASON, + callback_subscribe_post, + callback_unsubscribe_post, + callback_subscribe_event, + callback_unsubscribe_event, + parse_start_payload, + parse_post_callback, + parse_event_callback, +) # Создаем Flask app на верхнем уровне для экспорта app = Flask(__name__) @@ -179,6 +189,7 @@ logger.info(f"Telegram Bot API через ретранслятор: {relay}") # Создаем экземпляр бота bot = telebot.TeleBot(RESPONDER_BOT_TOKEN) +logger.info(f"Сезон подписок SEASON={SEASON}") def get_user_name(user): """Форматирует имя пользователя для записи в БД""" @@ -572,6 +583,65 @@ def format_channel_link(post_id=None): else: return f"https://t.me/c/{CHANNEL_ID}" + +def get_last_channel_message_id(): + """Максимальный tg_message_id среди опубликованных постов и событий (текущая БД).""" + conn = create_db_connection() + if conn is None: + return None + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT MAX(mid) AS last_id FROM ( + SELECT MAX(tg_message_id) AS mid FROM posts + WHERE tg_message_id IS NOT NULL AND tg_message_id > 0 + UNION ALL + SELECT MAX(tg_message_id) AS mid FROM events + WHERE tg_message_id IS NOT NULL AND tg_message_id > 0 + ) t + """ + ) + row = cursor.fetchone() + last_id = row["last_id"] if row else None + return int(last_id) if last_id else None + except pymysql.Error as e: + logger.error(f"Ошибка получения последнего message_id канала: {e}") + return None + finally: + conn.close() + + +def get_return_to_channel_link(): + """Ссылка на последнее известное сообщение в канале или на сам канал.""" + last_id = get_last_channel_message_id() + if last_id: + return format_channel_link(last_id) + return format_channel_link() + + +def create_season_ended_keyboard(): + keyboard = InlineKeyboardMarkup() + keyboard.row( + InlineKeyboardButton("↩ Вернуться в канал", url=get_return_to_channel_link()) + ) + return keyboard + + +def send_season_ended_message(chat_id): + """Ответ на клик по подписке прошлого сезона (или без префикса SEASON).""" + logger.info(f"send_season_ended_message chat_id={chat_id}, current SEASON={SEASON}") + text = ( + "Этот сезон уже завершён.\n\n" + "Подписки и кнопки из прошлых анонсов больше не работают. " + "Актуальные события — в канале." + ) + bot.send_message( + chat_id, + text, + reply_markup=create_season_ended_keyboard(), + ) + def create_help_keyboard(): """Создает клавиатуру для справки""" logger.info(f"starting create_help_keyboard") @@ -601,13 +671,13 @@ def create_manage_keyboard(post_id, is_subscribed): if is_subscribed: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), - InlineKeyboardButton("❌ Отписаться", callback_data=f"unsubscribe_{post_id}"), + InlineKeyboardButton("❌ Отписаться", callback_data=callback_unsubscribe_post(post_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) else: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), - InlineKeyboardButton("✔ Подписаться", callback_data=f"subscribe_{post_id}"), + InlineKeyboardButton("✔ Подписаться", callback_data=callback_subscribe_post(post_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) return keyboard @@ -621,13 +691,13 @@ def create_manage_keyboard_evt(event_id, is_subscribed): if is_subscribed: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), - InlineKeyboardButton("❌ Отписаться", callback_data=f"unsubscribe_evt_{event_id}"), + InlineKeyboardButton("❌ Отписаться", callback_data=callback_unsubscribe_event(event_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) else: keyboard.row( InlineKeyboardButton("❓ Справка", callback_data="cmd_help"), - InlineKeyboardButton("✔ Подписаться", callback_data=f"subscribe_evt_{event_id}"), + InlineKeyboardButton("✔ Подписаться", callback_data=callback_subscribe_event(event_id)), InlineKeyboardButton("↪ Назад", url=channel_link) ) @@ -1028,18 +1098,19 @@ def handle_start(message): user_name = get_user_name(user) if len(args) > 1: - # Обработка команды подписки на посты - if args[1].startswith('post_'): - post_id = args[1].split('_')[1] - # Отправляем сообщение управления подпиской - send_management_message(message.chat.id, post_id, user_id, user_name) + kind, entity_id, is_current = parse_start_payload(args[1]) + if not is_current: + send_season_ended_message(message.chat.id) return - # Обработка команды подписки на события - elif args[1].startswith('event_'): - event_id = args[1].split('_')[1] - # Отправляем сообщение управления подпиской - send_management_message_evt(message.chat.id, event_id, user_id, user_name) + if kind == "post" and entity_id: + send_management_message(message.chat.id, entity_id, user_id, user_name) return + if kind == "event" and entity_id: + send_management_message_evt(message.chat.id, entity_id, user_id, user_name) + return + # Неизвестный payload текущего сезона + send_season_ended_message(message.chat.id) + return # Команда /start без параметров welcome_text = get_welcome_text() @@ -1312,17 +1383,26 @@ def handle_callback(call): return # Обработка кнопок управления подпиской на посты - if (call.data.startswith("subscribe_") or call.data.startswith("unsubscribe_")) and not call.data.startswith(("subscribe_evt_", "unsubscribe_evt_")): + action, post_id, is_current = parse_post_callback(call.data) + if action and post_id: + if not is_current: + bot.answer_callback_query(call.id, "Этот сезон уже завершён") + send_season_ended_message(call.message.chat.id) + return + user = call.from_user user_id = user.id user_name = get_user_name(user) - # Разделяем данные callback - parts = call.data.split('_', 1) - action = parts[0] - post_id = parts[1] + if not get_post_data(post_id): + bot.answer_callback_query(call.id, "Анонс не найден") + bot.send_message( + call.message.chat.id, + "❌ Анонс не найден в базе данных текущего сезона.", + reply_markup=create_season_ended_keyboard(), + ) + return - # Выполняем действие if action == "subscribe": save_mark_to_db(post_id, user_id, user_name) result_text = "✅ Вы успешно подписались!" @@ -1371,15 +1451,26 @@ def handle_callback(call): return # Обработка кнопок управления подпиской на события - if call.data.startswith("subscribe_evt_") or call.data.startswith("unsubscribe_evt_"): + action, event_id, is_current = parse_event_callback(call.data) + if action and event_id: + if not is_current: + bot.answer_callback_query(call.id, "Этот сезон уже завершён") + send_season_ended_message(call.message.chat.id) + return + user = call.from_user user_id = user.id user_name = get_user_name(user) - parts = call.data.split('_', 2) - action = parts[0] - event_id = parts[2] - # Выполняем действие + if not get_event_data(event_id): + bot.answer_callback_query(call.id, "Событие не найдено") + bot.send_message( + call.message.chat.id, + "❌ Событие не найдено в базе данных текущего сезона.", + reply_markup=create_season_ended_keyboard(), + ) + return + if action == "subscribe": save_mark_to_db_evt(event_id, user_id, user_name) result_text = "✅ Вы успешно подписались!" @@ -1387,22 +1478,14 @@ def handle_callback(call): remove_mark_from_db_evt(event_id, user_id, user_name) result_text = "✅ Вы успешно отписались!" - # Обновляем сообщение с новым статусом try: - # Получаем текущие данные о событии event_data = format_event_message(event_id) text = event_data['text'] - # Создаем новую клавиатуру is_subscribed = action == "subscribe" keyboard = create_manage_keyboard_evt(event_id, is_subscribed) - # Для сообщений с изображением if call.message.content_type == 'photo': - # Получаем file_id существующего изображения - file_id = call.message.photo[-1].file_id - - # Редактируем подпись к изображению bot.edit_message_caption( chat_id=call.message.chat.id, message_id=call.message.message_id, @@ -1411,7 +1494,6 @@ def handle_callback(call): parse_mode='HTML' ) else: - # Редактируем текстовое сообщение bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.message_id, @@ -1420,7 +1502,6 @@ def handle_callback(call): parse_mode='HTML' ) - # Отправляем отдельное сообщение о результате bot.answer_callback_query(call.id, result_text) except Exception as e: logger.error(f"Ошибка обновления сообщения (events): {e}") diff --git a/tg_publish.py b/tg_publish.py index c2112dc..b6ac609 100644 --- a/tg_publish.py +++ b/tg_publish.py @@ -17,6 +17,7 @@ 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 season_links import subscription_start_link # Настройка anyio для правильной работы в отдельном потоке # Устанавливаем правильный backend для anyio (используем asyncio) @@ -232,7 +233,9 @@ async def publish_to_tg(vk_post_id): # Ссылка на подписку (только для событий и если включен бот подписки) if post['is_event'] and USE_SUBSCRIPTION_BOT: - links.append(f'🔜 Подписка') + links.append( + f'🔜 Подписка' + ) # Формируем строку ссылок links_line = " | ".join(links) if links else "" @@ -419,7 +422,9 @@ async def publish_to_tg(vk_post_id): updated_links.append(f'Оригинал в ВК') if post['is_event'] and USE_SUBSCRIPTION_BOT: - updated_links.append(f'🔜 Подписка') + updated_links.append( + f'🔜 Подписка' + ) links_line = " | ".join(updated_links) final_text = f"{text}\n\n{links_line}" diff --git a/volk_telegram_testing.py b/volk_telegram_testing.py index 1e226db..8e892b7 100644 --- a/volk_telegram_testing.py +++ b/volk_telegram_testing.py @@ -8,6 +8,7 @@ import json from datetime import datetime from dotenv import load_dotenv from telegram_relay import bot_api_method_url, get_relay_url +from season_links import subscription_start_link # Загрузка переменных окружения load_dotenv() @@ -94,7 +95,11 @@ async def test_telegram_publish(): # Формируем строку ссылок placeholder_message_id = '1234567890' if USE_SUBSCRIPTION_BOT: - links_line = f'🔎 Инфо | 📌 Иду | 🔜 Подписка' + links_line = ( + f'🔎 Инфо | ' + f'📌 Иду | ' + f'🔜 Подписка' + ) else: links_line = f'🔎 Инфо'