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:

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.

Screenshot der Heroku-App-Übersichtsseite

Die Heroku-App-Übersichtsseite

Screenshot: heroku.com

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.

Screenshot der Heroku-App-Settings mit den Umgebungsvariablen

So sieht es ungefähr aus, wenn alle API Keys in die Umgebungsvariablen eingetragen wurden.

Screenshot: heroku.com

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.

Screenshot des Heroku-Scheduler-Dashboards

Das Dashboard des Add-ons Heroku Scheduler

Screenshot: heroku.com

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.

Weiterführende Links / Quellen