Перейти к содержимому

Twitch Discord Webhook: уведомления о стримах и видео YouTube

Уведомления в Discord о старте стрима на Twitch и новых видео YouTube: twitch discord webhook через EventSub на Node, RSS-поллер на Python, embed с кнопкой.

6 мин чтения Доступно также на English
На этой странице

Ни Twitch, ни YouTube не умеют сами отправлять POST в вебхук Discord. Twitch EventSub шлёт свой JSON на ваш HTTPS-эндпоинт, YouTube отдаёт RSS-ленту для каждого канала, а Discord ждёт вполне конкретное тело сообщения на https://discord.com/api/webhooks/{id}/{token}. Между ними нужна прослойка, которая переводит одно в другое.

В этой статье — три реальных способа её собрать: полный поллер RSS-ленты YouTube на Python, ретранслятор Twitch EventSub на Node и вариант без кода через Zapier, IFTTT или n8n. Во всех трёх на выходе один и тот же embed: превью, пинг роли и кнопка-ссылка «Смотреть», которая работает на обычном вебхуке без бота.

Три способа получать уведомления о стримах в Discord

СпособЗадержкаЧто хоститьДля чего
Twitch EventSub → ваш скрипт → вебхукСекундыПубличный HTTPS-эндпоинтСтарт стрима на Twitch с названием, категорией и превью
RSS-лента YouTube → скрипт по расписаниюИнтервал опросаCron на любой постоянно работающей машинеНовые видео без единого API-ключа
Облачные Zapier / IFTTT / n8nМинутыНичегоНоль кода, пара уведомлений в день

EventSub работает по push-модели: Twitch дёргает ваш URL в момент старта стрима, но нужен сервер с валидным сертификатом и проверка подписи. Опрос RSS — самое простое, что работает для YouTube: без проекта в Google API, без OAuth, только HTTP GET раз в несколько минут и файл, где записано, какие id видео уже анонсированы. No-code-сервисы хороши ровно до момента, когда захочется свой embed, защиту от дублей или кнопку.

Какой бы путь вы ни выбрали, сторона Discord одинакова, поэтому начните с неё.

Сначала спроектируйте embed

Вот payload, который собирают оба скрипта ниже. Вставьте его в визуальный конструктор на discord-webhook.com или отправьте через curl, чтобы увидеть, как он выглядит:

{
  "username": "Stream Alerts",
  "content": "<@&123456789012345678> 🔴 **Nova** в эфире!",
  "allowed_mentions": { "roles": ["123456789012345678"] },
  "embeds": [
    {
      "title": "Elden Ring any% — идём на саб-30",
      "url": "https://www.twitch.tv/nova",
      "description": "Играет в **Elden Ring**",
      "color": 9520895,
      "image": {
        "url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_nova-1280x720.jpg?t=1789668000"
      },
      "fields": [{ "name": "Начало", "value": "<t:1789668000:R>", "inline": true }],
      "footer": { "text": "twitch.tv" },
      "timestamp": "2026-09-17T18:00:00.000Z"
    }
  ],
  "components": [
    {
      "type": 1,
      "components": [
        {
          "type": 2,
          "style": 5,
          "label": "Смотреть на Twitch",
          "url": "https://www.twitch.tv/nova"
        }
      ]
    }
  ]
}
curl -H "Content-Type: application/json" -d @payload.json \
  "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN?with_components=true"

