"The API for interacting with Gateways is complex and fairly unforgiving, therefore it's highly recommended you read all of the following documentation before writing a custom implementation." - Discord API Docs

Добрый день. Многие знают, что программисту приходится следить за развитием технологий, даже тех, которые не касаются его текущего стека. Ну, или ему это доставляет удовольствие, которое он оправдывает необходимостью держать руку на пульсе. Так обычно зарождаются разнообразные домашние проекты. Я решил свести в один пост свои наработки по написанию Node.js-бота для Discord Slash API с Serverless подходом в Yandex Cloud. Использование готовых библиотек сведено к минимуму.

Discord?

Discord — это многим известный мессенджер, бесплатный до определённых пределов. Он позволяет без особых технических навыков построить коммьюнити по какой-то теме. В основном, конечно, это геймерские сообщества, именно так себя Discord и позиционирует — встроенная функциональность для трансляции игр и прочие навороты. Ими, впрочем, дело не ограничивается — на Хабре пробегали статьи про жизнь небольших контор в этом мессенджере, также там заседают алготрейдеры с «Реддита» и много кто ещё. В определённой степени эта популярность вызвана открытым API для написания ботов. Как правило, в любом сообществе администраторы создают 1–2 канала для совместного прослушивания музыки, игр в простые текстовые развлечения — атмосфера IRC начала нулевых.

В настоящий момент существует два API для ботов на этой платформе. Одно, ставшее классическим, основано на вебсокетах. Второе Discord открыл в конце 2020 года — и им можно пользоваться на чистом REST.

Изначально я наткнулся на статью про Python и AWS, но просто скопировать её было бы слишком скучно для понимания и погружения, поэтому пришлось читать документацию самостоятельно.

Discord Slash API?

Коротко о разнице в подходах между основным WebSocket-протоколом и Discord Slash API для тех, кто уже писал своих ботов, — на вебсокетах можно (и нужно) слушать все сообщения в чате, независимо от того, упоминали там вашего бота или нет. Это открывает широкие возможности для взаимодействия по сбору статистики, для модерирования контента, для описания взаимодействия с пользователями в свободной форме, даже без явного упоминания, и т. д. Боты же, реализующие Discord Slash commands, получают в запрос только команду, написанную пользователем в специальном формате, но не слышат остальной трёп в канале. Эта разница отражена в названии взаимодействия — команда должна начинаться со слеша, и синтаксис выглядит привычным для многих мессенджеров — например, `/дай фотку:котика`. Параметры команды могут быть перечисляемыми. Клиентское приложение подскажет пользователю доступные варианты и явно обозначит, что это команда, а не простое сообщение в чат. 

Базовый сценарий:

Начнём с простого — заставим эту связку минимально работать. Нужно отвечать на команду
/дай с параметром фотку:<тип> соответствующей картинкой. Варианта будет три — котик, собакен или случайная из двух.

У Discord (в отличие от Telegram) довольно разухабистая система авторизаций, проверок и перекрёстных страниц, по которым надо пройти, чтобы все участники взаимодействия получили свои права в нужном объёме. Общением с @Botfather дело не ограничивается.

Сначала идём на портал разработчика и создаём новое приложение. На этой странице нам пригодятся поля APPLICATION ID и PUBLIC KEY. На вкладке Oauth2 нужно скопировать CLIENT SECRET, он тоже пригодится. 

С помощью этих данных можно получить временный Bearer token, который позволяет авторизовываться на некоторых служебных эндпоинтах. Получим его: 

curl --location --request POST 'https://discord.com/api/v8/oauth2/token' -u APPLICATION ID --header 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'grant_type=client_credentials' --data-urlencode 'scope=identify connections'

CLIENT SECRET вводим весто пароля в интерактивном режиме.
Ответом будет примерно такой JSON:

{
  "access_token": "BEARER_TOKEN", 
  "expires_in": 604800, 
  "scope": "identify connections", 
  "token_type": "Bearer"
}

Из ответа надо запомнить уже поле access_token. Обратите внимание, что scope — тоже значащее поле. Оно определяет, какие права будут у выданного нам токена. Перечисленных в примере хватит для дальнейшей работы.

Следующий нюанс — Slash-команды можно регистрировать глобально или в рамках одного сервера. Если ваш бот большой, серьёзный и готовится захватывать мир, глобальный режим — то что нужно. Везде, куда позвали бота, — его команды сразу появятся. На стадии отладки же он не очень удобен, так как изменения команд будут выкатываться до часа. Регистрация команды на одном сервере проходит мгновенно. Дублировать описание синтаксиса описания команды не буду — эта часть хорошо читается в оригинале.

