Кулстори: Как устроена Реквест-Система на Mikulski_Radio (Liquidsoap, Twitch, Donation Alerts) — Mikulski
Наложение сайта

Кулстори: Как устроена Реквест-Система на Mikulski_Radio (Liquidsoap, Twitch, Donation Alerts)

Последовательный и, по возможности, увлекательный рассказ о том, как получилось без кодерских навыков и за короткое время добавить систему заказа треков на Mikulski_Radio.

Donation-Alerts-in-twitch-chat

Путь начинается с js-скрипта, который парсит донатные уведомления из сервиса DonationAlerts в чат Твича.
Настройка и установка очень простые: копируется репозиторий и редактируется файл с настройками (settings.js), в котором указываются необходимые токены площадок.
Затем командой «node bot.js» бот активируется.
Полученный опыт от установки Discobot (транслирующий радио в голосовой чат Discord) научил меня пользоваться для запуска и управлением js-скриптами утилитой pm2 — с этим менеджером очень удобно следить и в фоновом режиме запускать множество приложений. Команда «pm2 start bot.js».


Стоит отметить, что команду «npm install» для установки вводить не нужно, иначе поломаются зависимости модулей из-за конфликтов их версий (на этой ошибке я потерял очень много времени).

Из технических нюансов огорчила нестабильность подключения веб-сокета DonationAlerts в контексте работы 24/7: были замечены обрывы связи через день-два. Из похожего скрипта DonationAlertsNotificationBot я заимствовал строчки для автоматического переподключения, а также добавил еще одну строчку в конце, которую нашел на одном из форумов, где человек решил таким образом проблему, что его скрипт не мог подключиться к сокету.
Исходный код:

const io = require('socket.io-client');
const socket = io("https://socket.donationalerts.ru:443");

После апгрейда:

const io = require('socket.io-client');
const socket = io("https://socket.donationalerts.ru:443", {
                    reconnection: true,
                    reconnectionDelay: 1000,
                    reconnectionDelayMax: 5000,
                    reconnectionAttempts: Infinity},
                    { transports: ["websocket"] }); 

Части для вывода в лог:

//donation alerts
socket.on('connect', function(data){
    console.log('Connected to Donation Alerts successfully!')
});
socket.on("connect_error", (err) => {
    console.error(`DA connect_error ${err.message}`);
    process.exit()
});

 socket.on('reconnect_attempt', () => {
                console.log('DA:' + ' Trying to reconnect to service')
            });

            socket.on('disconnect', () => {
                console.log('DA:' + ' Socket disconnected from service')
            });

Однако, даже после модернизации кода и несмотря на сообщения в логе о переподключениях, проблема не исчезла: точно так же, через несколько дней сокет DonationAlerts не передал событие. В данный момент я продолжаю отслеживать этот момент на активном дупликате бота. А пока для повышения надежности решил рестартить скрипт каждый час через утилиту crontab, которая в заданный интервал дает в консоль команду «pm2 restart id-процесса». И это обеспечивает бесперебойную работу.

CRONTAB -E

Альтернативное решение переподключения сокета DA

В общем, изучив некоторую информацию, выяснилось что не я один столкнулся с проблемами переподлючения соединения сокета через модуль socket-io. Так как версия этого модуля в данном проекте весьма старая (а установка более свежей версии ломает зависимости, как я отмечал выше), а также то, что решение (как я понял, на каждое разъединение, необходимо создавать новое подключение) требует более тонкого понимания программирования, я пришел к другой идее.
Я обратил внимание, что pm2 автоматически перезапускает скрипт, когда тот останавливает работу по какой-либо причине. Так что, я решил останавливать скрипт каждый раз, когда происходит дисконнект, добавив в блок с отловом дисконнекта строчку process.exit:

            socket.on('disconnect', () => {
                console.log(' Socket disconnected from DA_Main');
                process.exit(0);
            });

Таким образом, когда сокет разъединяется, скрипт останавливает свой процесс, а pm2 его снова запускает. И больше не нужно перезапускаться каждый час через crontab.


С подключением к Твичу проблем нет, встроенный в скрипт модуль tmi.js (Twitch Messaging Interface) автоматически возобновляет соединение с чатом при обрывах.