На что обратить внимание:

  • image и thumbnail. image рисуется на всю ширину под текстом, thumbnail — маленький квадрат справа. Для превью стрима или обложки видео берите image, а thumbnail оставьте под аватар канала. Обоим нужен публичный https-URL.
  • Кнопка «Смотреть» — это type: 2, style: 5, с url и без custom_id, внутри action row (type: 1). Discord учитывает components, только если в URL запроса есть ?with_components=true; без него тот же JSON примется, но сообщение придёт без кнопки. Обычный вебхук может слать кнопки-ссылки и другие неинтерактивные компоненты; кнопки, которые что-то делают внутри Discord, требуют бота — именно его даёт интеграция с ботом на сайте.
  • Пинг роли <@&roleId> сработает, только если этот id перечислен в allowed_mentions.roles. Подробнее — в статье про упоминания и пинги.
  • <t:UNIX:R> показывает «3 минуты назад» в часовом поясе каждого читателя; timestamp в embed должен быть в ISO 8601.
  • Цвета — десятичные числа: фиолетовый Twitch #9146FF — это 9520895, красный YouTube #FF0000 — 16711680.
  • Параметр ?t= против кеша. Discord кеширует картинки embed по URL, а Twitch отдаёт превью каждого стрима по одному и тому же адресу. Без меняющегося query-параметра анонс покажет кадр с прошлой недели.

YouTube: поллер RSS-ленты канала на Python

У каждого канала YouTube есть Atom-лента по адресу https://www.youtube.com/feeds/videos.xml?channel_id=UC.... Идентификатор UC... берётся на вкладке «О канале» (Поделиться → Копировать ID канала) или из исходника страницы по слову channelId. В ленте есть последние загрузки с названием, ссылкой, временем публикации и media:thumbnail — всё, что нужно для embed.

Поллер хранит JSON-файл с id уже анонсированных видео, при первом запуске просто заполняет его, чтобы не завалить канал старыми роликами, и уважает retry_after при 429. Используется requests; детали HTTP, обработка ошибок и async-вариант — в гайде вебхуки Discord на Python.

#!/usr/bin/env python3
import json
import os
import time
import xml.etree.ElementTree as ET
from pathlib import Path

import requests

CHANNEL_ID = os.environ["YT_CHANNEL_ID"]
WEBHOOK_URL = os.environ["DISCORD_WEBHOOK_URL"]   # https://discord.com/api/webhooks/{id}/{token}
STATE_FILE = Path("seen_videos.json")
FEED_URL = f"https://www.youtube.com/feeds/videos.xml?channel_id={CHANNEL_ID}"

NS = {
    "atom": "http://www.w3.org/2005/Atom",
    "yt": "http://www.youtube.com/xml/schemas/2015",
    "media": "http://search.yahoo.com/mrss/",
}


def fetch_entries() -> list[dict]:
    resp = requests.get(FEED_URL, timeout=15)
    resp.raise_for_status()
    root = ET.fromstring(resp.content)
    entries = []
    for entry in root.findall("atom:entry", NS):
        group = entry.find("media:group", NS)
        thumb = group.find("media:thumbnail", NS) if group is not None else None
        entries.append({
            "id": entry.findtext("yt:videoId", namespaces=NS),
            "title": entry.findtext("atom:title", namespaces=NS) or "Новое видео",
            "url": entry.find("atom:link", NS).get("href"),
            "author": entry.findtext("atom:author/atom:name", namespaces=NS) or "YouTube",
            "published": entry.findtext("atom:published", namespaces=NS),
            "thumbnail": thumb.get("url") if thumb is not None else None,
        })
    return entries


def build_payload(video: dict) -> dict:
    embed = {
        "title": video["title"][:256],
        "url": video["url"],
        "color": 16711680,
        "author": {"name": video["author"], "url": f"https://www.youtube.com/channel/{CHANNEL_ID}"},
        "footer": {"text": "youtube.com"},
        "timestamp": video["published"],
    }
    if video["thumbnail"]:
        embed["image"] = {"url": video["thumbnail"]}
    return {
        "username": "YouTube Alerts",
        "content": f"📺 Новое видео от **{video['author']}**",
        "embeds": [embed],
        "components": [{
            "type": 1,
            "components": [{"type": 2, "style": 5, "label": "Смотреть", "url": video["url"]}],
        }],
    }


