How to hack Twitch for fun and profit

Dzisiaj chciałbym przedstawić jak w prosty sposób można stworzyć narzędzia, które pomogą w promocji kanału podczas streamowania.
Postaram się opisać proces tworzenia dwóch botów, jednego z wykorzystaniem node.js, drugiego z wykorzystaniem PHP i Selenium.
Chciałbym zaznaczyć, że nie jestem programistą node.js, a skrypt powstał w oparciu o dokumentację i moje doświadczenie z innymi językami. Warto też zwrócić uwagę, że skrypt jest oparty o ES5, a nie ES6, jak powinno się czynić.

Kody źródłowe znajdują się tutaj https://github.com/elmasterlow/bots

Node.js twitch bot

Nie będę rozwijał się na temat instalacji potrzebnego oprogramowania do odpalania skryptów w node. Wszystko znajduje się w dokumentacji na oficjalnej stronie https://nodejs.org/en/. Jeżeli chodzi o naukę ww. języka to na początek polecałbym poznać się z jakimiś prostszymi tutorialami.

Nie warto wymyślać koła od nowa, dlatego do zarządzania requestami skorzystam z frameworka Express. Dzięki temu zyskamy strukturę aplikacji, która pozwoli nam na pominięcie pisania podstawowych funkcjonalności od nowa, takich jak odbieranie odpowiedzi w callbacka z OAuth, potrzebnego przy logowaniu się do api twitcha.

Wszystko czego potrzebujemy to wygenerować stukturę aplikacji, a jak to zrobić znajdziemy tutaj https://expressjs.com/en/starter/generator.html

Z modułów jakich będziemy potrzebować do naszego bota, to min. logowanie Oauth 2.0 z pluginem do twitcha, rzeczy do zarządzania czasem, możliwość łączenia się z kanałem irc.

Dzięki takim modułom jak:

  • passport
  • moment
  • irc

osiągniemy cel, a praca nad pomysłem będzie przebiegać w miarę lekko.

Chcąc bardziej podzielić aplikację, nie pisząc całej logiki w jednym pliku musimy stworzyć folder controllers, gdzie będą trzymane rzeczy związane z zapytaniami i odpowiedziami od serwera. Dla uproszczenia przykładu logikę zarządznia wiadomościami, a także inne funkcjonalności, które będą potrzebne, przeniosę do folderu utils.

W ten sposób stuktura prezentuje się następująco

  • bin (express)
  • controllers
  • public (express)
  • utils
  • views (express)

W dalszej kolejności wypadałoby zapoznać się z oficjalną dokumentacją API Twitch https://dev.twitch.tv/docs/v5/guides/irc/, gdzie opisany jest proces autentykacji, odbierania i wysyłania wiadomości.

Do logowania użytkownika z API, Twitch korzysta z protokołu Oauth 2.0. Protokół jest standardem na dzień dzisiejszy, jeżeli chodzi o autoryzacje między serwisami, dzięki czemu, teraz możemy korzystać z dobroci internetu i używać gotowych narzędzi, które zrobią za nas “brudną robotę”.

Żeby w ogóle skorzystać z możliwośći API potrzebujemy zarejstrować aplikację https://www.twitch.tv/kraken/oauth2/clients/new.

Po zapoznaniu się z odpowiednim opisem i pomyślnym stworzeniu aplikacji przejdźmy do kolejnego kroku, jakim jest stworzenie konfiguracji dla naszego narzędzia. W pliku config.js, który posłuży za trzymanie potrzebnych danych stwórzmy obiekt config, z odpowiednimi danymi.

var config = {};
config.twitch = {
    channels : ["#piotr_z_lubartowa"],
    host : "irc.chat.twitch.tv",
    port: 443,
    secure: true,
    nick : "piotr_z_lubartowa",
    messagesLimit: 100,
    timeLimit: 30,
    clientID: '',
    clientSecret: '',
    callbackURL: 'https://localhost:3000/auth/twitch/callback',
    scope: ["user_read","chat_login"],
};
module.exports = config;