Рассматривая строчки, которые отвечают за передачу сообщения из алерта в чат..

socket.on('donation', function(msg){
  donate = JSON.parse(msg)
  client.action(options.channels[0]," | "+ donate.username + " - " + "[" + donate.amount + " " +donate.currency+"]" + '\n' + donate.message)
}); 

..я задумался о том, что было бы здорово еще сохранять часть информации в текстовый файл. И уже в Liquidsoap через функцию file.getter (условно: постоянное чтение заданного файла) выводить последний донат на стриме.
Быстро нагуглил, что есть функция fs.writeFile, но добиться работоспособности удалось только с подсказкой от SpaceMelodyLab:

socket.on('donation', function(msg){
  donate = JSON.parse(msg)
    client.action(options.channels[0]," DonationAlerts.com/r/Mikulski : "+ donate.username + " donated " + donate.amount + " " + donate.currency + " and said: " + "'" + donate.message + "'")
  fs.writeFile('/Donation-Alerts-in-twitch-chat/lastdonate.txt', "Latest Tipper: " + donate.username + " - " + donate.amount + " " + donate.currency, 'utf8', function(err) {if (err) return console.log(err);
  console.log('Lastdonate Updated!')});

Тем временем…

Дабы сократить текст, я не буду вдаваться в подробное описание тех мероприятий, которые предшествовали следующему шагу. Но пару слов стоит сказать:

  • Изначально я заводил аудио в Liquidsoap из своей радио-трансляции с сервера Shoutcast (еще летом я настроил его чисто из любопытства). В итоге я перестроил систему наоборот. Теперь Liquidsoap параллельно раздает два потока: один видео (собственно, стрим) и второй только аудио в Shoutcast. Этот шаг облегчил обновление плейлиста, позволил добавить «предсказание» следующего трека, а также открыл потенциальную возможность реквестов.
  • Много времени ушло на решение проблемы с некорректным отображением кириллицы из текстового файла. Я перебирал кодировки текста в файле, пытался назначить кодировку внутри Liquidsoap, менял шрифты, пытался менять язык системы… Но решилось все почти случайно. В документации я обратил внимание на то, что Liquidsoap для графического отображения текста использует первую попавшуюся библиотеку (я пришел к выводу, что следуя по списку в алфавитном порядке). В общем, я удалил плагин camlimages, установил gd — и все заработало!

Request Queue

Я предполагал, что в Liquidsoap предусмотрена возможность ставить треки в очередь воспроизведения по запросу. И спустя некоторое время в The Liquidsoap book я наткнулся на главу, где это описывается и дается несколько разных методов для реализации. Поигравшись с ними, я остановился на самом простом и эффективном:

def on_request()
fname = string.trim(file.contents("/radio/request.txt"))
log.important("Playing #{fname}.")
queue.push.uri(fname)
end
file.write(data=string_of(time()), "/radio/request.txt")
file.watch("/radio/request.txt", on_request)

Что здесь происходит:

  • Создается функция on_request.
  • Назначается переменная fname, которая «вынимает» содержимое файла (request.txt, в данном случае), хранящего полный путь к аудиофайлу.
  • В лог выводится, содержимое fname.
  • В очередь воспроизведения ставится трек, взятый из пути прописанного в fname (то бишь из файла request.txt).
  • Функция on_request закрывается.
  • Файл, где хранится путь к треку (request.txt) перезаписывается с ненужными данными. Просто временное значение в миллисекундах. Это нужно для того, чтобы при запуске скрипта этот файл точно существовал.
  • Указывается, что нужно следить за изменениями в файле request.txt и когда они происходят — вызывать функцию on_request.
    Эта функция заглянет в файл и если там будет указан корректный путь к файлу, то поставит его в очередь. В случае, если же файла по указанному пути нет, то в логе появится сообщение, что такого файла не существует и Liquidsoap спокойно продолжит работать.

Ну, а так как я уже научился сохранять текст сообщения из доната в текстовый файл, то следующий ход был уже понятен. В скрипт bot.js добавил строчки, которые сохраняют в файл request.txt текст «/путь/к/папке/Mikulski — [сообщение из доната].mp3»:

socket.on('donation', function(msg){
  donate = JSON.parse(msg)
      fs.writeFile('/radio/request.txt', "/music/Mikulski - " + donate.message + ".mp3", 'utf8', function(err) {if (err) return console.log(err);
  console.log('Request Accepted!')});
});

В донатное сообщение прописывается название трека, в request.txt сохраняется полный путь к треку и он встает в очередь.
По такому же принципу, в bot.js добавляются функции, которые сохраняют информацию в файлы next-song.txt («Далее реквест от [Username] — [сообщение из доната]») и donation.txt (более крупным шрифтом «[Username] задонатил [Сумма] [Валюта]! Спасибо!»).

Реквесты из Твич-чата

Казалось бы, что на этом можно остановиться: цена реквеста минимальная, кроссплатформенность максимальная, да и попросту чудо волшебное, что это вообще получилось реализовать.
Но не давали покоя желание поощрить щедрых бустеров (они и без того прилично тратят на меня в месяц) и то, что бот для Твича, по сути, уже есть — только добавить команду с привязкой к ВИП-бейджу, раздать бейджи бустерам и готово!
Впрочем, от идеи с ВИП-бейджами я сразу же отказался, потому как планировал выстроить более гибкую систему. К тому же их большая часть уже раздана, а отбирать и перераспределять как-то неприлично.
Самой собой сформулировалось ТЗ: команду могут активировать только конкретные пользователи, кулдаун для исключения возможного бардака и сохранять в файл текст сообщения после !sr.
Я рассчитывал на безмятежную прогулку на пару часов, но проблемы возникли буквально на каждом этапе. Мною, почему-то, предполагалось, что кулдаун и чтение аргументов команды предусмотрены и заложены в tmi.js, а поморочиться придется только с привязкой к конкретному Username.
Оказалось же, что все эти моменты надо программировать вручную. С гуглом и с горем пополам я залип над этим квестом часов на 12…

Дать понять боту, к какому юзеру прислушиваться по какой команде прислушиваться оказалось просто:

client.on('message', (channel, tags, message, self) => {
if(self) return; 
if ('!sr' && tags.username == 'mikulski_') { 
    client.say(channel, `@${tags.username} , your groovy request has been accepted for processing!`);}
};

Очень много мороки было с подбором нужной копипасты для установки кулдауна. При этом хотелось сделать, чтобы кулдаун относился только к пользователю, использовавшему команду, а не к самой команде в целом. Но это сделать так и не вышло:

var block = false;

client.on('message', (channel, tags, message, self) => {
if(self) return;
 if (command === 'sr' && tags.username == 'mikulski_radio') { 
  if (!block) {
    client.say(channel, `@${tags.username} , your groovy request has been accepted for processing!`);

 block = true
 setTimeout(() => {
  block = false;} , (60 * 3000))
 }
 else {
  client.say(channel, `@${tags.username} Please wait a couple of minutes before the next request! <3`)
 }
};

Самая феерия была с «выемкой» аргумента из сообщения, чтобы сохранять его в качестве реквеста в текстовый файл.
Сначала я воспользовался гайдом, который предлагал забирать аргумент с помощью регулярных выражений regExp. И я считал это рабочим вариантом, пока уже в «боевом тесте» не выяснилось, что скрипт падает почти от любого сообщения чате (даже не относящегося к команде) с цифрами или символами. Благо что, pm2 его автоматически перезапускал. Пришлось срочно искать другой способ.
Куда лучше себя показал метод slice и split: отрезается первый первый элемент сообщения (!sr), остальные элементы собираются в кучу и все это объявляется переменной для обозначения чат-команды:

client.on('message', (channel, tags, message, self) => {
if(self) return;

const argument = message.slice(1).split(' ');
const command = argument.shift().toLowerCase();

Правда, здесь ожидал следующий подвох: если в сообщении больше одного слова (например, Where Are You), то на выходе функции .split(‘ ‘) — эти слова разделялись запятыми (Where,Are,You).
Понадобилась еще одна функция, которая после slice.split преобразует данные в «string» (иначе на replace будет возникать ошибка в скрипте), а потом из этого «string» запятые подменяются пробелами:

const arg = message.slice(1).split(' ');
const command = arg.shift().toLowerCase();
const argument = arg.toString().replace(/,/g," ");

В конечном итоге, сам блок с командой/реакцией выглядит так:

//Mikulski_
 if (command === 'sr' && tags.username == 'mikulski_') { 
  if (!block) {
    client.say(channel, `@${tags.username} , your groovy request has been accepted for processing!`);
 fs.writeFile('/radio/request.txt', "/music/Mikulski - " + argument + ".mp3", function(err) {if (err) return console.log(err)});
 console.log(' Boosty Request updated!');
 fs.writeFile('/radio/next-song.txt', "Request by Booster "  + tags.username + " : " + argument + " ", function(err) {if (err) return console.log(err)});
 console.log(' Boosty Requester updated!');
 block = true
 setTimeout(() => {
  block = false;} , (60 * 3000))
 }
 else {
  client.say(channel, `@${tags.username} Please wait a couple of minutes before the next request! <3`)
 }
};

Ну и для каждого нового пользователя/бустера я этот блок копирую, меняя лишь никнейм. Здесь, конечно же, это стоило бы оптимизировать, создав переменные или вовсе вызывая данные из отдельного файла. Но я настолько уже с этим намучался, что пока нет сил и желания освоить эти приемы.

SpaceMelodyLab научил меня создавать массивы с перечнем разрешенных пользователей, чтобы не приходилось на каждого подписчика дублировать весь блок. Также добавил реакцию бота, если командой пытается воспользоваться человек не из списка:

const users = ['user1', 'user2', 'user3'];

if (command === 'sr' && users.indexOf(tags.username) === -1) {
    client.say(channel, `@${tags.username} , to use this command, you must be a Boosty subscriber (Mini-Merchpack Tier) - https://boosty.to/mikulski`);
    console.log(`${tags.username} tried to request without subscription`);
}

 if (command === 'sr' && users.indexOf(tags.username) >=0) { 
  if (!block) {
    client.say(channel, `@${tags.username} , your groovy request has been accepted for processing!`);
 fs.writeFile('/radio/request', "/radio/music/Mikulski - " + argument + ".mp4", function(err) {if (err) return console.log(err)});
 console.log(' Boosty Request updated!');
 fs.writeFile('/radio/next-song', "Boosty Request by "  + tags.username + " : " + argument + " ", function(err) {if (err) return console.log(err)});
 console.log(' Boosty Requester updated!');
 block = true
 setTimeout(() => {
  block = false;} , (10 * 1000))
 }
 else {
  client.say(channel, `@${tags.username} Please wait a little before the next request! <3`)
 }
}
});

Оповещения в Telegram

Ну и в качестве вишенки на торте, скрипт подвергся еще одной модернизации, которой мне не хватало: это отслеживание реквестов через оповещения в Telegram.
Для этого понадобится модуль channel-telegram-bot. Я рекомендую создать новую папку, в ней ввести команду установки модуля:

npm i channel-telegram-bot

После чего из подпапки node_modules скопировать папки channel-telegram-bot и telebot в директорию node_modules основного бота. Почему так? По идее, команда npm i (или npm install) должна добавить модули, не меняя имеющихся. Но по непонятной мне причине, после этого действия у меня удалялись некоторые другие модули. Поэтому лучше подстраховаться.

В верхушку скрипта добавляется код:

const channelBot = require('channel-telegram-bot');
const telegram = 'токен_телеграм_бота';

И ниже, по аналогии с сохранением сообщения доната в файл, создается блок:

socket.on('donation', function(msg){
  donate = JSON.parse(msg)
  channelBot.sendMessage('@телеграм_канал_или_чат',"Request: "+ donate.username + " donated " + donate.amount + " " + donate.currency + " Сообщение: " + "'" + donate.message + "'", telegram)
    console.log('Radio Request sent to the Telegram!')

Где прописывается адрес (через @) телеграм канала или чата, куда следует отправить оповещение, само сообщение и переменная telegram, в которой хранится токен телеграм-бота.