def send(payload: dict) -> None:
    while True:
        resp = requests.post(WEBHOOK_URL, params={"with_components": "true"}, json=payload, timeout=15)
        if resp.status_code == 429:
            time.sleep(resp.json().get("retry_after", 1))
            continue
        if resp.status_code >= 400:
            raise RuntimeError(f"Discord вернул {resp.status_code}: {resp.text}")
        return


def main() -> None:
    seen = set(json.loads(STATE_FILE.read_text())) if STATE_FILE.exists() else set()
    first_run = not seen
    for video in reversed(fetch_entries()):   # от старых к новым, чтобы в канале был порядок
        if video["id"] in seen:
            continue
        if not first_run:
            send(build_payload(video))
        seen.add(video["id"])
    STATE_FILE.write_text(json.dumps(sorted(seen)))


if __name__ == "__main__":
    main()

Запустите один раз вручную, чтобы заполнить файл состояния, затем поставьте в расписание:

export YT_CHANNEL_ID=UCxxxxxxxxxxxxxxxxxxxxxx
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
python3 yt_poller.py

crontab -e   # и добавьте строку с запуском каждые 5 минут:
*/5 * * * * cd /opt/yt-alerts && /usr/bin/python3 yt_poller.py >> poller.log 2>&1

Две особенности ленты. В ней только последние загрузки, поэтому опрашивайте достаточно часто, чтобы активный канал не успел выложить больше между двумя запусками. Кроме того, трансляции и премьеры тоже попадают в ленту как записи; если нужны только загрузки — фильтруйте по названию. Если опрос слишком медленный, YouTube поддерживает push-уведомления WebSub для того же URL ленты, и получится схема как у Twitch ниже.

Twitch: ретранслятор EventSub на Node

EventSub с транспортом webhook устроен так: вы регистрируете приложение, создаёте подписку stream.online на конкретного стримера, и Twitch делает POST на ваш HTTPS-коллбэк. Первый запрос — проверочный challenge, который нужно вернуть обратно как обычный текст; каждое следующее событие подписано HMAC-SHA256 по id сообщения, timestamp и сырому телу. Эндпоинт проверяет подпись, быстро отвечает 2xx, забирает из Helix название и категорию стрима и отправляет embed.

// twitch-alerts.js — Node 18+, npm install express
const express = require('express');
const crypto = require('node:crypto');

const {
  TWITCH_CLIENT_ID,
  TWITCH_APP_TOKEN, // app access token (client credentials grant)
  TWITCH_EVENTSUB_SECRET, // секрет, который вы передаёте при создании подписки
  DISCORD_WEBHOOK_URL,
} = process.env;

const app = express();
const seenMessages = new Set(); // Twitch повторяет доставку; отсекаем дубли по id сообщения

function verifySignature(req) {
  const id = req.header('Twitch-Eventsub-Message-Id') ?? '';
  const ts = req.header('Twitch-Eventsub-Message-Timestamp') ?? '';
  const sig = req.header('Twitch-Eventsub-Message-Signature') ?? '';
  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', TWITCH_EVENTSUB_SECRET)
      .update(id + ts)
      .update(req.body) // сырой Buffer, см. express.raw ниже
      .digest('hex');
  return (
    sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  );
}

async function fetchStream(userId) {
  const res = await fetch(`https://api.twitch.tv/helix/streams?user_id=${userId}`, {
    headers: { 'Client-Id': TWITCH_CLIENT_ID, Authorization: `Bearer ${TWITCH_APP_TOKEN}` },
  });
  const { data } = await res.json();
  return data?.[0] ?? null; // null, если стрим уже закончился
}

