Darf ich vorstellen? Mein Twitter-Bot “Crypto Status”: Heute gebe ich mal einen ausführlichen Einblick hinter die Kulissen meines Bots, der auf Twitter unter @status_crypto unterwegs ist. Wie sich schon aufgrund des Namens vermuten lässt, handelt es sich dabei um einen Twitter-Account, welcher automatisiert die aktuellen Kurse von Kryptowährungen postet.
In diesem Artikel werde ich zunächst ein wenig allgemeiner an das Thema herantreten und erläutern, was man überhaupt unter einem Twitter-Bot verstehen kann und was dieser für nützliche Funktionen aber auch Probleme mit sich bringt. Anschließend erzähle ich euch Näheres zu meinem Projekt “Crypto Status” und beleuchte die Hintergründe hinter der Entwicklung des gleichnamigen Twitter-Bots. Da ich ja eigentlich hauptsächlich über technische Sachverhalte schreibe, darf eine genaue Erläuterung der Technik, auf der der Bot beruht, natürlich auch nicht fehlen. Einen besonderen Fokus legen wir dabei auf OOP sowie Deployment mit einem Service namens Heroku. Zuletzt gibt es, wie immer, noch ein kurzes Fazit, indem ich nochmal zusammenfassend auf das Projekt zurückblicke. Ich wünsche euch viel Spaß!
Was ist ein Twitter-Bot?
Auf Wikipedia findet man folgende Definition für den Begriff “Bot”:
Unter einem Bot (von englisch robot ‚Roboter‘) versteht man ein Computerprogramm, das weitgehend automatisch sich wiederholende Aufgaben abarbeitet, ohne dabei auf eine Interaktion mit einem menschlichen Benutzer angewiesen zu sein.
Wikipedia - Bot
Dies ist natürlich noch sehr allgemein formuliert und lässt sich weiter eingrenzen, schließlich handelt es sich bei dem Kurznachrichtendienst Twitter um ein soziales Netzwerk. Ein Twitter-Bot ist demnach ein sogenannter “Social Bot”, welcher komplett neutral betrachtet erstmal einfach für die automatische Absetzung von Nachrichten in den sozialen Medien eingesetzt wird, in unserem Fall auf der Plattform Twitter.
Social Bots und ihre vielfältigen (negativen) Einsatzmöglichkeiten
Leider beschränkt sich die Nutzung von Bots allgemein, und damit auch die von Social Bots, aber nicht auf stets positive Anwendungsfälle. Stattdessen existieren besonders auf Twitter zahlreiche Bots, welche auf bestimmte Hashtags reagieren und damit verbundene Informationen bzw. Nachrichten verbreiten. Hierfür werden beispielsweise besonders realistisch aussehende Accounts inkl. Profilbild, Beiträgen und Followern erschaffen, diese selbst auch anderen Nutzern folgen. Das Ziel dieser Bots ist meist die Verbreitung von Werbung sowie das Vortäuschen von Mehrheiten. Ebenso beliebt sind sie bei der Stimmungsmache für oder gegen jemanden bzw. etwas. So werden Social Bots letztendlich häufig eingesetzt, um politische Propaganda im Sinne ihrer Auftraggeber zu verbreiten.
In die Öffentlichkeit rückte das Thema vor allen Dingen ab dem Jahr 2016. So wurden z.B. während des Wahlkampfs für das EU-Mitgliedschaftsreferendum im Vereinigten Königreich im umfangreichen Ausmaß Social Bots eingesetzt, um für den sogenannten “Brexit” zu werben. Das Gleiche gilt für den Wahlkampf der US-Präsidentschaftswahl zwischen Donald Trump und Hillary Clinton.
Bot oder Mensch? Gar nicht so einfach!
Das große Problem an dieser Geschichte ist auch, dass sich Bots kaum noch von regulären Accounts unterscheiden lassen. So erweist es sich für den Ottonormalverbraucher als äußerst schwierig zu erkennen, ob ein Beitrag in sozialen Medien wie Twitter von einem tatsächlichen Mensch oder doch einer Maschine stammt. Aus diesem Grund werden soziale Bots auch von einigen als Gefährdung für den freien Meinungsbildungsprozess in einer Demokratie eingestuft, wobei manche Experten diese Einschätzung auch für überzogen halten.
Wie immer gibt es zwei Seiten der Medaille
Letztlich ist es aber wie mit so ziemlich allem: Alles hat seine Vor- und Nachteile, sodass man nie Schwarz-Weiß-Malerei betreiben, sondern stets beide Seiten der Medaille genauestens unter die Lupe nehmen sollte.
Folgendes Zitat bringt diese Situation ganz gut auf den Punkt:
Bei allen Risiken und Herausforderungen dürfen die vielfältigen Potenziale und Anwendungsmöglichkeiten der zu Grunde liegende Bot-Technologie nicht unberücksichtigt bleiben. In der Medien- und Verlagsbranche erlauben die Algorithmen das automatisierte Verfassen von Beiträgen und die Übernahme lästiger Routineaufgaben. [...] Auch kann automatisierte Kommunikation zur Förderung von gesellschaftlich wünschenswerten Verhaltensweisen [...] oder zur Information der Öffentlichkeit beitragen. [...] Durch die Integration der Technologien in smarte Assistenten und neue Interfaces werden Bots zum täglichen Begleiter.
Jan Dennis Gumz und Resa Mohabbat Kar vom Kompetenzzentrum Öffentliche IT in der Trendschau "Social Bots"
Nun nochmal konkret: Das ist ein Twitter-Bot
Für diejenigen, welche meine Erläuterungen rund um den Begriff “Twitter-Bot” nun noch zu unkonkret fanden bzw. gerne eine kurze Definition hätten, welche ein wenig mehr die technische Seite beleuchtet, kann ich noch Folgendes anbieten:
A Twitter bot is a type of bot software that controls a Twitter account via the Twitter API. The bot software may autonomously perform actions such as tweeting, retweeting, liking, following, unfollowing, or direct messaging other accounts.
Wikipedia - Twitter bot
Mein Twitter-Bot “Crypto Status”
Kommen wir nun zu meinem Twitter-Bot “Crypto Status”: Die Idee zu diesem Projekt kam mir im Januar dieses Jahres und war eher spontaner Natur. So entstand die Idee mehr oder weniger an einem freien Tag, an dem ich relativ viel Zeit hatte und nicht wirklich wusste, was ich mit dieser Zeit anfangen sollte. Da ich mich rund um die Weihnachtstage und den Jahreswechsel relativ stark mit dem Thema Kryptowährungen auseinandergesetzt hatte und mich immer wieder dabei ertappen konnte, wie ich die Kurse von Bitcoin und Co. beobachtete, kam mir die Idee, dass es doch eigentlich sehr praktisch wäre, die aktuellen Entwicklungen stets in der Timeline auf Twitter verfolgen zu können.
Ich überlegte nicht lange, sondern machte mich sofort auf die Suche nach einer vernünftigen API. Die Suche dauerte nicht lange an und ich landete bei der API der im Crypto-Geschäft sehr populären Website coinmarketcap.com. Die API dort erwies sich als nahezu perfekt für meinen Anwendungsfall und ich setzte mich noch am selben Tag hin, eröffnete also einen entsprechenden Twitter-Account inkl. Twitter-App.
Das Ziel war es somit, einen Twitter-Bot zu schaffen, welcher stündlich den aktuellen Preis in US-Dollar sowie die neuesten Tendenzen in Prozent der Top-Zehn-Kryptowährungen veröffentlicht.
Damit man sich das besser vorstellen kann, ist hier nun ein Beispiel-Tweet, welcher von meinem Bot abgesetzt wurde:
#HourlyCryptoStatus (#1 to #3):
— Crypto Status (@status_crypto) 26. März 2018
#1 #BTC (#Bitcoin): 7,955.11 USD | 1 BTC | -0.12% 1h
#2 #ETH (#Ethereum): 471.53 USD | 0.059627 BTC | -0.4% 1h
#3 #XRP (#Ripple): 0.59 USD | 0.000075 BTC | -0.01% 1h
Zu Beginn sah dies natürlich noch ein wenig anders aus. Ich habe also nach dem Release noch ein paar Änderungen unternommen. Das Prinzip hinter meinem Bot sollte aber klar werden: Aufgeteilt in 3 Tweets wird ungefähr zu jeder vollen Stunde ein neues Update in diesem Format abgesendet, sodass die Follower meines Bots stets auf dem Laufenden sind, was die aktuellen Kursentwicklungen angeht.
So bin ich bei der Entwicklung meines Bots vorgegangen
Kommen wir nun zur Entwicklung meines Bots. Aufgrund der Tatsache, dass es sich dabei um ein sehr spontanes Projekt von mir handelte, könnte man vermuten, dass der Bot schlicht aus einer Datei besteht, in der alles im prozeduralen Stil runtergeschrieben wurde. Dies ist allerdings nicht der Fall, da es mir wichtig war, eine gut strukturierte Anwendung zu entwickeln. Somit lag es also auf der Hand, auf objektorientiertes PHP zurückzugreifen.
Für die Versendung der Tweets, also der Interaktion mit der Twitter API, habe ich mich für die Library Codebird entschieden und die Bereitstellung der Anwendung übernimmt bei mir Heroku in der kostenlosen Version. Dazu aber später noch mehr.
Ansonsten darf man natürlich nicht vergessen, sich unter apps.twitter.com eine Twitter App zu erstellen, mit der man dann auf die Twitter API zugreifen kann, schließlich hat man sonst keine Zugangsdaten.
Das ist der Code: Dank OOP gut strukturiert
Nun schauen wir uns erstmal den Aufbau meines Bots inkl. den Code
an: Von der Struktur habe ich meinen Bot im Grunde so aufgebaut,
dass ich innerhalb der Datei (app.php), welche letztendlich
ausgeführt wird, um ein neues Crypto-Status-Update zu posten, nur
alle Abhängigkeiten einbinde und eine neue Instanz meiner
Hauptklasse CryptoStatus
erstelle.
Anschließend wird die Methode init()
dieser Klasse
ausgeführt. Wie der Name schon vermuten lässt, sorgt diese für die
Initialisierung der Anwendung. Konkret werden dort Instanzen der
anderen Klassen, welche die unterschiedlichen Aufgabenbereiche
abdecken, erzeugt. In diesem Teil wird meine App also praktisch
“hochgefahren”.
Darauf ist dann alles bereit und ich führe nur noch die
run()
-Methode der CryptoStatus
-Klasse aus.
Diese sorgt letztendlich dafür, dass die nötigen Daten von der
CoinMarketCap-API
eingeholt werden, diese Daten dann verarbeitet werden und die Tweets
über die
Twitter API
abgesetzt werden.
So sieht der Code der Datei app.php
aktuell aus:
<?php
/**
* A simple Twitter bot application which posts hourly status updates for the top 10 cryptocurrencies.
*
* PHP version >= 7.0
*
* LICENSE: MIT, see LICENSE file for more information
*
* @author JR Cologne <kontakt@jr-cologne.de>
* @copyright 2018 JR Cologne
* @license https://github.com/jr-cologne/CryptoStatus/blob/master/LICENSE MIT
* @version v0.2.1
* @link https://github.com/jr-cologne/CryptoStatus GitHub Repository
*
* ________________________________________________________________________________
*
* app.php
*
* The main application file
*
*/
require_once 'vendor/autoload.php';
use CryptoStatus\BugsnagClient;
use CryptoStatus\CryptoStatus;
// initialize error handling
$bugsnag_client = new BugsnagClient;
$app = new CryptoStatus;
// initialize app
$app->init();
// run app
$app->run();
Mithilfe des üblichen Autoloaders von Composer werden alle Dependencies eingebunden. Anschließend werden die richtigen Namespaces angegeben.
Die Klasse BugsnagClient
ist dabei für das Error
Handling mithilfe des Services
Bugsnag
zuständig. Näher darauf eingehen werde ich in diesem Fall jetzt
nicht, da die Fehlerbehandlung mit
Bugsnag
keine wesentliche Funktionalität meines Twitter-Bots darstellt,
sondern nur für eine leichtere Verfolgung und Verwaltung möglicher
Fehler, die mit der Zeit auftreten könnten, sorgt.
Im Anschluss wird dann auch schon ein Objekt der
CryptoStatus
-Klasse erzeugt, wovon erst die Methode
init()
und darauf die Methode
run()
ausgeführt wird.
Damit ist bereits alles abgeschlossen. Die tatsächliche
Funktionalität versteckt sich also hauptsächlich in den
entsprechenden Klassen, während die Datei
app.php
letztendlich nur für den nötigen Anstupser
sorgt, damit alles wie gewünscht abläuft.
Nun schauen wir uns genau diese Klassen an. Wir beginnen mit der
Hauptklasse CryptoStatus
.
Die Hauptklasse CryptoStatus
Die Klasse CryptoStatus
stellt praktisch den Kern der
Anwendung dar, schließlich wird von dort aus auf die anderen Klassen
zugegriffen, sodass die einzelnen Prozesse ins Rollen kommen.
Innerhalb der öffentlichen init()
-Methode werden die
benötigten Klassen instanziiert.
Hier ist der Code der Methode:
/**
* Initialize application
*/
public function init() {
$this->twitter_client = new TwitterClient(Codebird::getInstance());
$this->crypto_client = new CryptoClient(new CurlClient, [
'api' => CRYPTO_API,
'endpoint' => CRYPTO_API_ENDPOINT,
'params' => [
'limit' => CRYPTO_API_LIMIT
]
]);
}
Wie im Code erkennbar ist, wird zuerst ein Objekt vom
TwitterClient
erzeugt und in einer Property
gespeichert. Der Konstruktor bekommt dabei eine
Codebird
-Instanz übergeben.
Darauf passiert dann dasselbe mit dem CryptoClient
, nur
dass dieser meinen CurlClient
inkl. ein Array mit ein
paar Einstellungen für die API eingereicht bekommt. Mithilfe des
cURL-Clients wird also letztendlich ein Request an die API geschickt,
welche uns die Daten der Kryptowährungen bereitstellt.
Entgegengenommen werden diese Daten vom CryptoClient
.
Auch dazu aber später mehr, wenn wir uns die entsprechenden Klassen anschauen.
Gehen wir nun zur run()
-Methode über. Diese sorgt im
Prinzip dafür, dass die Funktionalitäten unserer App auch ausgeführt
werden.
Dies ist der Code:
/**
* Run the application
*/
public function run() {
$this->dataset = $this->getDataset();
$this->formatData();
$tweets = $this->createTweets();
if (!$this->postTweets($tweets)) {
$this->deleteTweets($this->failed_tweets);
}
}
Als Erstes rufen wir die Methode getDataset()
unser
CryptoStatus
-Klasse auf. Wie der Name schon vermuten
lässt, werden dadurch alle Daten in Empfang genommen und in der
Property $dataset
gespeichert. Hier ist die Methode
getDataset()
:
/**
* Get the Crypto data
*
* @return array
*/
protected function getDataset() : array {
return $this->crypto_client->getData();
}
Im Grunde macht diese nichts anderes, als einfach nur den
Rückgabewert der Methode getData()
des
CryptoClient
’s zurückzugeben.
Im Anschluss werden die Daten dann innerhalb der
run()
-Methode nach unseren Wünschen formatiert.
Ausgeführt wird dies durch den Aufruf der Methode
formatData()
, welche auch Teil der Hauptklasse ist:
/**
* Format the Crypto data to an array of strings
*
* @throws CryptoStatusException if Crypto data is missing
*/
protected function formatData() {
$this->dataset = array_map(function (array $data) {
if (isset($data['rank'], $data['symbol'], $data['name'], $data['price_usd'], $data['price_btc'], $data['percent_change_1h'])) {
$data['name'] = $this->camelCase($data['name']);
$data['price_usd'] = $this->removeTrailingZeros(number_format($data['price_usd'], 2));
$data['price_btc'] = $this->removeTrailingZeros(number_format($data['price_btc'], 6));
return "#{$data['rank']} #{$data['symbol']} (#{$data['name']}): {$data['price_usd']} USD | {$data['price_btc']} BTC | {$data['percent_change_1h']}% 1h";
}
throw new CryptoStatusException('Crypto data is missing', 1);
}, $this->dataset);
}
Das Ergebnis wird einfach direkt in der Property
$dataset
gespeichert, damit keine unnötigen Parameter
und Zuweisungen nötig sind. In dem speziellen Fall ist dies völlig
in Ordnung, da es sich um eine Methode handelt, die sicher nicht für
andere Anwendungsfälle verwendet wird, sondern nur in diesem
bestimmten Fall.
Allgemein sollte man ansonsten aber eher darauf verzichten, innerhalb einer Methode, welche Daten manipuliert, direkt auf die Properties einer Klasse zuzugreifen. Stattdessen würde man die Daten als Parameter erwarten und anschließend, nach der Manipulation, einfach zurückgeben. Das macht das Ganze deutlich flexibler.
Zur Erläuterung der Verarbeitung der Daten sollte ich noch erwähnen,
wie die Funktion
array_map()
funktioniert. Im Grunde ermöglicht diese, die Anwendung einer
Callback-Funktion auf jedes Element eines Arrays. So erwartet die
Funktion als ersten Parameter eine Funktion. Diese kann
beispielsweise einfach direkt innerhalb des Funktionsaufrufs als
anonyme Funktion deklariert werden. Alternativ lässt sich auch der
Name einer Funktion als String übergeben, oder eine Variable, welche
eine anonyme Funktion beinhaltet, ist ebenfalls zulässig. Der zweite
Parameter ist dann das Array, welches manipuliert werden soll.
Innerhalb der Callback-Funktion wird dann das jeweilige Element des
Arrays bearbeitet, indem dieses von der Funktion
array_map()
praktisch als Argument in die Callback-Funktion eingepflegt wird,
und nach der gewünschten Bearbeitung aus der Callback-Funktion
wieder zurückgegeben wird.
In unserem Fall ist das jeweilige Element des Arrays die Variable
$data
. Als neues Element wird dann der String, welcher
letztendlich auch im Tweet erscheint, aus der Callback-Funktion
zurückgegeben. Dieser Rückgabewert wird dann von der Funktion
array_map()
entgegengenommen und in dem angegebenen Array (z.B. die Property
$dataset
) abgelegt. Anders ausgedrückt werden die alten
Daten an dieser Stelle mit dem Rückgabewert der Callback-Funktion
überschrieben.
Soweit so gut, nun sind unsere Daten formatiert und wir müssen nur noch die drei Tweets versenden.
Ich erinnere nochmal an unsere run()
-Methode:
/**
* Run the application
*/
public function run() {
$this->dataset = $this->getDataset();
$this->formatData();
$tweets = $this->createTweets();
if (!$this->postTweets($tweets)) {
$this->deleteTweets($this->failed_tweets);
}
}
Während die ersten beiden Schritte bereits abgeschlossen sind,
kommen wir jetzt zur Methode createTweets()
, dessen
Rückgabewert in der Variable $tweets
gespeichert wird.
Die Methode createTweets()
sieht folgendermaßen aus:
/**
* Create the Tweets with Crypto data and return them as an array
*
* @return array
*/
protected function createTweets() : array {
$tweets = [];
$start_rank = 1;
$end_rank = 3;
$length = 3;
for ($i = 0; $i < 3; $i++) {
$tweets[$i] = "#HourlyCryptoStatus (#{$start_rank} to #{$end_rank}):\n\n";
$tweets[$i] .= implode("\n\n", array_slice($this->dataset, $start_rank - 1, $length));
$start_rank += 3;
$end_rank += 3;
if ($i == 1) {
$end_rank++;
$length++;
}
}
return $tweets;
}
In der Methode passiert noch nicht viel mehr, als dass die Daten zu den Kryptowährungen nun endgültig in das Format der drei Tweets arrangiert werden. Das Ergebnis ist ein Array mit drei Strings. Jeder String ist der Text eines Tweets.
Dies alles wird zurückgegeben und in unserer Variable
$tweets
in der run()
-Methode
abgespeichert.
Nun fehlt uns noch folgender Teil:
if (!$this->postTweets($tweets)) {
$this->deleteTweets($this->failed_tweets);
}
Zuallererst wird die Methode postTweets()
mit unserem
Array der Tweets ausgestattet und ausgeführt. Diese sieht so aus:
/**
* Post the specified Tweets
*
* @param array $tweets The Tweets to post
* @return bool
*/
protected function postTweets(array $tweets) : bool {
$last_tweet_id = null;
for ($i = 0; $i < 3; $i++) {
if ($last_tweet_id) {
$tweet = $this->twitter_client->postTweet([
'status' => '@' . TWITTER_SCREENNAME . ' ' . $tweets[$i],
'in_reply_to_status_id' => $last_tweet_id
], [ 'id' ]);
} else {
$tweet = $this->twitter_client->postTweet([
'status' => $tweets[$i]
], [ 'id' ]);
}
if (isset($tweet['id'])) {
$tweet_ids[] = $last_tweet_id = $tweet['id'];
} else {
break;
}
}
if (count($tweet_ids) == 3) {
return true;
} else {
$this->failed_tweets = $tweet_ids;
return false;
}
}
Mithilfe des TwitterClient
’s werden dadurch die drei
Tweets versendet, wobei der zweite und dritte Tweet als Antwort auf
den vorherigen Tweet abgesetzt wird, damit ein Thread entsteht und
die Tweets eindeutig als zusammengehörig gekennzeichnet sind.
Zusätzlich wird der TwitterClient
aufgefordert, die
Tweet-ID zurückzugeben. Dies machen wir, damit wir überprüfen
können, ob der Tweet auch tatsächlich erstellt wurde und alles gut
gegangen ist. Wurden alle drei Tweets erfolgreich versendet, wird
also true
von der Methode
postTweets()
zurückgegeben. Ist dies nicht der Fall,
werden die, in einem Array abgelegten, IDs der Tweets an die
Property $failed_tweets
übergeben und die Methode gibt
den Boolean-Wert false
zurück.
Dies führt dazu, dass der Code innerhalb der if
-Abfrage
unserer run()
-Methode ausgeführt wird und alle Tweets
gelöscht werden, sodass zu keinem Zeitpunkt ein unvollständiges bzw.
fehlerhaftes Status-Update herausgegeben wird.
Die dafür zuständige Methode sieht folgendermaßen aus:
/**
* Delete the specified Tweets
*
* @param array $tweet_ids The IDs of the Tweets to delete
* @throws CryptoStatusException if Tweets could not be deleted
*/
protected function deleteTweets(array $tweet_ids) {
$deleted_counter = 0;
foreach ($tweet_ids as $tweet_id) {
$deleted = $this->twitter_client->deleteTweet($tweet_id);
if ($deleted) {
$deleted_counter++;
}
}
if ($deleted_counter != count($tweet_ids)) {
throw new CryptoStatusException('Deleting Tweets failed', 2);
}
}
Die Klasse CryptoClient
Fahren wir nun fort mit dem CryptoClient
, welcher dafür
verantwortlich ist, die Crypto-Daten von einer API zu empfangen.
Dies ist der komplette Code:
<?php
/**
* A simple Twitter bot application which posts hourly status updates for the top 10 cryptocurrencies.
*
* PHP version >= 7.0
*
* LICENSE: MIT, see LICENSE file for more information
*
* @author JR Cologne <kontakt@jr-cologne.de>
* @copyright 2018 JR Cologne
* @license https://github.com/jr-cologne/CryptoStatus/blob/master/LICENSE MIT
* @version v0.2.1
* @link https://github.com/jr-cologne/CryptoStatus GitHub Repository
*
* ________________________________________________________________________________
*
* CryptoClient.php
*
* The client for retrieving the Crypto data from an API.
*
*/
namespace CryptoStatus;
use CryptoStatus\CurlClient;
use CryptoStatus\Exceptions\CryptoClientException;
class CryptoClient {
/**
* The cURL client instance
*
* @var CurlClient $curl_client
*/
protected $curl_client;
/**
* The Crypto client options
*
* @var array $options
*/
protected $options = [
'api' => null,
'endpoint' => null,
'params' => []
];
/**
* Constructor, initialization
*
* @param CurlClient $curl_client
* @param array $options
* @throws CryptoClientException if no API and/or API Endpoint is specified in options
*/
public function __construct(CurlClient $curl_client, array $options = []) {
$this->curl_client = $curl_client;
$this->options = array_merge($this->options, $options);
if (empty($this->options['api']) || !isset($this->options['endpoint'])) {
throw new CryptoClientException('No API and/or API Endpoint specified', 1);
}
}
/**
* Get Crypto data
*
* @return array
*/
public function getData() : array {
return $this->curl_client->get($this->getRequestUrl())->json();
}
/**
* Get request URL for Crypto API call
*
* @return string
* @throws CryptoClientException if no API and/or API Endpoint is specified
*/
protected function getRequestUrl() : string {
if (empty($this->options['api']) || !isset($this->options['endpoint'])) {
throw new CryptoClientException('No API and/or API Endpoint specified', 1);
}
$url = $this->options['api'] . $this->options['endpoint'];
if (empty($this->options['params'])) {
return $url;
}
$first_param = true;
foreach ($this->options['params'] as $key => $value) {
if (isset($this->options['params'][$key])) {
if ($first_param) {
$first_param = false;
} else {
$url .= urlencode('&');
}
$url .= "?{$key}={$value}";
}
}
return $url;
}
}
Im Grunde ist diese Klasse relativ übersichtlich. Zunächst haben wir
den Konstruktor, welcher als erstes Argument eine Instanz der Klasse
CurlClient
erwartet, womit dann der Request an die API
ausgeführt wird. Der zweite Parameter des Konstruktors ist ein Array
mit Optionen.
Im Konstruktor werden dann das CurlClient
-Objekt und
die Optionen jeweils in einer Property hinterlegt. Für den Fall,
dass keine API und kein Endpoint angegeben wurde, wird eine
Exception geworfen.
Als Nächstes wäre da die öffentliche Methode getData()
,
welche, wie wir bereits wissen, in der run()
-Methode
der Hauptklasse CryptoStatus
aufgerufen wird, um die
Crypto-Daten zu bekommen.
In dieser Methode getData()
wird einfach nur die
get()
-Methode des cURL-Clients ausgeführt, wobei als
einziges Argument der Rückgabewert der Methode
getRequestUrl()
übergeben wird.
Diese stellt einfach nur die URL für den API-Call zusammen und gibt
diese zurück. Letztendlich wird also in die
get()
-Methode schlicht und ergreifend eine URL, die
mithilfe von cURL aufgerufen werden soll, eingeführt.
Unmittelbar im Anschluss wird die Methode json()
der
Klasse CurlClient
auf den Rückgabewert der
get()
-Methode aus derselben Klasse ausgeführt. Dies
erfolgt über die direkte Aneinanderkettung von den Methoden, was
natürlich sehr platzsparend ist und elegant aussieht:
return $this->curl_client->get($this->getRequestUrl())->json();
Der Sinn hinter dieser Methode ist, das JSON
zu
dekodieren und ein Array der Daten zurückzugeben, damit das Ganze
dann gut weiterverarbeitet werden kann.
Einen näheren Blick darauf werfen wir jetzt, wenn wir uns die Klasse
CurlClient
anschauen.
Die Klasse CurlClient
Wie jetzt mittlerweile klar sein sollte, ist die Klasse
CurlClient
für die Tätigung des cURL-Requests zuständig
und wird für das Einholen der Daten in der Klasse
CryptoClient
benötigt.
Hier ist der komplette Code der Klasse:
class CurlClient {
/**
* The cURL handle of the current cURL session
*
* @var resource $ch
*/
protected $ch;
/**
* The result of a performed cURL request
*
* @var mixed $result
*/
protected $result;
/**
* Constructor, initialize cURL
*/
public function __construct() {
$this->ch = curl_init();
}
/**
* Perform a cURL request and retrieve the result
*
* @param string $url The URL for the cURL request
* @return self
* @throws CurlClientException if cURL request failed
*/
public function get(string $url) : self {
curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($this->ch, CURLOPT_URL, $this->sanitizeUrl($url));
$this->result = curl_exec($this->ch);
curl_close($this->ch);
if ($this->result === false) {
throw new CurlClientException('cURL request failed', 1);
}
return $this;
}
/**
* Json-decode result/return data of cURL request
*
* @param bool $array = true Return json-decoded data as array
* @return array (default) or object
* @throws CurlClientException if cURL return data could not be json-decoded
*/
public function json(bool $array = true) {
$json = json_decode($this->result, $array);
if ($json === null) {
throw new CurlClientException('cURL return data could not be json-decoded', 3);
}
return $json;
}
/**
* Sanitize and validate URL for cURL request
*
* @param string $url The URL for the cURL request
* @return string
* @throws CurlClientException if an invalid URL is given
*/
protected function sanitizeUrl(string $url) : string {
$url = filter_var($url, FILTER_SANITIZE_URL);
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new CurlClientException('Invalid URL', 2);
}
return $url;
}
}
Der Konstruktor ist dabei lediglich dafür zuständig, eine
cURL-Session zu initialisieren und den cURL-Handler in der Property
$ch
abzulegen.
Den tatsächlichen Request führt hingegen die Methode
get()
durch.
In dieser müssen erst noch ein paar Optionen gesetzt werden. Hierzu
gehört vor allen Dingen die URL, welche als Argument in die Methode
gegeben wurde und zuerst noch mit der Methode
sanitizeUrl()
gesäubert und auf Validität überprüft wird, bevor sie dann mittels
der Funktion
curl_setopt()
als URL für den Request festgelegt wird.
Im Anschluss wird dann in der get()
-Methode letztlich
der cURL-Request ausgeführt und das Ergebnis in der Property
$result
hinterlegt. Darauf kann die cURL-Session
geschlossen werden und die Anfrage wird nur noch auf Erfolg
überprüft.
Sollte die Anfrage gescheitert sein, wird eine Exception geschmissen. Ist dies nicht der Fall, wird die aktuelle Instanz des cURL-Clients zurückgegeben.
Dies ermöglicht das sogenannte “Method chaining”, bei dem mehrere Methode einfach hintereinander gehängt werden können.
Die Methode, welche angehangen werden soll, ist in diesem Fall die
json()
-Methode, welche das JSON
, das von
der API zurückgegeben wurde, in ein Array umwandelt. Dies
funktioniert ganz einfach mit der Funktion
json_decode()
.
Die Klasse TwitterClient
Kommen wir nun zur letzten Klasse, dem Twitter-Client. Zuständig ist dieser für die Interaktion mit der Twitter-API-Client-Library Codebird und damit letztendlich auch der Twitter API selber.
Hier ist wieder der Code:
<?php
/**
* A simple Twitter bot application which posts hourly status updates for the top 10 cryptocurrencies.
*
* PHP version >= 7.0
*
* LICENSE: MIT, see LICENSE file for more information
*
* @author JR Cologne <kontakt@jr-cologne.de>
* @copyright 2018 JR Cologne
* @license https://github.com/jr-cologne/CryptoStatus/blob/master/LICENSE MIT
* @version v0.2.1
* @link https://github.com/jr-cologne/CryptoStatus GitHub Repository
*
* ________________________________________________________________________________
*
* TwitterClient.php
*
* The client for interacting with the Twitter API.
*
*/
namespace CryptoStatus;
use CryptoStatus\Exceptions\TwitterClientException;
use Codebird\Codebird;
class TwitterClient {
/**
* A Codebird instance (a Twitter client library for PHP)
*
* @var Codebird $client
*/
protected $client;
/**
* The Twitter API keys
*
* @var array $api_keys
*/
protected $api_keys;
/**
* Constructor, initialization and authentication with Twitter API
*
* @param Codebird $twitter_client A Cordbird instance
* @throws TwitterClientException if authentication with Twitter API failed
*/
public function __construct(Codebird $twitter_client) {
$this->client = $twitter_client;
$this->api_keys = $this->getApiKeys();
if (!$this->authenticate()) {
throw new TwitterClientException("Authentication with Twitter API failed", 2);
}
}
/**
* Post a Tweet
*
* @param array $params Parameters for Twitter API method statuses/update
* @param array $return Data to return from Twitter API reply
* @return boolean (default) or array (when $return is specified)
*/
public function postTweet(array $params, array $return = []) {
$reply = $this->client->statuses_update($params);
if ($reply->httpstatus == 200) {
if (!empty($return)) {
foreach ($return as $value) {
$return_data[$value] = $reply->{$value};
}
return $return_data;
} else {
return true;
}
}
return false;
}
/**
* Delete a Tweet
*
* @param string $id ID of the Tweet to delete
* @return bool
*/
public function deleteTweet(string $id) : bool {
$reply = $this->client->statuses_destroy_ID([ 'id' => $id ]);
if ($reply->httpstatus == 200) {
return true;
}
return false;
}
/**
* Get API keys from environment variables
*
* @return array
* @throws TwitterClientException if Twitter API keys could not be retrieved
*/
protected function getApiKeys() : array {
$api_keys = [
'consumer_key' => getenv(TWITTER_API_CONSUMER_KEY),
'consumer_secret' => getenv(TWITTER_API_CONSUMER_SECRET),
'access_token' => getenv(TWITTER_API_ACCESS_TOKEN),
'access_token_secret' => getenv(TWITTER_API_ACCESS_TOKEN_SECRET)
];
if ( empty($api_keys['consumer_key']) || empty($api_keys['consumer_secret']) || empty($api_keys['access_token']) || empty($api_keys['access_token_secret']) ) {
throw new TwitterClientException("Could not get Twitter API Keys", 1);
}
return $api_keys;
}
/**
* Authenticate with Twitter API
*
* @return bool
*/
protected function authenticate() : bool {
$this->client::setConsumerKey($this->api_keys['consumer_key'], $this->api_keys['consumer_secret']);
$this->client->setToken($this->api_keys['access_token'], $this->api_keys['access_token_secret']);
return true;
}
}
Als Erstes hätten wir da den Konstruktor. Dieser erwartet eine
Codebird
-Instanz als Argument, welche dann wieder in
einer Property abgelegt wird. Eine andere Property wird durch die
Methode getApiKeys()
gefüllt. Diese holt sich die
gesamten API Keys aus den
Umgebungsvariablen
mithilfe der Funktion
getenv()
und gibt ein damit gefülltes Array zurück.
Im Anschluss wird die Verbindung mit der Twitter API über
Codebird
in der Methode
authenticate()
aufgebaut.
Das Posten eines Tweets läuft hingegen über die Methode
postTweet()
ab. Diese Methode hat zwei Parameter: Ein
Array mit Parametern für die Twitter API Methode
statuses/update
und ein Array, womit sich angeben lässt, was von dem geposteten
Tweet an Daten zurückgegeben werden soll, wie z.B. die ID. Letzteres
ist optional, da bei Erfolg ansonsten einfach
true
zurückgegeben wird.
Die Methode deleteTweet()
ist hingegen ein bisschen
einfacher strukturiert. Es wird schlicht und ergreifend nur ein
Argument erwartet und das ist die ID des zu löschenden Tweets. Für
die Interaktion mit der Twitter API wird dann wieder
Codebird
eingesetzt und je nach Erfolg oder Misserfolg
gibt die Methode deleteTweet()
einen booleschen Wert
zurück. Das ist alles.
Die Exception-Klassen
Neben den ganzen Klassen, die ich nun vorgestellt habe, existiert stets auch eine dazu passende Exception-Klasse.
Da diese eigentlich immer gleich aussehen, zeige ich hier nur kurz
beispielhaft die Klasse CryptoStatusException
<?php
/**
* A simple Twitter bot application which posts hourly status updates for the top 10 cryptocurrencies.
*
* PHP version >= 7.0
*
* LICENSE: MIT, see LICENSE file for more information
*
* @author JR Cologne <kontakt@jr-cologne.de>
* @copyright 2018 JR Cologne
* @license https://github.com/jr-cologne/CryptoStatus/blob/master/LICENSE MIT
* @version v0.2.1
* @link https://github.com/jr-cologne/CryptoStatus GitHub Repository
*
* ________________________________________________________________________________
*
* CryptoStatusException.php
*
* The Exception of the CryptoStatus class
*
*/
namespace CryptoStatus\Exceptions;
use \Exception;
class CryptoStatusException extends Exception {
}
Alles, was diese machen, ist die
Exception
-Klasse von PHP
zu erweitern.
Die Erzeugung einer Exception sieht dann beispielsweise so aus:
throw new CryptoStatusException('Crypto data is missing', 1);
Zuhause in der Cloud: Deployment mit Heroku
Nachdem wir uns nun ausführlich den Code meines Twitter-Bots “Status Crypto” angeschaut haben, verliere ich jetzt noch ein paar Worte über das Deployment der Anwendung mit Heroku.
Was ist Heroku?
Heroku ist eine Cloud-Plattform nach dem Prinzip PaaS (Platform as a service) und bietet Services an, die es ermöglichen, eine Webapplikation sehr leicht in der Cloud zu hosten, sodass man sich um nichts kümmern muss, außer die Entwicklung der App.
So übernimmt Heroku die komplette Bereitstellung und Verwaltung der Server und bietet Unterstützung für einige Programmiersprachen bzw. Technologien wie Java, Node.js, Python, Ruby, Go und natürlich PHP.
Dadurch, dass das Ganze auf der Cloud basiert, ist man sehr flexibel und kann theoretisch beliebig skalieren, vorausgesetzt man besitzt den entsprechenden Tarif.
Deployment mit Heroku: So schnell und einfach
Das Schöne an Heroku ist, dass wirklich nicht viel benötigt wird, um loszulegen und mit der eigenen Webanwendung an den Start zu gehen.
Eine Voraussetzung, um Heroku mit PHP zu nutzen, ist die Verwendung von Composer, da Heroku über Composer die Dependencies verwaltet und daran ebenfalls erkennt, dass es sich um eine PHP-Anwendung handelt.
Als allererstes erstellt man sich also einen kostenlosen Account bei Heroku und lädt das Heroku Command Line Interface (Heroku CLI) herunter.
Sobald dies erledigt ist, sollte man erst noch sicherstellen, dass PHP, Composer und Git lokal auf dem Computer installiert sind und aus dem Terminal darauf zugegriffen werden kann.
Sobald alles installiert ist, öffnet man das Terminalprogramm der
Wahl, wie z.B. PowerShell auf Windows, und gibt den Befehl
heroku login
ein. Darauf wird man nach seinen
Login-Daten gefragt und meldet sich an.
Im Anschluss ist es dann wichtig, dass man sich im entsprechenden Projektordner der App befindet, welche man online stellen möchte und Git für sein Projekt im Einsatz hat.
Nun kann man nämlich mit dem Befehl heroku create
eine
App erstellen. Des Weiteren wird ein Git-Repository im System von
Heroku erstellt, welches mit dem lokalen Git-Repository des eigenen
Projektes verknüpft wird. Möchte man seiner App einen bestimmten
Namen geben, kann man diesen mit einem Leerzeichen getrennt hinter
heroku create
angeben.
Nun sollte alles bereit sein und in eurem Heroku Dashboard sollte bereits eure App aufgetaucht sein. Dort könnt ihr diese auch noch verwalten und verschiedene Einstellungen treffen.
Darauf gehen wir aber später ein. Erstmal gilt es, den Code zu
deployen, also hochzuladen und für die App bereitzustellen. Dies
erfolgt mithilfe von Git und dem Befehl
git push heroku master
. Nun wird automatisch alles von
Heroku eingerichtet.
Hätten wir nun eine normale Web App müssten wir nur noch den Befehl
heroku ps:scale web=1
eingeben. Dies sorgt dafür, dass
eine Instanz der App läuft und online verfügbar ist. Gibt ihr
heroku open
ein, wird eurer Standardbrowser mit eurer
App geöffnet, welche nun unter einer Domain nach dem Format
your-app-name.herokuapp.com
verfügbar sein sollte.
Schon habt ihr eure Web App erfolgreich deployed.
In unserem Fall macht dies aber wenig Sinn. Wir brauchen keine Web-Instanz, schließlich soll unserer App nur im Hintergrund ablaufen und von nirgendwo öffentlich zugänglich sein.
Aus diesem Grund stoppen wir die Web-Instanz, auch Dyno genannt,
wieder mit dem Befehl heroku ps:scale web=0
und
wechseln ins
Heroku Dashboard, klicken dort auf unsere App und befinden uns nun auf der
Übersichtsseite der App.
Im Idealfall startet ihr die Web-Instanz also gar nicht, sondern geht nach dem Hochladen des Codes direkt ins Dashboard.
Umgebungsvariablen setzen
Nun müssen wir erstmal unsere API Keys für die Twitter API in den Umgebungsvariablen (Environment Variables) hinterlegen.
Hierfür gehen wir in die “Settings” unserer App bei Heroku und klicken auf “Reveal Config Vars” unter dem Punkt “Config Variables”. Es tauchen nun Felder auf, in denen wir Key-Value-Paare hinterlegen können.
Der Key muss dabei mit dem String übereinstimmen, den wir
letztendlich in die Funktion
getenv()
einführen, um unsere API Keys aus den
Umgebungsvariablen
zu lesen.
Das Value-Feld hingegen wird mit dem jeweiligen API Key ausgefüllt, den man von Twitter bekommt.
Ist das erledigt, sollten auch die API Keys verfügbar sein.
Regelmäßige Aufgaben mit Heroku Scheduler ausführen
Jetzt navigieren wir auf die Seite “Resources”. Hier findet ihr nicht nur nochmal eine Übersicht über eure Dynos, sondern auch die Add-ons. Letzteres ist genau das, wonach wir suchen, schließlich möchten wir ja, dass unsere App jede Stunde ausgeführt wird, um die aktuellen Crypto-Status-Updates auf Twitter zu veröffentlichen.
Diese Funktionalität bietet uns ein Add-on namens Heroku Scheduler. Dies gibt ihr am besten ins Suchfeld ein und klickt dann auf das entsprechende Add-on, sobald es auftaucht.
Ihr klickt euch durch die Menüführung durch und aktiviert das Add-on Heroku Scheduler. Anschließend sollte es euch dann auf der Seite Resources angezeigt werden und ihr könnt von dort aus ins Dashboard des Add-ons gelangen, indem ihr auf das Add-on klickt.
Nun klickt ihr auf “Add new job” und erstellt eine Aufgabe. In das
erste Formularfeld gehört der Befehl, der ausgeführt werden soll. In
unserem Fall ist das php app/app.php
. Dies sorgt dafür,
dass die Datei app.php
, welche sich im Ordner
app
befindet, mit PHP ausgeführt wird.
Die Einstellung bei “Dyno Size” belässt ihr bei “Free”, “Frequency” stellen wir auf “Hourly”, schließlich soll unsere Anwendung stündlich ausgeführt werden, und bei dem Feld “Next Due” könnt ihr grob im 10-Minuten-Takt auswählen, wann die erste Ausführung eures Jobs erfolgt bzw. zu welcher Zeit einer Stunde die Aufgabe immer ausgeführt werden soll. Die Einstellung “:00” sorgt dementsprechend dafür, dass euer Script immer zu jeder vollen Stunde bzw. in den ersten 10 Minuten nach einer vollen Stunde ausgeführt wird. “:30” würde die Ausführung ungefähr auf die Minuten 30-39 einer Stunde ansetzen usw. In unserem Fall ergibt die Einstellung “:00” am meisten Sinn, schließlich würde man bei einer Anwendung, welche stündliche Updates gibt, am ehesten erwarten, dass diese rund um die volle Stunde erfolgen.
Nun müsst ihr nur noch auf “Save” klicken und alles sollte funktionieren. Der oben angegebene Befehl sollte grob zur gewünschten Zeit ausgeführt werden.
Ein wichtiger Zusatz ist dabei das Wort “grob”, da der Heroku Scheduler leider nicht zu 100 Prozent genau und zuverlässig ist. Dafür ist er aber auch komplett kostenlos, vorausgesetzt man bleibt unter dem Limit der Ausführungszeit eines kostenlosen Accounts. Für den Anwendungsfall meines Twitter-Bots reicht es bisher auf jeden Fall völlig aus.
Automatische Deploys von GitHub
Zuletzt noch ein kleiner “Profi-Tipp” für diejenigen, die es sich noch einfacher machen wollen:
Es gibt die Möglichkeit unter dem Reiter bzw. Tab “Deploy” der App die Deployment-Methode auf GitHub umzustellen, die Heroku-App mit dem GitHub-Account und Repository zu verbinden und so dann automatische Deploys von einem bestimmten Branch aus durchzuführen. Dies bedeutet, dass jede Änderung z.B. auf dem Master-Branch automatisch auch bei Heroku hochgeladen und deployed wird. Das ist wirklich praktisch und eröffnet einen ganz neuen Workflow.
Fazit
So, was kann man abschließend sagen? Nun, ja. Ich muss sagen, ich bin wirklich froh, dass ich dieses Projekt, meinen eigenen Twitter-Bot zu entwickeln, angegangen bin. Es hat wirklich Spaß gemacht, daran zu arbeiten und mich beispielsweise auch mit Heroku ein wenig zu beschäftigen.
Ich hoffe, dass dieser Artikel trotz der Tatsache, dass ich stellenweise Themen nur leicht gestreift habe und an anderer Stelle dafür ein wenig zu viel geschrieben habe, dem einen oder anderen weiterhilft bzw. zumindest ein paar interessante Einblicke mit sich bringt.
Falls noch Fragen bestehen oder etwas unklar ist und ich gewisse Stellen nochmal überarbeiten sollte, könnt ihr mich gerne über Twitter oder per Mail kontaktieren. Bin da für vieles bereit.
Auch wird dieser Artikel wahrscheinlich in einer stark abgewandelten Form in wenigen Tagen auf Medium in englischer Sprache erscheinen. Ich plane nämlich neben meinem Blog hier auf jr-cologne.de, wo ich auf Deutsch schreibe, auch auf Medium ab und zu einen Artikel auf Englisch zu veröffentlichen.