clientID i clientSecret powinniśmy uzupełnić danymi, które zwróciła nam dopiero co stworzona aplikacja, to samo tyczy się callbackURL, który jest także ustawiany po jej stronie. W tym momencie załóżmy, że znamy już adres, chociaż dopiero za chwilę będziemy posiadać odpowiedni kod. Scope to prawa, o które zapytamy i które powinniśmy dostać, żeby zarządzać czatem.

Zapewne zastanawiasz się do czego posłużą dwie wartości, mianowicie te związane z limitem. Wg dokumentacji będąc moderatorem na kanale, w ciągu 30 sekund możemy wysłać 100 wiadomości, jeżeli wyślemy więcej skutkuje to banem na 30 minut. Obsłużymy to w ten sposób, że wiadomości do wysłania będziemy kolejkować w tablicy, co przyczyni się do tego, że nasz użytkownik nie zostanie zbanowany, a te dwie wartości nam w tym pomogą (czy pisałem, że moduł moment.js też się przyda?).

Po swojej stronie posiadamy plik app.js, który jest bootstrapem naszej apki, a w którym umieścimy co następuje

W przygotowanym wcześniej folderze controllers stwórzmy trzy pliki:

  • index.js
  • auth.js
  • chat.js

Bot składa się z trzech „modułów”:

  • strony głównej, dzięki której będziemy mieli możliwość wyświetlenia widoku z potrzebnymi kontrolkami do zalogowania się i połączenia z Twitch API,
  • samego modułu do łączenia się z Twitchem,
  • naszej części, gdzie obsłużymy czat.

W tym momencie pliki powinny składać się z następującego kodu.

'use strict';
var express = require('express');
var router = express.Router();
router.get("/", function (req, res) {
});
module.exports = router;

Jak działa routing w express.js dowiemy się tutaj https://expressjs.com/en/guide/routing.html.

Do naszego bootstrapowego pliku powinniśmy podać to, co nas interesuje. Zacznijmy od załadowania odpowiednich vendors i w miejscu, gdzie zwykło się podawać moduły dopiszmy następujące rzeczy.

var passport = require("passport");
var session = require('express-session');

a następnie odpowiednio kontrolery, dzięki którym obsłużymy część rzeczy

var chat = require('./controllers/chat');
var index = require('./controllers/index');
var auth = require('./controllers/auth');

skorzystajmy też z odpowiednich middleware’ów

app.use(session({
    secret: 'type-your-secret-here',
    cookie: {}
}));
app.use(passport.initialize());
app.use('/', index);
app.use('/auth', auth);
app.use('/chat', chat);

Let’s the fun begin

Wróćmy do naszego pliku controllers/auth.js, gdzie umieścimy następujący kod

'use strict';
var express = require('express');
var router = express.Router();
var passport = require("passport");
var twitchStrategy = require("passport-twitch").Strategy
var config = require('../config');
passport.use(new twitchStrategy({
        clientID: config.twitch.clientID,
        clientSecret: config.twitch.clientSecret,
        callbackURL: config.twitch.callbackURL,
        scope: config.twitch.scope,
    },
    function(accessToken, refreshToken, profile, done) {
        profile.access_token = accessToken;
        return done(null, profile);
    }
));
passport.serializeUser(function(user, done) {
    done(null, user);
});
passport.deserializeUser(function(user, done) {
    done(null, user);
});
router.get("/twitch", passport.authenticate("twitch"));
router.get("/twitch/callback", passport.authenticate("twitch", { failureRedirect: "/" }), function(req, res) {
    res.redirect("/");
});
module.exports = router;

Jest to standardowy kod skopiowany z dokumentacji modułu, z wykorzystaniem naszej konfiguracji i jednej rzeczy, którą dorobiłem, a mianowicie zapisania access_token do dalszego korzystania w bocie. Przyda się przy łączeniu z kanałem irc, gdzie odbywa się kolejne uwierzytelnienie.

Posiadamy też dwie metody do serializacji i deserializacji użytkownika ale na ten moment nie będziemy rozwodzić się co tam się dzieje. W callbacku, który ustawiliśmy dla aplikacji po stronie Twitcha mamy kod odpowiadający za przekierowanie zarówno po nieprawidłowym logowaniu, jak i po prawidłowym. Przekierujmy użytkownika na index.