async function postGoLive(event) {
  const stream = await fetchStream(event.broadcaster_user_id);
  const login = event.broadcaster_user_login;
  const channelUrl = `https://www.twitch.tv/${login}`;
  const preview = `https://static-cdn.jtvnw.net/previews-ttv/live_user_${login}-1280x720.jpg?t=${Date.now()}`;

  const payload = {
    username: 'Stream Alerts',
    content: `🔴 **${event.broadcaster_user_name}** в эфире!`,
    embeds: [
      {
        title: (stream?.title || 'Стрим начался').slice(0, 256),
        url: channelUrl,
        description: stream?.game_name ? `Играет в **${stream.game_name}**` : undefined,
        color: 9520895,
        image: { url: preview },
        fields: [
          {
            name: 'Начало',
            value: `<t:${Math.floor(Date.parse(event.started_at) / 1000)}:R>`,
            inline: true,
          },
        ],
        footer: { text: 'twitch.tv' },
        timestamp: event.started_at,
      },
    ],
    components: [
      {
        type: 1,
        components: [{ type: 2, style: 5, label: 'Смотреть на Twitch', url: channelUrl }],
      },
    ],
  };

  const res = await fetch(`${DISCORD_WEBHOOK_URL}?with_components=true`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`Discord ${res.status}: ${await res.text()}`);
}

app.post('/twitch/eventsub', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifySignature(req)) return res.sendStatus(403);

  const body = JSON.parse(req.body);
  const type = req.header('Twitch-Eventsub-Message-Type');

  if (type === 'webhook_callback_verification') {
    return res.type('text/plain').send(body.challenge); // подтверждаем подписку
  }
  if (type === 'revocation') {
    console.warn('Подписка отозвана:', body.subscription.status);
    return res.sendStatus(204);
  }

  const id = req.header('Twitch-Eventsub-Message-Id');
  if (seenMessages.has(id)) return res.sendStatus(204);
  seenMessages.add(id);
  res.sendStatus(204); // сначала отвечаем Twitch, потом делаем медленную часть

  try {
    if (body.subscription.type === 'stream.online') await postGoLive(body.event);
  } catch (err) {
    console.error(err);
  }
});

app.listen(8080);

Разворачивайте это за reverse proxy с настоящим сертификатом: Twitch вызывает только https://-коллбэки на порту 443. Для локальной разработки пробросьте порт 8080 через туннель. Получите app access token по client credentials на https://id.twitch.tv/oauth2/token, узнайте id стримера через GET https://api.twitch.tv/helix/users?login=nova и создайте подписку:

curl -X POST "https://api.twitch.tv/helix/eventsub/subscriptions" \
  -H "Client-Id: $TWITCH_CLIENT_ID" \
  -H "Authorization: Bearer $TWITCH_APP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "stream.online",
    "version": "1",
    "condition": { "broadcaster_user_id": "141981764" },
    "transport": {
      "method": "webhook",
      "callback": "https://alerts.example.com/twitch/eventsub",
      "secret": "'"$TWITCH_EVENTSUB_SECRET"'"
    }
  }'

Подписка висит в статусе webhook_callback_verification_pending, пока эндпоинт не вернёт challenge, после чего переходит в enabled. Twitch CLI умеет отправить на ваш URL поддельный stream.online, подписанный вашим секретом, — так можно проверить всю цепочку, не дожидаясь настоящего стрима.

Два полезных расширения. Отправляйте с ?wait=true, сохраняйте полученный id, подпишитесь заодно на stream.offline и делайте PATCH .../messages/{message_id}, когда стрим закончился, — анонс превратится в «стрим завершён» вместо того, чтобы устареть. А если публичный URL светить не хочется, у EventSub есть транспорт WebSocket, где ваш скрипт сам подключается к Twitch; сторона Discord не меняется.

Без кода: Zapier, IFTTT и n8n

Если нужна пара уведомлений в день и никакой особой логики, код можно пропустить:

  • Zapier: триггер Twitch или YouTube, затем действие «Webhooks by Zapier» с POST на URL Discord, заголовком Content-Type: application/json и JSON-телом из полей триггера. Если добавляете кнопку, допишите к URL ?with_components=true.
  • IFTTT: триггер YouTube «новое публичное видео» плюс действие Webhooks «make a web request» с тем же JSON-телом.
  • n8n: нода RSS Feed Trigger с URL ленты YouTube, затем нода HTTP Request, отправляющая embed. n8n можно хостить у себя, так что это ближайший no-code-аналог Python-поллера.