curl --location --request POST 'https://discord.com/api/v8/applications/APPLICATION ID/guilds/SERVER ID/commands' --header 'Authorization: Bearer BEARER_TOKEN' --header 'Content-Type: application/json' --data-raw '{
    "name": "дай",
    "description": "Отправляет фото котика, пёсика, или рандом",
    "options": [
        {
            "type": 3,
            "name": "фото",
            "description": "Чьё фото выслать",
            "required": true,
            "choices": [
                {
                    "name": "пёсика",
                    "value": "animal_dog"
                },
                {
                    "name": "котика",
                    "value": "animal_cat"
                },
                {
                    "name": "случайную",
                    "value": "animal_rnd"
                }
            ]
        }
    ]
}'

Итак, теперь Discord знает про вашего бота, знает про команду /дай фото:<тип фото>, к примеру. Можно пригласить бота на сервер SERVER ID, где мы создали его команду, через интерфейс консоли управления. Если на этом сервере начать набирать сообщение с / — команда «дай» появится в интерфейсе и её можно будет вызвать.

Работать она, конечно же, ещё не будет.

Знания, необходимые для этой, казалось бы, простой подготовительной части, щедро рассыпаны по документации, так что написание первой простейшей версии бота заняло у меня несколько вечеров. Кроме того — таких извращенцев просто мало, в основном эту функциональность используют вместе с основным ботом, висящим на WebSocket с помощью библиотеки discord.js (что, конечно, получается гораздо быстрее), и готовых сниппетов в интернете практически нет.

Пришла пора писать Serverless-функцию, которая будет отвечать на запросы. 

Yandex Cloud Functions

Я не буду подробно останавливаться на описании интерфейса консоли Yandex Cloud. Типичный жизненный цикл Serverless-функций — это обрабатывать входящие обращения, иногда уже отсортированные Gateway API, и отдавать результат. Биллинг потом спишет копеечку за время работы функции.

Отдельно оговорюсь, что в интернете я наткнулся на интереснейшее обсуждение реализации бота на стандартном для Discord WebSocket-протоколе, который завязывался на Amazon API Gateway, умеющем преобразовывать каждое WebSocket-сообщение в отдельный вызов Serverless-функции. Yandex API Gateway так пока не может (но планирует смочь, особенно если их потыкает побольше народа). Честно говоря, и слава Эру, иначе я никогда не довёл бы эту статью до конца.

Итак, в консоли Yandex Cloud создаём новую функцию, среда выполнения — Node.js 14, время выполнения — 3 секунды (про него объясню позднее), доступная память — минимальная — 128 МБ.

Начинаем писать мало-мальски работающий код:

const di = require("discord-interactions")

module.exports.handler = function (event, context) {
	const signature = event.headers["X-Signature-Ed25519"]
	const timestamp = event.headers["X-Signature-Timestamp"]
	
  let isValidRequest = di.verifyKey(event.body, signature, timestamp, 'PUBLIC ID');
  if (!isValidRequest) {
		return {
			statusCode: 401,
		}
	}

  const body = JSON.parse(event.body);
		
  let result;
    
  if (body.type === 1){
		result = {
			statusCode: 200,
			headers: {"Content-Type": "application/json"},
			body: JSON.stringify({
				type: 1
			})
		}
	}

	return result
};

Мы объявили функцию, которая будет вызываться при получении REST-запроса по своему случайно сгенерированному постоянному адресу. В параметр event прилетает всё описание запроса — заголовки и тело. Переменная, которую мы возвращаем, описывает, как должен выглядеть ответ, его статус, заголовки и тело.

Бот обязан проверять подпись входящих запросов. Discord при первом сохранении endpoint проверяет, как она реагирует на запрос с некорректной подписью. И если бот не сможет распознать его и ответить 401-й ошибкой — откажется с ней работать. Проверку было решено отдать на откуп готовой библиотеке, помня о первой заповеди самостоятельной реализации аутентификации — «не делай этого». 

Рядом с этим файлом нужно создать package.json, положив в него описание проекта, а главное — зависимостей:

{
  "name": "serverlessdiscordbot",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {},
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "discord-interactions": "^2.0.2"
  }
}

После сохранения этого файла на вкладке «Операции» появится запись о том, что все зависимости установлены. Конечно, никто не мешает писать код в любимой IDE, а затем положить его в ZIP и закинуть на сервер со всеми зависимостями — но мне было интересно, как работает этот функционал.