Jak wyżej zaznaczyłem, w profilu użytkownika, a raczej jego sesji, zapisujemy access_token. Sprawdźmy więc czy odpowiednie dane tam siedzą. Do tego celu w controllers/index.js umieśćmy następujący kod:

router.get("/", function (req, res) {
    if (typeof req.session.passport !== 'undefined' && req.session.passport.user.access_token) {
        var access_token = req.session.passport.user.access_token;
    } else {
        var access_token = false;
    }
    res.render('index', {access_token: access_token});
});

a w views/index.jade, który odpowiada za widok strony głównej

extends layout
block content
    if !access_token
        a(href='/auth/twitch') Twitch auth
    else
        a(href='/chat') Join chat

Powyższy fragment ma za zadanie sprawdzić czy istnieje sesja z kluczem passport i czy użytkownik zapisany w sesji posiada swój access_token. Następnie dzięki temu wyświetlamy po stronie szablonu link do autoryzacji z Twitchem lub do dołączenia do czatu, w zależności od spełnionego warunku.

W tym momencie posiadamy odpowiedni kod do pobrania access_token, który wkorzystamy w dalszej części, a mianowicie do łączenia się z naszym kanałem irc – do odbierania i wysyłania wiadomości.

Przejdźmy do logiki bota.

Oprócz standardowej struktury, którą opisałem podczas tworzenia kontrolerów potrzebujemy odpowiednich metod do zarządzania czatem. Po pierwsze middleware, który nie pozwoli nam przejść na podany url w przypadku, kiedy nie posiadamy tokenu, a także miejsce, gdzie umieścimy logikę bota.

router.use('/', function (req, res, next) {
    if (typeof req.session.passport === 'undefined') {
        return res.redirect('/');
    }
    next()
})

router.get('/', function (req, res) {
    res.render('chat');
});

W tym przypadku w folderze views powinniśmy stworzyć kolejny widok, odpowiadający temu, co umieściliśmy wyżej, czyli chat.js (tym się w ogóle nie przejmujemy, bo nie jest nam to potrzebne).

Znów skorzystamy z dobrodziejstw open-source i wykorzystamy moduł, który wpisałem w wymagania. Załadujmy więc irc, a instancję klienta tej klasy nazwijmy po prostu bot.

var config = require('../config');
var irc = require('irc');
// …
router.get('/', function (req, res) {
    // bot instance
    var bot = new irc.Client(config.twitch.host, config.twitch.nick, {
        channels: [config.twitch.channels + " " + config.twitch.password],
        debug: true,
        password: "oauth:" + req.session.passport.user.access_token,
        username: config.twitch.nick,
        millisecondsOfSilenceBeforePingSent: 240 * 1000,
        millisecondsBeforePingTimeout: 180 * 1000,
    });
    // bot.addListener("raw", function (raw) {});
    // ask for membership
    bot.addListener("motd", function (motd) {
        bot.send('CAP REQ', 'twitch.tv/membership');
    });
    bot.addListener("join", function (channel, nickname) {
        console.log(nickname + ' just joined');
    });
    bot.addListener("ping", function () {
        // console.log('Send pong to server!');
        bot.send('PONG', 'tmi.twitch.tv');
    });
    bot.addListener("error", function (error) {
        // console.log(error);
    });
    res.render('chat');
});

Po kolei postaram się opisać dzięki czemu możemy odbierać i wysyłać wiadomości, a także w jaki sposób zdobyliśmy prawa do dowiadywania się kto dołącza do kanału.

W instancji nowej klasy irca, do jego klienta podajemy odpowiednie dane, które zaciągamy z wcześniej przygotowanej konfiguracji. Czytając dokumentację dowiadujemy się w jaki sposób powinniśmy wysłać żądanie do serwera, żeby proces autoryzacji przebiegł prawidłowo. Tutaj korzystamy z wcześniej wyciągniętego access_token. Nasze hasło to string

oauth:password

W dalszej części dokumentacji dowiedzieć się można, że aby otrzymać prawa do tego, by wiedzieć kto dołącza do kanału potrzeba jest wysłać odpowiedni request, który wygląda następująco

