Discord называет «вебхуками» две вещи, которые ведут себя совершенно по-разному. Одна — это URL по принципу «отправил и забыл» для публикации сообщений. Другая — endpoint interactions, который получает подписанные POST-запросы всякий раз, когда пользователь вызывает slash-команду. Первая работает на localhost. Вторая — никогда. Это руководство о второй, потому что именно ей нужен туннель.
Две разновидности вебхуков Discord
Исходящие вебхуки — это URL, на которые вы делаете POST. Вы отправляете JSON-сообщение, оно появляется в канале. Туннель не нужен, потому что трафик идёт с вашей машины наружу.
Endpoint-ы interactions — обратное направление. Вы регистрируете URL в Discord; Discord делает POST на него, когда пользователь вызывает /вашу-команду. Endpoint должен быть HTTPS, отвечать в течение трёх секунд и проверять подпись Ed25519 на каждом запросе. Localhost сам по себе ничего из этого не умеет.
Почему endpoint-ы interactions строже большинства
Discord хочет не просто HTTPS — он требует доказать, что вы контролируете endpoint, прежде чем принять URL. Проверка происходит при регистрации: Discord шлёт interaction PING, ваш сервер должен ответить подписанным PONG. Если проверка Ed25519 не пройдёт хоть раз, Discord откажется сохранять URL.
С первого раза часть с Ed25519 вы не настроите. Почти ни у кого не получается.
Проверка Ed25519 — та самая болезненная часть
Discord использует Ed25519 (не HMAC SHA-256) и присылает два заголовка на каждой interaction:
X-Signature-Ed25519— подпись в hexX-Signature-Timestamp— временная метка в секундах
Вы подписываете timestamp + rawBody публичным ключом, который Discord показывает в портале разработчика. В большинстве языков для этого есть библиотека. В Node:
import nacl from 'tweetnacl';
const valid = nacl.sign.detached.verify(
Buffer.from(timestamp + rawBody),
Buffer.from(signature, 'hex'),
Buffer.from(PUBLIC_KEY, 'hex'),
);
Причина номер один провала проверки — снова — middleware, который парсит тело до того, как выполнится этот код. Вам нужна сырая строка, которую прислал Discord, а не пересобранная версия распарсенного объекта.
Пошагово: endpoint interactions на localhost
- Запустите HTTP-сервер вашего бота локально (любой порт).
- Выполните
npx portpreview 3000, чтобы получить публичный HTTPS-адрес. - В портале разработчика Discord откройте приложение и вставьте URL туннеля вместе с путём interactions в поле Interactions Endpoint URL.
- Нажмите Save. Discord сразу пошлёт
PING. Если проверка Ed25519 работает, URL сохранится. Если нет, поле покажет ошибку. - После сохранения вызовите slash-команду на любом сервере, где есть ваш бот. Запрос придёт в ваш обработчик.
Если сохранение раз за разом не удаётся, изучите захваченный запрос — заголовки, тело и ответ вашего сервера. В девяти случаях из десяти тело было незаметно изменено JSON-middleware до проверки.
Дедлайн в три секунды
Discord требует ответ менее чем за три секунды, иначе считает interaction провалённой. Для всего, что дольше, отправьте первый ответ типа 5 (deferred), а затем PATCH-ом обновите исходную interaction настоящим ответом позже. Локальное тестирование сразу показывает, какие обработчики слишком медленные — захват запросов отображает точную задержку.
Исходящие вебхуки всё ещё полезны для тестов
Если нужно просто отправлять сообщения в канал из локального кода, URL исходящего вебхука работает без туннеля. POST JSON — сообщение появилось. Мы используем это для алертов из логов локальных прогонов.
Когда эта схема перестаёт хватать
Для одного бота с горсткой команд локального тестирования с туннелем более чем достаточно. Если вы ведёте публичного бота на миллионы пользователей, со временем захочется staging-деплой со стабильным URL, чтобы Discord не перепроверял всё каждый раз при ротации сессии туннеля.
Таймаут Discord в три секунды — та же семья проблем, что и десятисекундное окно GitHub. Прочитайте наш разбор повторов вебхуков и идемпотентности для подробной версии. Запишитесь в лист ожидания PortPreview, чтобы получить туннель и захват в одном CLI.