Рабочие примеры и компромиссы — в статье вебхуки Discord через Zapier, Make и n8n.

Частые ошибки

  • Сообщение пришло без кнопки или вернулся 400. Нет кнопки: запрос ушёл без ?with_components=true, и Discord проигнорировал components. 400: у кнопки есть custom_id и стиль не 5. Обычный вебхук принимает только кнопки-ссылки.
  • Embed без картинки или со старой картинкой. URL не публичный https, либо Discord закешировал предыдущую загрузку по тому же адресу. Откройте URL в приватном окне браузера и добавьте меняющийся query-параметр, как в примерах.
  • {"message":"Unknown Webhook","code":10015}. Неверный id или токен, либо вебхук удалили. Создайте новый в «Настройки сервера → Интеграции → Вебхуки» и обновите переменные окружения.
  • 429 Too Many Requests. Прочитайте retry_after из тела ответа и подождите столько перед повтором — так делает send() в Python. Всплески уведомлений — как раз тот случай, когда это бьёт; см. лимиты вебхуков.
  • Анонс стрима приходит дважды. EventSub повторяет доставку, если не получил 2xx, поэтому отсекайте дубли по Twitch-Eventsub-Message-Id. У RSS-поллера дубли значат, что файл состояния не сохраняется — типично для контейнера без примонтированного тома.
  • Подписка Twitch не становится enabled. Коллбэк недоступен по https, challenge вернули как JSON вместо plain text, либо секрет в окружении отличается от того, что указан в подписке.
  • 400 из-за username. В переопределённом имени нельзя использовать «clyde» и «discord», поэтому «Discord Alerts» не пройдёт, а «Stream Alerts» — пройдёт.

FAQ

Может ли Twitch слать уведомления о старте стрима прямо в вебхук Discord?

Нет. EventSub отправляет JSON в формате Twitch и проверяет ваш эндпоинт через challenge, а вебхук Discord ждёт тело сообщения. Нужен небольшой ретранслятор, который принимает событие Twitch и собирает payload для Discord, либо no-code-сервис, делающий то же самое.

Насколько быстро приходят уведомления YouTube через RSS?

Задержка равна интервалу опроса плюс время, за которое YouTube обновляет ленту, — обычно несколько минут после загрузки. Если нужно почти мгновенно, подпишитесь на ту же ленту через WebSub и обрабатывайте push так же, как ретранслятор Twitch.

Можно ли добавить кнопку «Смотреть» без бота?

Да. Кнопка-ссылка (type: 2, style: 5, url) — неинтерактивный компонент, поэтому обычный вебхук может её отправить, если в URL есть ?with_components=true. Бот нужен только кнопкам, которые запускают действия внутри Discord.

Может ли анонс сам обновиться, когда стрим закончился?

Да. Отправьте с ?wait=true, чтобы получить id сообщения, сохраните его, а по stream.offline сделайте PATCH https://discord.com/api/webhooks/{id}/{token}/messages/{message_id} с новым содержимым. Подробности — в статье про редактирование и удаление сообщений вебхука.

Что дальше

Соберите embed один раз в конструкторе Discord Webhook, экспортируйте как JSON или Python и вставьте в тот ретранслятор, который подходит вашей инфраструктуре. Та же схема работает для любого другого источника событий: смотрите паттерны уведомлений через вебхуки, гайд по вебхукам на Python с деталями HTTP и Zapier, Make и n8n, если хостить ничего не хочется.

Теги: twitch discord webhookyoutube discord notification webhookвебхук discordуведомления twitchуведомления youtubetwitch eventsubrss youtubediscord embed

Похожие статьи

Все статьи

Соберите это в визуальном редакторе

Embed, Components V2, кнопки и опросы с живым предпросмотром Discord. Бесплатно, без регистрации.