CAP REQ :twitch.tv/membership

W odpowiedzi otrzymamy

:tmi.twitch.tv CAP * ACK :twitch.tv/membership

co znaczy tyle, że takie prawa otrzymaliśmy.

Tutaj musimy pamiętać, że każde żądanie do serwera, każda komenda to wiadomość, którą trzeba zliczyć. Pojawiają się schody, ponieważ w podanym module nie znalazłem odpowiedniego kodu, który pozwala na taką rzecz, więc trzeba nadpisywać.

Client.prototype.send = function(command) {
    if (this.opt.debug)
        util.log('SEND: ' + args.join(' '));
};

Wpadłem na bardzo głupi, działający pomysł.

W konfiguracji klienta ustawiony został tryb debugowania, dzięki któremu w konsoli otrzymujemy różne wiadomości. Wykorzystajmy to.

console.error = console.log;
console.log = function (log) {
    if (arguments.length === 3 && arguments[2].substr(0, 4) === 'SEND') {
        // tutaj pojawi się kod do zapisywania wiadomości
    } else {
        console.error(log);
    }
};

Ten hack powoduje to, że nadpisujemy console.log swoją funkcjonalnością, a konkretnie sprawdzamy czy ilość argumentów podanych do funkcji jest równy 3 (tak jak zrobione jest to w podanym module), a następnie sprawdzany jest czy początek stringu to tekst ‘SEND’, który jest zwracany w trybie debugowania, żebyśmy wiedzieli co wysyłamy. Ten kod jest bardzo zły i nie powinniśmy tak robić. Powinniśmy nadpisać prototyp klasy Client i metody send. Powyższy fragment pokazuje tylko tyle, że zawsze wszystko da się zrobić na około.

Message handler

Następnym krokiem będzie przygotowanie logiki bota, która obsłuży nam wiadomości. Zaczniemy od tego, że stworzymy odpowiedni plik utils/message-handler.js z klasą i metodami odpowiedzialnymi za wiadomości, a także zapisywanie nicków i odczytywanie ich po to, żeby nie powtarzać powitania dla użytkowników, którzy już odwiedzili nasz stream.

'use strict';

var fs = require('fs');
var moment = require('moment');
var Array = require('../utils/array');

var MessageHandler = function (config) {

};

MessageHandler.prototype.readFile = function () {

};

MessageHandler.prototype.storeNickname = function (nickname) {

};

MessageHandler.prototype.sendAwaitingMessages = function (callback) {

};

MessageHandler.prototype.rememberNickname = function (nickname, callback) {

};

MessageHandler.prototype.randomMessage = function (nickname, callback) {

};

MessageHandler.prototype.batchMessages = function (message, nickname) {

};

// store messages that been already sent
MessageHandler.prototype.storeMessage = function (message) {

};

// clear all messages which are older than 30 seconds
MessageHandler.prototype.clearMessages = function () {

};

module.exports = MessageHandler;

Nasz prototyp klasy będzie zawierał odpowiednio:

  • Odczyt pliku
  • Zapis loginu użytkownika do pliku
  • Wysłanie wiadomości, które oczekują w kolejce do wysłania
  • Wybranie losowej wiadomości z pliku
  • Zapisanie wiadomości do oczekujących wiadomości do wysłania
  • Wyczyszczenie wiadomości starszych niż czas, który ustawiliśmy w konfiguracji dla ilości wiadomości, żeby zapobiec dostaniu bana

Będziemy potrzebować funkcji, która sprawdzi czy w tablicy jest przechowana odpowiednia wartość. Osiągniemy ten cel dodając do prototypu klasy Array nową metodę. W pliku utils/array.js zamieścimy

'use strict';

Array.prototype.contains = function(k, callback) {
    var self = this;
    return (function check(i) {
        if (i >= self.length) {
            return callback(false);
        }
        if (self[i] === k) {
            return callback(true);
        }
        return process.nextTick(check.bind(null, i+1));
    }(0));
};

module.exports = Array;

A w pliku odpowiadającym za odczytywanie wiadomości uprzednio umieściliśmy odpowiednie odwołanie do klasy.

