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'🔎 Инфо'