Twitch stream notify bot for Telegram with Docker
Telegram port of the Discord notifier: tokenless decapi.me status checks, photo+caption announce with inline link buttons, /stream_test command. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Токен Telegram-бота (@BotFather -> /newbot) — единственный обязательный секрет
|
||||||
|
TELEGRAM_TOKEN=your_telegram_bot_token
|
||||||
|
|
||||||
|
# Ник стримера на Twitch (то, что в URL: twitch.tv/ЭТО). По умолчанию theraiwy
|
||||||
|
TWITCH_LOGIN=theraiwy
|
||||||
|
|
||||||
|
# Куда слать оповещения: @username канала (напр. @my_channel)
|
||||||
|
# или числовой id чата/группы (напр. -1001234567890)
|
||||||
|
NOTIFY_CHAT_ID=@your_channel
|
||||||
|
|
||||||
|
# Интервал проверки в секундах (по умолчанию 60)
|
||||||
|
CHECK_INTERVAL=60
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Секреты — НИКОГДА не коммитить
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Не создавать .pyc и не буферизовать stdout (чтобы логи шли в реальном времени)
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Сначала зависимости — для кэша слоёв
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Затем код
|
||||||
|
COPY bot.py .
|
||||||
|
|
||||||
|
# Запуск от непривилегированного пользователя
|
||||||
|
RUN useradd --create-home appuser
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
CMD ["python", "bot.py"]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Twitch → Telegram Stream Notify Bot
|
||||||
|
|
||||||
|
Бот проверяет, вышел ли стример в эфир на Twitch, и при старте стрима
|
||||||
|
отправляет оповещение в указанный чат/канал Telegram.
|
||||||
|
Определение статуса работает через публичный [decapi.me](https://decapi.me) — **без Twitch-токенов**.
|
||||||
|
|
||||||
|
Это Telegram-версия [Discord-бота](../twitch_discord).
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Определение статуса стрима **без токенов Twitch** (публичный decapi.me)
|
||||||
|
- Оповещение **один раз** при переходе оффлайн → онлайн (без спама, пока стрим идёт)
|
||||||
|
- Сообщение с превью эфира, названием стрима и игрой
|
||||||
|
- Inline-кнопки под сообщением: **Twitch** и **Все ссылки**
|
||||||
|
- Команда `/stream_test` — тестовое оповещение
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `TELEGRAM_TOKEN` | ✅ | Токен бота из [@BotFather](https://t.me/BotFather) → `/newbot` |
|
||||||
|
| `NOTIFY_CHAT_ID` | ✅ | Куда слать: `@username` канала или числовой id чата/группы (напр. `-1001234567890`) |
|
||||||
|
| `TWITCH_LOGIN` | нет | Ник стримера из URL `twitch.tv/ЭТО`. По умолчанию `theraiwy` |
|
||||||
|
| `CHECK_INTERVAL` | нет | Интервал проверки в секундах. По умолчанию `60` |
|
||||||
|
|
||||||
|
Единственный обязательный секрет — `TELEGRAM_TOKEN`.
|
||||||
|
|
||||||
|
## Настройка Telegram-бота
|
||||||
|
|
||||||
|
1. Создайте бота у [@BotFather](https://t.me/BotFather) командой `/newbot`, получите токен.
|
||||||
|
2. **Добавьте бота в канал/группу как администратора** с правом отправлять сообщения —
|
||||||
|
иначе он не сможет писать в `NOTIFY_CHAT_ID`.
|
||||||
|
3. Укажите `NOTIFY_CHAT_ID`:
|
||||||
|
- для публичного канала — его `@username` (напр. `@theraiwy_streams`);
|
||||||
|
- для приватной группы/канала — числовой id (напр. через `@userinfobot` или
|
||||||
|
`@getidsbot`), для супергрупп/каналов он вида `-100...`.
|
||||||
|
|
||||||
|
### Как узнать числовой id чата
|
||||||
|
|
||||||
|
Добавьте бота в чат, напишите там любое сообщение и загляните в логи бота, либо
|
||||||
|
воспользуйтесь ботом `@getidsbot`.
|
||||||
|
|
||||||
|
## Запуск через Docker (рекомендуется)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # заполнить TELEGRAM_TOKEN и NOTIFY_CHAT_ID
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Логи: `docker compose logs -f`
|
||||||
|
|
||||||
|
### Деплой в DockHand
|
||||||
|
|
||||||
|
`docker-compose.yml` читает значения из окружения через `${...}`, поэтому `.env`
|
||||||
|
в репозиторий не коммитится. В панели DockHand при создании стека задайте переменные
|
||||||
|
в разделе **Environment**:
|
||||||
|
|
||||||
|
```
|
||||||
|
TELEGRAM_TOKEN=твой_токен
|
||||||
|
NOTIFY_CHAT_ID=@your_channel
|
||||||
|
TWITCH_LOGIN=theraiwy
|
||||||
|
CHECK_INTERVAL=60
|
||||||
|
```
|
||||||
|
|
||||||
|
Обязательны только `TELEGRAM_TOKEN` и `NOTIFY_CHAT_ID` — у остальных есть значения по умолчанию.
|
||||||
|
|
||||||
|
## Запуск без Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # заполнить значения
|
||||||
|
python bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
- `/stream_test` — отправить тестовое оповещение в целевой чат (помечается `🧪 [ТЕСТ]`).
|
||||||
|
|
||||||
|
## Как определяется стрим
|
||||||
|
|
||||||
|
Каждые `CHECK_INTERVAL` секунд бот опрашивает decapi.me (`uptime`, `title`, `game`).
|
||||||
|
При переходе оффлайн → онлайн отправляется оповещение. Публичный сервис может изредка
|
||||||
|
быть недоступен — в этом случае проверка пропускается и повторяется в следующем цикле,
|
||||||
|
бот не падает. Рекомендуемый интервал — **30–60 секунд**.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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}🔴 <b>{_esc(user_name)}</b> сейчас в эфире!",
|
||||||
|
"",
|
||||||
|
f"<a href=\"{TWITCH_URL}\">{_esc(title)}</a>",
|
||||||
|
f"🎮 <b>{_esc(game)}</b>",
|
||||||
|
]
|
||||||
|
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()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
twitch-notify-tg:
|
||||||
|
build: .
|
||||||
|
container_name: twitch-notify-tg
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Значения подставляются из окружения стека (панель DockHand / .env рядом с compose)
|
||||||
|
TELEGRAM_TOKEN: ${TELEGRAM_TOKEN}
|
||||||
|
NOTIFY_CHAT_ID: ${NOTIFY_CHAT_ID}
|
||||||
|
TWITCH_LOGIN: ${TWITCH_LOGIN:-theraiwy}
|
||||||
|
CHECK_INTERVAL: ${CHECK_INTERVAL:-60}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
python-telegram-bot[job-queue]>=21.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
Reference in New Issue
Block a user