Inicjując klasę handlera wiadomości musimy pamiętać o trzech parametrach. Jeden to konfiguracja, którą wczytujemy z pliku config.js i dwie publiczne zmienne przechowujące nasze wiadomości.

var MessageHandler = require('../utils/message-handler');

var MessageHandler = function (config) {
    this.config = config;
    this.messages = [];
    this.awaitingMessages = [];
};

W pliku odpowiadającym za komunikację z czatem powinniśmy następnie dodać

var messageHandler = new MessageHandler(config);

co pozwoli nam odwoływać się do odpowiednich metod.

Pierwszą rzeczą, jaka teraz będzie nas interesowała to przechwycenie nazwy użytkownika, który dołącza do czatu. Wg dokumentacji wiadomości są zapisywane w cache i wysyłane co 10 sekund, po to, żeby odciążyć serwer. Z tego względu nie zawsze, niestety, zdążymy wysłać użytkownikowi wiadomości.

Due to caching, events are not sent to a channel immediately; instead, they are batched up and sent every 10 seconds.

W body callbacka, który wykonuje się, gdy nadejdzie event o dołączeniu użytkownika odwołajmy się do dwóch metod

  • Zapisanie nicku
  • Kolejny callback do pobrania wiadomości i powiązania go z danym użytkownikiem

Żeby wiedzieć czy nick należy zapisać musimy odczytać plik, a zrobimy to dzięki modułowi filesystem i kawałku prostego kodu.

MessageHandler.prototype.readFile = function () {
    try {
        return fs.readFileSync(this.config.file.path).toString();
    } catch (err) {
        switch (err.errno) {
            case -2:
                // create blank file if does not exists
                fs.writeFileSync(this.config.file.path, '');
                return '';
                break;
            default:
                throw (err);
                break;
        }
    }
};

Próbujemy otworzyć plik (co ważne, w tym akurat przypadku robimy to synchronicznie), a w razie niepowodzenia przechwytujemy wyjątek i sprawdzamy nr błędu. Jeżeli wyjątek posiada nr błędu -2, wiemy, że plik nie istnieje więc go tworzymy z pustą zawartością, inaczej zwracamy wyjątek, więc bot się wyłoży.

By móc korzystać z kontekstu klasy message handlera do callbacka, który przekazujemy w rememberNick powinniśmy zbindować naszą instancję.

Wygląda to następująco

bot.addListener("join", function (channel, nickname) {
    // user just joined to room, remember his name
    messageHandler.rememberNickname(nickname, messageHandler.randomMessage.bind(messageHandler));
});

MessageHandler.prototype.rememberNickname = function (nickname, callback) {
    var self = this;
    this.readFile().split(this.config.file.separator).contains(nickname, function (nicknameExists) {
        if (!nicknameExists) {
            self.storeNickname(nickname);
            callback(nickname, self.batchMessages.bind(self));
        }
    });
};

Gdzie this to kontekst klasy MessageHandler. W tym momencie odwołujemy się do naszego readFile(), który odpowiada za odczyt i zwrócenie zawartości pliku. To, co zostało zwrócone dzielimy na tablicę, a naszą funkcją contains, którą niedawno stworzyliśmy przeszukujemy tablicę pod kątem nazwy użytkownika. Dalej prosta droga do zapisania nazwy, wywołanie callbacka, który, np. wyśle wiadomość (w tym wypadku wylosuje wiadomośc, a później przekaże daną wiadomość do następnej funkcji).

MessageHandler.prototype.randomMessage = function (nickname, callback) {
    fs.readFile(this.config.file.welcome, function (err, data) {
        var lines = data.toString().split('\n');
        // get random line
        var message = lines[Math.floor(Math.random() * lines.length)];
        message = message.replace('%who%', '@' +nickname);
        callback(message, nickname);
    });
};

MessageHandler.prototype.batchMessages = function (message, nickname) {
    this.awaitingMessages.push({'text': message, 'to': nickname});
};

W tym przypadku odczytujemy plik, każdą linię dzielimy i tworzymy tablicę. Następnie pobieramy losową wartość z tablicy, a string, który wybraliśmy podmieniamy na nazwę użytkownika. Następnie wiadomość ląduje w naszej zmiennej publicznej zadeklarowanej na początku w konstruktorze jako wiadomość oczekująca na wysłanie.