У проверки подписи есть побочный эффект — вы не сможете потыкать свою функцию тестовыми данными, чтобы проверить её работу. Так что при более-менее серьёзной разработке поднимайте отдельную версию функции, которая не столь радикально относится к подписи. А заниматься такой отладкой придётся, потому что Discord не расскажет вам, что пошло не так при общении с ботом. Именно ручная отладка помогла мне понять, что по умолчанию функция отдаёт заголовок text/plain вместо нужного application/json. 

Итак, после сохранения можно взять URL функции, зайти в личный кабинет приложения на сайте Discord, вбить его в поле INTERACTIONS ENDPOINT URL и сохранить. Ура. Теперь начнём, наконец, что-то отвечать на саму команду:

...  
const IMAGE_DOG_URL = "https://cdn2.thedogapi.com/images/1MZ0YbOpS.jpg";
const IMAGE_CAT_URL = "https://cdn2.thecatapi.com/images/tXSD5qfr7.jpg";
...    
let url = "";
let title = "";

//у нашей функции только один параметр, так что перебор делать не будем
switch (body.data.options[0].value) {
	case 'animal_cat':
		url = IMAGE_CAT_URL;
		title = `Кошка по запросу ${body.member.user.username}`;
		break;
	case 'animal_dog':
		url = IMAGE_DOG_URL;
		title = `Пёсик по запросу ${body.member.user.username}`;
		break;
	default:
		url = Math.random() < 0.50 ? IMAGE_CAT_URL : IMAGE_DOG_URL;
		title=`Картинка по запросу ${body.member.user.username}`;
	}

	let result = {
		statusCode: 200,
		headers: {"Content-Type": "application/json"},
		body: JSON.stringify({
		type: 4,
		data: {
			tts: false,
			//content: 'Здесь мог бы быть текст сообщения',
			embeds: [{title, image:{url}}],
			allowed_mentions: []
			}
		})
	}

Первые результаты

Первая итерация закончена, бот работает, выдает одну, вторую или случайную картинку. Конечно, это абсолютно синхронная и лёгкая операция, и отдавать таким образом статику суперпросто. Теперь пойдём дальше — и сделаем нашу функцию асинхронной.

Все, перед кем вставала задача радовать неугомонных пользователей фотографиями животных, наверное, знают про сервисы The Cat API и The Dog API. Это API, отдающее ссылки на фотографии, доступные для использования в проектах, похожих на наш. Большинство подобных сервисов реализуют функцию random и сами следят за актуальностью фотографий в своей базе, что снимает с разработчика множество проблем. Такие сервисы есть почти по любой тематике, и многие из них бесплатны до какого-то количества обращений. Мне вполне хватало этого лимита, так что я приступил к их использованию, всего лишь один fetch-запрос до сервиса... и сразу наступил на грабли того самого ограничения в 3 секунды, про которое говорил ранее. И проблема тут совсем не во времени исполнения функции — хотя, учитывая, что тарифицируют нас за время работы, необдуманно забивать это поле девятками — безусловно, не лучшая идея.

Проблема описана в совершенно другом месте документации Discord и звучит как «Interaction tokens are valid for 15 minutes and can be used to send followup messages but you must send an initial response within 3 seconds of receiving the event. If the 3 second deadline is exceeded, the token will be invalidated». В переводе на русский это означает, что вместе с сообщением о событии мы получаем особый токен, который позволяет реагировать на входящий запрос ещё 15 минут. Но первый ответ должен быть дан за 3 секунды, иначе пользователь увидит сообщение об ошибке, а токен протухнет. 

Я не планировал скачивать изображения и считал что «трёх секунд хватит всем»?, но нет. Во-первых, запросы в нашем несовершенном мире иногда падают по сетевым причинам. Во-вторых, сервисы эти монетизируются крайне условно и геокешинг с CDN в разных странах, как правило, не используют. Запросы отрабатывали, но за 3 секунды можно вылететь. 

Кроме того, однажды я попал в ситуацию, когда изображения перестали открываться в чате. Виноват оказался Роскомнадзор: CDN сервиса с котиками попал под блокировку. Сам сервис отдавал ссылку на картинку, я передавал её в чат, а Discord не мог открыть её, так как из России адрес был недоступен. Дополнительная проверка ссылки на доступность (напомню, мы всего лишь пытаемся показывать милые фото котиков) делала перспективы уложиться в 3 секунды более призрачными, особенно в случае повторного запроса. 

Решение? Очевидно — создадим ещё одну функцию.

Новый план!

Если делать всё по-взрослому — такой запрос от функции к функции следует передавать, используя брокер сообщений или другие механизмы, которые могут служить триггером для функций в облаке. Мне показалось, что для такого простого и нетребовательного проекта это уже слишком, можно обойтись запросом POST в один конец, не дожидаясь его результата. Я не был уверен, успеет ли этот запрос отработать, не оптимизирует ли его какой-то из внутренних механизмов облака, — но нет, вполне себе рабочий вариант. Конечно, мы не узнаем, дошёл ли запрос и как был обработан — но, повторюсь, на практике в этом месте проблем у меня не возникло или они были пренебрежимо малы.

