import logging
import os
import aiohttp
from dotenv import load_dotenv
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.ext import Application, CommandHandler, ContextTypes
load_dotenv()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
log = logging.getLogger("twitch-notify")
# --- Конфигурация из .env ---
TELEGRAM_TOKEN = os.environ["TELEGRAM_TOKEN"]
TWITCH_LOGIN = os.getenv("TWITCH_LOGIN", "theraiwy") # ник стримера на твиче
# ID чата/канала для оповещений: @username канала или числовой id (напр. -1001234567890)
NOTIFY_CHAT_ID = os.environ["NOTIFY_CHAT_ID"]
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60")) # секунды между проверками
# --- Ссылки для кнопок ---
TWITCH_URL = f"https://twitch.tv/{TWITCH_LOGIN}"
LINKS_URL = "https://links.theraiwy.top"
def _chat_id(raw: str) -> int | str:
"""Числовой id приводим к int, @username оставляем строкой."""
raw = raw.strip()
try:
return int(raw)
except ValueError:
return raw
class TwitchStatus:
"""Определение статуса стрима через публичный decapi.me — БЕЗ токенов."""
BASE = "https://decapi.me/twitch"
def __init__(self, login: str):
self.login = login
self._session: aiohttp.ClientSession | None = None
async def _ensure_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def _get_text(self, endpoint: str) -> str:
session = await self._ensure_session()
url = f"{self.BASE}/{endpoint}/{self.login}"
async with session.get(url) as resp:
resp.raise_for_status()
return (await resp.text()).strip()
async def get_stream(self) -> dict | None:
"""Возвращает данные стрима, если стример в эфире, иначе None."""
uptime = await self._get_text("uptime")
# decapi возвращает "... is offline" когда стрим не идёт
if "offline" in uptime.lower():
return None
title = await self._get_text("title")
game = await self._get_text("game")
thumb = (
f"https://static-cdn.jtvnw.net/previews-ttv/"
f"live_user_{self.login.lower()}-1280x720.jpg"
)
return {
"user_name": self.login,
"title": title or "Без названия",
"game_name": game or "—",
"thumbnail_url": thumb,
"uptime": uptime,
}
async def close(self):
if self._session and not self._session.closed:
await self._session.close()
def build_keyboard() -> InlineKeyboardMarkup:
"""Inline-клавиатура с кнопками-ссылками."""
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton("🟣 Смотреть на Twitch", url=TWITCH_URL),
InlineKeyboardButton("🔗 Все ссылки", url=LINKS_URL),
]
]
)
def build_caption(stream: dict, test: bool = False) -> str:
"""Собирает текст-подпись (HTML) для оповещения о стриме."""
title = stream.get("title", "Без названия")
game = stream.get("game_name", "—")
user_name = stream.get("user_name", TWITCH_LOGIN)
prefix = "🧪 [ТЕСТ] " if test else ""
lines = [
f"{prefix}🔴 {_esc(user_name)} сейчас в эфире!",
"",
f"{_esc(title)}",
f"🎮 {_esc(game)}",
]
return "\n".join(lines)
def _esc(text: str) -> str:
"""Экранирование под HTML parse mode Telegram."""
return text.replace("&", "&").replace("<", "<").replace(">", ">")
async def send_announce(bot, stream: dict, test: bool = False):
"""Отправляет оповещение о стриме в целевой чат."""
caption = build_caption(stream, test=test)
thumb = stream.get("thumbnail_url", "")
chat_id = _chat_id(NOTIFY_CHAT_ID)
try:
if thumb:
await bot.send_photo(
chat_id=chat_id,
photo=thumb,
caption=caption,
parse_mode=ParseMode.HTML,
reply_markup=build_keyboard(),
)
else:
raise ValueError("no thumbnail")
except Exception:
# Превью может быть недоступно/невалидно — падаем в обычное сообщение
await bot.send_message(
chat_id=chat_id,
text=caption,
parse_mode=ParseMode.HTML,
reply_markup=build_keyboard(),
disable_web_page_preview=False,
)
log.info("Отправлено оповещение о стриме: %s", stream.get("title"))
async def check_stream(context: ContextTypes.DEFAULT_TYPE):
"""Периодическая задача: опрос статуса и оповещение при выходе в эфир."""
twitch: TwitchStatus = context.bot_data["twitch"]
try:
stream = await twitch.get_stream()
except Exception:
log.exception("Ошибка при запросе статуса стрима")
return
is_live = stream is not None
was_live = context.bot_data.get("was_live", False)
# Оповещаем только при переходе оффлайн -> онлайн
if is_live and not was_live:
await send_announce(context.bot, stream)
context.bot_data["was_live"] = is_live
async def stream_test(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Отправить тестовое оповещение о стриме."""
fake_stream = {
"user_name": TWITCH_LOGIN,
"title": "🧪 Тестовое оповещение — проверка бота",
"game_name": "Just Chatting",
"thumbnail_url": (
f"https://static-cdn.jtvnw.net/previews-ttv/"
f"live_user_{TWITCH_LOGIN.lower()}-1280x720.jpg"
),
}
await send_announce(context.bot, fake_stream, test=True)
log.info("Отправлено тестовое оповещение по команде /stream_test")
async def on_startup(app: Application):
log.info("Бот запущен, слежу за стримером: %s", TWITCH_LOGIN)
async def on_shutdown(app: Application):
twitch: TwitchStatus = app.bot_data.get("twitch")
if twitch:
await twitch.close()
def main():
app = Application.builder().token(TELEGRAM_TOKEN).post_init(on_startup).post_shutdown(on_shutdown).build()
app.bot_data["twitch"] = TwitchStatus(TWITCH_LOGIN)
app.bot_data["was_live"] = False
app.add_handler(CommandHandler("stream_test", stream_test))
app.job_queue.run_repeating(check_stream, interval=CHECK_INTERVAL, first=5)
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()