Jak wcześniej pisałem, musimy pamiętać o zapisaniu każdej wysłanej wiadomości, żeby móc je zliczyć, z czasem, w którym ją wysłaliśmy.

Prototyp tej klasy wygląda następująco

MessageHandler.prototype.storeMessage = function (message) {
    this.messages.push({'time': moment(), 'message': message});
};

Ostatnia rzecz, jaka nam została to wysłanie wiadomości czyli wybranie jej z wiadomości oczekujących na wysłanie, a także wyczyszczenie tablicy ze starszymi wiadomościami po to, żebyśmy mieli pod kontrolą ich ilość.

MessageHandler.prototype.sendAwaitingMessages = function (callback) {
    if (this.messages.length < this.config.twitch.messagesLimit && this.awaitingMessages.length) {
        // get first awaiting message
        callback(this.awaitingMessages.shift());
    }
};

Po kolei trzeba sprawdzić czy ilość wysłanych wiadomości nie przekroczyła limitu, jaki ustaliliśmy i czy w ogóle jakieś wiadomości oczekują na wysłanie. Jeżeli tak, to pobieramy najstarszą wiadomość, czyli pierwszą wartość z tablicy.

Do tej pory nie uzupełniliśmy kodu związanego z zapisywaniem wysłanych wiadomości. Wróćmy do funkcji nadpisującej console.log i w sekcji odpowiadającej za wykonanie kodu po prawidłowym wykonaniu warunku umieśćmy

messageHandler.storeMessage(arguments[2]);

a w handlerze

MessageHandler.prototype.storeMessage = function (message) {
    this.messages.push({'time': moment(), 'message': message});
};

Dobra, ale w takim razie w jaki sposób wywołać wysyłanie wiadomości?

Wracamy do naszego kontrolera, a pod instancją bota dodajemy następujący kod

setInterval(function () {
    messageHandler.sendAwaitingMessages(function (message) {
        bot.say(config.twitch.channels[0], message.text);
    });
}, 1000);

Jest to timer, który co sekundę wykonuje funkcję, którą mu przekażemy. W tym przykładzie wykonujemy metodę, o której przed chwilą pisałem, a callbackiem jest funkcja, która jako argument przyjmuje wiadomość do wysłania. Następnie wysyłamy wiadomość na czat. Voilà.

Pamiętając o tym, że w warunku do oczekujących wiadomości sprawdzamy ilość wysłanych, musimy też zadbać o to, żeby wiadomości starsze niż pewien określony czas zostały wyczyszczone z pamięci.

Do zrobienia jest to samo co w przypadku wysyłania oczekujących wiadomości. Trzeba napisać prototyp

// clear all messages which are older than 30 seconds
MessageHandler.prototype.clearMessages = function () {
    if (this.messages.length) {
        for (var i in this.messages) {
            var message = this.messages[i];
            if (message.time < moment().subtract(30, 'seconds')) {
                console.log('Deleting ' + i + ' message');
                delete this.messages[i];
            }
        }
    }
};

I go odpalić

// clear messages
setInterval(function () {
    messageHandler.clearMessages();
}, 1000);

Tak skonstruowany program pozwoli nam zwracać użytkowników, którzy aktualnie dołączają do kanału po to, by przywitać ich wcześniej przygotowanymi wiadomościami. To z kolei poskutkuje tym, że nawiążemy pierwszą interakcję z użytkownikiem, który postanowił odwiedzić nasz kanał. W ten sposób nie musimy śledzić listy osób na kanale, ponieważ wszystko dzieje się automatycznie.

Bot PHP Selenium

Tym razem nie będziemy potrzebować api, by osiągnąć zamierzony cel. To, że będziemy wysyłać wiadomości udostępni nam Selenium z biblioteką WebDrivera od Facebooka.

Zakładam, że wiemy jak korzystać z composera. Stwórzmy więc podstawową konfigurację potrzebnych nam bibliotek.

{
  "require": {
    "symfony/console": "v3.2",
    "facebook/webdriver": "^1.4"
  },
  "autoload": {
    "psr-4": {
      "Command\\": "command/"
    }
  }
}