Медленная функция должна будет отправить результат своей работы методом POST на https://discord.com/api/v8/webhooks/APPLICATION ID/INTERACTION TOKEN — это послужит ответом на первый, служебный ответ быстрой функции. 

const fetch = require("node-fetch");

module.exports.handler = async function (event, context) {
	
  const body = JSON.parse(event.body)
  let url=""
  let req
  
  if (body.type === "cat") {
  	req = await fetch("https://api.thecatapi.com/v1/images/search?limit=1&size=full")
  } else {
		req = await fetch("https://api.thedogapi.com/v1/images/search?limit=1&size=full")
  }

	let res = await req.json()
  url = res[0].url
  
  fetch(`https://discord.com/api/v8/webhooks/${body.appId}/${body.token}`, {
		headers: {'Content-Type': 'application/json'},
			method: "post",
			body: JSON.stringify({
				"content": body.title,
				"embeds": [{
					image: {url}
				}]
			})
		})
  
	return { statusCode: 200 }
}

Изменения в быстрой функции:

…

const SLOW_URL = "https://functions.yandexcloud.net/SLOWFUNCTIONCODE"
…
fetch(SLOW_URL, {
		headers: {
			'Content-Type': 'application/json'
		},
		method: "POST",
		body: JSON.stringify({
			"appId": body.application_id,
			"token": body.token,
			type,
			title
		})
	})
  
let result = {
	statusCode: 200,
  headers: {"Content-Type": "application/json"},
  body: JSON.stringify({
  	type: 4,
    data: {
    	tts: false,
      content: 'Картинка готовится!',
      flags: 1<<6,
      allowed_mentions: []
    }
  })
}

В ответ я добавил поле flags, чтобы чуть меньше засорять весь канал лишними сообщениями. Медленная же функция будет радовать результатом своей работы всех.

Работа с точки зрения пользователя:

Итоги

Главный вопрос, который может возникнуть при прочтении: «Зачем?».

Я отвечаю на него: «Чтобы не следить за сервером». Как только Yandex Cloud добавит в Gateway API возможность удержания WebSocket-сообщений — я попробую переписать и WebSocket-бота (который начинает спамить котиками при слишком бурном и не очень цензурном общении в чате) на Serverless.

Иногда нужен контроль, иногда страшно, что функция слишком бодро масштабируется, иногда есть виртуалка, на которой вполне хватит места на ещё-один-докер-с-js-процессом-внутри. Но рано или поздно за этим придётся следить — переносить процесс, данные, добавлять мониторинг, обновлять систему, наконец. Serverless-подход несложен в использовании, не требует ухода после деплоя и очень дёшев при небольших/нечастых нагрузках для домашних проектов.

Кстати, про «дёшево» — это, как правило, второй возникающий вопрос. Посчитаем по прайсу.

При тарификации вычислительных ресурсов (ГБ ? час) учитывается объём памяти, выделенный для функции, и время выполнения функции. 

На июль 2021 г. 1 миллион вызовов стоит 10 ?, ГБ*час стоит 3,42 ?, а исходящий трафик — 0,96 ? за гигабайт.

Среднее оплачиваемое время выполнения этих функций (из отчёта Yandex Cloud):

Медленная функция: 1200 мс
Быстрая: 200 мс

Итого: 1,4 с на обработку одного запроса.

Прикинем стоимость миллиона котиков:

3,42 ? (128 / 1024) ? (1200 / 3600 / 1000) ? 1 000 000 + 10 ? (1 000 000 / 1 000 000) = 152,5 ?

Но — приятный подарок — первый миллион запросов, первые 10 ГБ ? час в месяц и первые 10 ГБ исходящего трафика не тарифицируются.

Из моей личной практики (на канале в 400 человек, где у бота есть клуб преданных фанатов): он ни разу не вышел в платный режим. Даже с учётом вакханалии на 1 апреля, когда он показывал, а затем оперативно удалял чуть-чуть NSFW-шные картинки, которые «случайно» попали в его базу.

Для меня это уже не первый раз, когда Serverless-решение оказалось дешевле, но главное — гораздо проще в поддержке , чем стандартный подход.

Неважно, что вы делаете — планировщик задач, торгового бота или такой проект на коленке, — прежде чем писать ещё_один_демон_за_которым_надо_следить, подумайте, возможно Serverless подойдёт вам больше.

Хорошей пятницы!