Po poprawnej instalacji obu rzeczy, nic nie stanie nam na przeszkodzie do pisania dalszego kodu. Może…

Tak naprawdę ile razy pisałem jakąś rzecz dla siebie, która zautomatyzuje mi pewne czynności, tyle razy miałem problem z driverem do mozilli i wersją odpowiedniego serwera selenium. W tym przykładzie użyłem wersji geckodriver-v0.16.1-linux64.tar.gz z https://github.com/mozilla/geckodriver/releases, a z pomocą odpowiednich artykułów https://github.com/SeleniumHQ/selenium/issues/3630#issuecomment-285636532 i https://toolsqa.com/selenium-webdriver/how-to-use-geckodriver/
wybrałem wersję, pasującą do drivera. https://www.seleniumhq.org/download/ wersja 3.4.0. Plik geckodriver umieściłem w głównym katalogu bota ale nic nie stoi na przeszkodzie, żeby podmienić sobie go w systemie.

Serwer Selenium włączamy pod konsolą

java -Dwebdriver.gecko.driver="./geckodriver" -jar ./selenium-server-standalone-3.4.0.jar

Tak naprawdę cała logika bota nie jest skomplikowana, a większość rzeczy załatwi nam znajomość odpowiedniego pobierania Xpath ze źródła strony, by móc odpowiednio przechodzić po elementach DOM.

Nasz bootstrap bota, który znajduje się w głównym katalogu projektu, prezentuje się tak

require __DIR__.'/vendor/autoload.php';
use Symfony\Component\Console\Application;
$application = new Application();
$application->add(new \Command\BotCommand());
$application->run();

Jak widać kolejną rzeczą będzie napisanie odpowiedniego command, w którym wywołamy to, co nas interesuje.

namespace Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;

class BotCommand extends Command
{
    private $host = 'https://0.0.0.0:4444/wd/hub';
    private $driver;
    private $name = '';
    private $password = '';
    private $log = 'log.txt';
    private $messages = 'messages.txt';

    public function __construct()
    {
        parent::__construct();

        $capabilities = new DesiredCapabilities();
        $capabilities->setBrowserName('firefox');
        $this->driver = RemoteWebDriver::create($this->host, $capabilities);
    }

    protected function configure()
    {
        $this
            ->setName('bot')
            ->setDescription('Send welcome messages to users just joined')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
    }
}

Już teraz wiemy, jakie zmienne będą potrzebne. Musimy przechować host naszego serwera Selenium (bierzemy go po włączeniu serwera, wyświetli nam się informacja w konsoli), stworzymy WebDriver już w konstruktorze. Nazwe naszego konta i hasło do niego przechowamy w zmiennej prywatnej (chociaż takie rzeczy powinniśmy zaciągać z pliku konfiguracji, np. stworzonego w yaml), a także pliku z nazwami użytkownika, którzy odwiedzili nasz stream i wiadomościami, jakie chcemy do nich wysłać – skorzystajmy z tych samych co w bocie z node.js.

Przejdźmy do odpowiedniego uzupełniania kodu. Tutaj, nie tak jak w poprzednim bocie, wszystko wykona się po kolei, więc kod, który napiszemy powinien odwzorować to, jak poruszamy się po serwisie.

Na początek zalogujmy się.

$this->driver->get('https://www.twitch.tv/login');
// wait for presence elements
(new WebDriverWait($this->driver, 10))->until(WebDriverExpectedCondition::presenceOfElementLocated(
    WebDriverBy::id('loginForm')
));
$this->driver->findElement(WebDriverBy::xpath('//*[@id="username"]'))->sendKeys($this->name);
$this->driver->findElement(WebDriverBy::xpath('//*[@id="password"]/input'))->sendKeys($this->password);

Po wejściu na stronę logowania czekamy na załadowanie formularza. Twitch na frontendzie ma ember, co nieco komplikuje sprawę i musimy wiedzieć jak poruszać się po takiej stronie.

W tym przypadku czekamy 10 sekund na pojawienie się odpowiedniego elementu, z takim id jakiego potrzebujemy. Jeżeli po 10 sekundach nie pojawi się formularz, nasz skrypt wyrzuci wyjątek. Po pojawieniu się formularza wyszukujemy dwóch pól do wpisania wartości nazwy użytownika i hasła.

Tutaj moja uwaga, że na początku miałem obsłużone też odpowiednie naciśnięcie na button, jednak zrezygnowałem z tego i zostawiłem odpowiednio dużo czasu na manualne obsłużenie naciśnięcia logowania. Czemu tak? Ponieważ po wielu logowaniach na stronie wyskakuje re-captcha, którą niestety obsłużymy ręcznie. Wg mojego rekonesansu da się to obejść, np. pisząc odpowiednią wtyczkę w javie, która będzie nam robiła screenshot captcha, a jakiś serwis zewnętrzny nam to obsłuży ale musimy pominąć ten wątek.

Czas potrzebny na manualne naciśnięcie przycisku, obsłużmy w ten sposób.

(new WebDriverWait($this->driver, 15))->until(WebDriverExpectedCondition::titleIs('Twitch'));

Kolejną rzeczą, jaką będziemy musieli zrobić, to wejście na nasz kanał i poczekanie na załadowanie się okna czatu. Tak jak wcześniej pisałem, Twitch oparty jest o front na Emberze, więc poczekajmy na odpowiednie załadowanie elementów i zlokalizujmy okno czatu na kanale.

$this->driver->manage()->timeouts()->pageLoadTimeout(30);
$this->driver->get(sprintf('https://www.twitch.tv/%s', $this->name));
(new WebDriverWait($this->driver, 15))->until(WebDriverExpectedCondition::presenceOfElementLocated(
    WebDriverBy::xpath('//*[@class="message-line chat-line admin ember-view"]/div/span[5]')
));

Pozostało nam zrobić nieskończoną pętlę, w której obsłużymy wysyłanie wiadomości.

private function loop()
{
    $messages = file($this->messages);
    $filesize = filesize($this->log);
    $current = explode(';', file_get_contents($this->log));
    while (1) {
        // new users
        if (filesize($this->log) !== $filesize) {
            $justJoined = explode(';', file_get_contents($this->log));
            $difference = array_diff($justJoined, $current);
            foreach ($difference as $nickname) {
                $this->driver->findElement(WebDriverBy::xpath('//*[@class="js-chat-input chat-input ember-view"]/textarea'))->sendKeys(sprintf(
                    '/w %s %s', $nickname, $messages[rand(0, count($messages) - 1)]
                ));
                $this->driver->findElement(WebDriverBy::xpath('//*[@class="js-chat-interface chat-interface__wrap ember-view"]/div[3]/button'))->click();
                sleep(rand(5,10));
            }
            $filesize = filesize($this->log);
            $current = $justJoined;
        }
        // https://stackoverflow.com/questions/7664879/using-filesize-php-function-in-a-loop-returns-the-same-size
        clearstatcache();
        sleep(5);
    }
}

Po kolei odczytujemy wiadomości do wylosowania z wcześniej przygotowanego pliku. Sprawdzamy wielkość pliku z nazwami użytkowników, którzy dołączyli do kanału. W tym momencie wiemy ile waży plik, a dzięki temu, że działamy w pętli, sprawdzamy co kolejne wykonanie jego wartość i porównujemy czy się nic nie zmieniło. Tak jak w przypadku poprzedniego bota (bo to dzięki niemu wiemy kto dołączył do kanału), każda wartość nazwy użytkownika jest podzielona przez średnik, więc wszystkie wartości możemy zapisać w pamięci jako tablica i porównać je funkcją array_diff. Na samym końcu każdemu dopiero co dołączonemu użytkownikowi wysyłamy wylosowaną wiadomość. Zwróć uwagę, że wiadomość wysyłam przez zwykłe okno czatu odpowiednią komendą /w nickname wiadomość. Pamiętamy też o odpowiedniej symulacji losowości czasu.

W razie pytań zostaw wiadomość, a z tego miejsca zachęcam wszystkich do zabawy z zewnętrznymi api do tworzenia ciekawych narzędzi i nauki tego, jak to wszystko się zachowuje.