Twitter et le monde en temps réel

ou comment faire un bot qui lit un stream Twitter

Twitter adore le temps réel, l’action continue, le « je vérifie pas mon tweet avant de poster je veux être le premier à en parler » et plein d’autres choses merveilleuses qui rendent ce réseau social incroyable.

Dans ce contexte où la réponse instantanée est légion, il convient, lors de la mise en place d’un robot (bot) Twitter avec réponse automatique de faire en sorte qu’il poste le plus rapidement possible ce qu’il a à dire. Non seulement vous améliorerez le contact utilisateur <> machine en fournissant directement le contenu que le twittos désire, mais en prime vous augmenterez probablement l’envie de l’utilisateur de lui répondre à nouveau.

Avant-propos

Quel choix pour une réponse rapide en 2019 ?

  1. Télécharger les mentions de votre bot de façon extrêmement récurrente (API REST statuses/mentions_timeline)
  2. Utiliser l’Account Activity API™ de Twitter qui permet -moyennant finance- de « souscrire » à l’activité de votre compte
  3. Utiliser le seul service de streaming encore mis à disposition par Twitter (statuses/filter)

Voyons ensemble les avantages et les inconvénients de chaque méthode.

La première est relativement simple à mettre en place, ce serait un bête script s’exécutant toutes les X secondes et qui relèverait toutes les mentions envoyées depuis le dernier lancement du script. Son plus grand atout est qu’il est 100% gratuit, simple à mettre en place et qu’il permet de lire les mentions des comptes privés qui vous suivent. Son inconvénient majeur est qu’il est très facilement rate-limité (75 / 15 minutes). Si vous recevez beaucoup de mentions, vous pourriez avoir du mal à les relever périodiquement et à répondre rapidement.

La seconde est… énigmatique pour moi. N’ayant pas accès à l’API (339$/mois minimum :D), je n’ai rien testé et à vrai dire, son fonctionnement est assez éloigné de ce qui se passe ailleurs dans l’API Twitter. Son avantage est sa rate limit très élevée (500/15min) et l’accès aux mentions des comptes privés.

Et enfin… la dernière : Vous vous en doutez, c’est d’elle dont on va parler. statuses/filter est le dernier service de stream de Twitter encore actif (non, statuses/sample ne compte pas) et est donc notre dernier recours pour faire du -vrai- temps réel pour notre bot.

Il n’y a pas si longtemps, en août 2018, Twitter fermait GET site et GET user, deux services de stream permettant de suivre une home timeline et une user timeline en temps réel.
Twitter a jugé qu’ils devaient être supprimés :
Ils permettaient aux clients alternatifs d’avoir une TL bien trop facilement, alors que le seul support restant, statuses/home_timeline, limite l’obtention de cette même TL à 1 chargement toutes les minutes (avec 200 tweets max. par chargement) et ont donc par conséquent disparu il y a quelques mois.

On va donc se servir du dernier ressortissant du presque disparu service de stream, en choisissant des mots clés permettant « d’émuler » statuses/mentions_timeline, à la différence près que le stream ne peut pas récupérer les mentions des comptes privés, même si notre bot les suit.


Qu’est-ce qu’un « stream », au juste ?

Un stream, en français un flux, est un arrivage permanent de données (de même type ou non) qui permet généralement de recevoir des données en temps réel, sans « pull » répétitif d’un même serveur pour obtenir des mises à jour.

Chez Twitter, un stream se présente comme une requête HTTP qui ne se termine jamais. En effet, HTTP permet de recevoir des données en mode « chunked » (par paquet) et Twitter exploite cette particularité pour envoyer un nouveau chunk lorsqu’il doit envoyer un message au possesseur du stream.
Si aucune donnée n’est envoyée pendant 30 secondes, pour éviter que le client ne ferme automatiquement la connexion (timeout), Twitter envoie une ligne vide (un CRLF, « \r\n ») pour maintenir en vie le flux.
Le reste du temps, vous recevrez des objets de tweet, c’est à dire du JSON de tweet tout à fait classique (voir la partie sur les tweet object de Twitter), ou d’autres messages d’information/d’erreur.


Support du projet

Maintenant que l’on sait sur quelle approche nous allons nous baser, il convient de choisir quel langage de programmation sera capable d’héberger facilement notre projet.

Pour son côté asynchrone facile et sa flexibilité d’installation de package, j’ai choisi JavaScript (enfin, Node.js).
Vous êtes libre d’utiliser un autre langage, c’est juste que des packages pour gérer les streams Twitter sont déjà tout prêts et le système d’événement de JS aide beaucoup à la compréhension.


Sommaire

  1. Installer les bons packages
  2. Création et fonctionnement d’un stream
  3. Actions du bot
  4. Problème rencontré
  5. Déployer le robot

1. Installer les bons packages

Pour commencer, l’essentiel : ouvrez un terminal.
Débutons avec une base saine directement dans un nouveau dossier, initialisons npm et créons un fichier index.js pour héberger notre projet !

mkdir twitter-bot-js
cd twitter-bot-js
touch index.js
npm init

Après avoir initialisé votre projet, vous n’avez plus qu’à installer le paquet magique pour se servir des streams Twitter.

npm install twitter-stream-api

Je vous laisse jeter un coup d’oeil à sa documentation si besoin, mais on va passer sur ses fonctionnalités dans la partie suivante.

2. Création et fonctionnement d’un stream

On va débuter le code désormais ! Ouvrer le fichier index.js dans votre éditeur de code préféré, on va commencer à écrire.
La première chose à faire est d’importer la classe TwitterStream fournie par twitter-stream-api. Ensuite, sauvegardez vos clés dans des constantes pour pouvoir initialiser la connexion OAuth avec Twitter.
Si vous ne savez pas ce que sont les clés Twitter, je vous renvoie à cet article, partie « Création d’une application » puis « Première connexion ».

const TwitterStream = require('twitter-stream-api');

const consumer_key = "MY_CONSUMER_TOKEN";
const consumer_secret = "MY_CONSUMER_SECRET";
const token = "MY_ACCESS_TOKEN";
const token_secret = "MY_ACCESS_TOKEN_SECRET";

Maintenant, il est temps d’initialiser un stream. L’instanciation de TwitterStream se fait en lui passant un objet contenant les clés d’authentification et en second paramètre un booléen, spécifiant si le JSON envoyé par Twitter lors du stream doit être automatiquement parsé (true).

let twitter_stream = new TwitterStream({
consumer_key,
consumer_secret,
token,
token_secret
}, true);

L’objet est prêt à recevoir vos instructions ! Choisissez maintenant ce que vous souhaitez obtenir comme tweets.
Vous pouvez mixer les opérateurs (comme dans une recherche Twitter, le tout est ici) comme vous le souhaitez. Si vous voulez juste suivre les mentions d’une personne en particulier, utilisez le paramètre « track: @utilisateur_a_suivre »; Si vous voulez au contraire suivre les tweets d’une personne en particulier, utilisez « follow: [ID numérique utilisateur] ».

Dans mon cas, on va chercher à suivre mes mentions (@Alkihis).

twitter_stream.stream('statuses/filter', {
track: '@Alkihis',
stall_warnings: true
});

Le premier paramètre de .stream est l’endpoint de stream désiré. Il n’en reste qu’un seul, statuses/filter, donc on utilise celui-ci. Le second est les paramètres qui seront envoyés avec la requête (track, follow, language…). Notez le « stall_warnings: true » qui signale à Twitter d’envoyer les messages d’alerte (fermeture à prévoir du stream, file d’attente pleine…) avec le reste du stream.

Il faut désormais inscrire les événements du stream pour pouvoir traiter le cas quand les données arrivent (et quand il y a un problème) !
Pour ça, on utilise .on(‘event’, callback) de l’objet TwitterStream.
Dans le cas où les données ne représentent pas une erreur, on utilise .on(‘data’, obj => {}).

L’événement .on(‘data error’, error => {}) est indiqué pour traiter un objet inconnu.
.on(‘data keep-alive’, () => {}) traite le cas où un CRLF est reçu.

D’autres événements sont disponibles pour notamment configurer si on reçoit une erreur HTTP, si la connexion s’arrête… Je vous laisse regarder la documentation pour plus d’informations.

Vous vous doutez, dans la partie suivante, nous allons plutôt voir le fonction de l’événement ‘data’, puisque c’est lui qui recevra les tweets streamés.

3. Actions du bot

Vous avez réfléchi à ce que devrait faire votre robot ? On va s’y attaquer ici.
Dans mon cas, j’aimerais qu’à chaque mention me concernant contenant le mot « pousse », mon bot réponde « tu roules » automatiquement.

Dans un premier temps, on va identifier si le tweet nous intéresse, puis on verra en dessous comment y répondre (on va bricoler notre propre classe pour communiquer avec l’API REST, plutôt qu’utiliser un module tout fait, parce que j’adore m’amuser !).

twitter_stream.on('data', async function (obj) {
// On vérifie d'abord qu'on reçoit bien un tweet (on sait jamais),
// Twitter peut envoyer des données incohérentes
if (obj.text && obj.in_reply_to_screen_name) {

// On vérifie maintenant que le tweet me mentionne bien directement
// et ne soit pas un tweet "de passage", où je suis juste dans une conversation,
// mais ce tweet ne me répond pas directement.
// On vérifie également que je ne me réponds pas à moi-même (thread)
if (obj.in_reply_to_screen_name.toLowerCase() === 'alkihis' && obj.user.screen_name.toLowerCase() !== 'alkihis') {

// Maintenant, il faut vérifier que le tweet contienne bien le mot "pousse",
// pour pouvoir le traiter et répondre.
if (obj.text.match(/\bpousse\b/i)) {
// Entre dans la condition si le mot "pousse" est trouvé,
// Et n'est pas entouré de caractères alphanumériques

// On peut répondre au tweet maintenant !
}
}
}
else {
console.log("Donnée inconnue reçue: ", obj);
}
});

Bon, on a désormais une bonne base, mais TwitterStream ne peut pas communiquer avec l’API REST de Twitter, on va donc devoir soit coder notre propre module, soit utiliser un module tout fait. Je vous laisse choisir.
La partie qui suit suppose que vous souhaitez vous exercer avec l’API Twitter et vous invite à écrire un petit module simple (on va pas coder la gestion OAuth non plus).


Création d’un petit module communiquant avec Twitter

En premier, on va d’abord installer quelques paquets (oui, quand même) qui vont nous faciliter la vie. On aura besoin d’un handler OAuth1 et de la gestion de XMLHttpRequest via les Promise. Ensuite, créez votre fichier twitter.js quelque part, qui contiendra notre classe Twitter. Moi, il sera dans un sous-dossier src.

npm install oauth-1.0a request-promise-native
mkdir src
touch src/twitter.js

Vous êtes maintenant dans twitter.js. La première chose à faire est de charger les modules requis (oauth-1.0a et request-promise-native, que l’on a téléchargé, et crypto, inclus dans Node.js).
Je vais faire court dans cette partie parce que c’est plus ou moins la même chose que dans le tuto PHP, notamment pour faire une requête. La seule différence est que je vais faire ici une méthode makeRequest(url, method, data, headers) qui simplifiera les requêtes.

La classe Twitter est directement mise dans module.exports, un nom qui représente en réalité ce qui va être chargé lorsqu’on inclura le fichier twitter.js via la fonction require().

Voilà le code de base de la classe, le tout à insérer dans twitter.js :

const OAuth = require('oauth-1.0a');
const rp = require('request-promise-native');
const crypto = require('crypto');

module.exports = class Twitter {
constructor(consumer, secret, access_token = undefined, access_token_secret = undefined) {
this.oauth = OAuth({ // Initialise la classe OAuth et la façon de hasher (HMAC-SHA1 pour Twitter)
consumer: { key: consumer, secret: secret },
signature_method: 'HMAC-SHA1',
hash_function(base_string, key) {
return crypto.createHmac('sha1', key).update(base_string).digest('base64');
}
});

// Enregistre les access_token, access_token_secret
this.access_token = access_token;
this.access_token_secret = access_token_secret;

this.lastResponse = undefined;
}

setToken(access_token, access_token_secret) {
this.access_token = access_token;
this.access_token_secret = access_token_secret;
}

makeRequest(url, method, form_data = {}, headers = {}) {
// La méthode doit être en uppercase (norme OAuth)
method = method.toUpperCase();

// Enregistre le request_data pour générer la signature OAuth
let request_data = {
url,
method,
data: form_data
};

// Ecrit la base de la requête pour request-promise-native
let base_rq = {
uri: request_data.url,
method: request_data.method,
headers
};

// Enregistre les données générées par OAuth.authorize dans le bon emplacement de la
// requête HTTP : form (données formatées en formdata), body (données brutes dans le body)
// ou qs (données encodées dans la query string)
if (request_data.method === 'POST') {
base_rq.form = this.oauth.authorize(request_data, {key: this.access_token, secret: this.access_token_secret});
}
else if (headers && headers['Content-Type'] && headers['Content-Type'] === 'application/x-www-form-urlencoded') {
base_rq.body = this.oauth.authorize(request_data, {key: this.access_token, secret: this.access_token_secret});
}
else {
base_rq.qs = this.oauth.authorize(request_data, {key: this.access_token, secret: this.access_token_secret});
}

// Lance la requête et sauvegarde dans lastResponse
this.lastResponse = rp(base_rq);

return this.lastResponse;
}
}

On a cette superbe longue base, il est temps de l’exploiter pour pouvoir répondre à un tweet !
Pour commencer, on va créer une méthode dans notre classe pour envoyer un tweet, simplement.
On utilise l’endpoint statuses/update en POST, et la fonction acceptera un objet d’arguments que l’on peut fournir à l’endpoint.

sendTweet(text, args = {}) {
// On définit tweet_mode='extended' pour récupérer un tweet étendu (avec full_text notamment)
args.tweet_mode = 'extended';
// status représente le texte du tweet
args.status = text;

// Si jamais l'utilisateur n'a pas explicitement spécifie qu'il ne veut pas que Twitter rajoute
// les @ en début de tweet si il répond, on le définit automatiquement
if (args.in_reply_to_status_id && !args.auto_populate_reply_metadata) {
args.auto_populate_reply_metadata = true;
}

return this.makeRequest('https://api.twitter.com/1.1/statuses/update.json', 'POST', args);
}

Bon, bah on est presque prêt à répondre aux gens qui veulent me pousser pour rouler !
Pour simplifier les choses, on va créer une méthode permettant de répondre très facilement aux tweets :

replyTo(tweet_id_str, text, additionnals_args = {}) {
// On définit le in_reply_to_status_id et on envoit le tweet
additionnals_args.in_reply_to_status_id = tweet_id_str;

return this.sendTweet(text, additionnals_args);
}

On ne peut pas faire plus simple que cette méthode ! Elle est juste là pour éviter la corvée de marquer « in_reply_to_status_id » à chaque fois.


C’est bon, ça y est ! On peut terminer l’action de notre bot qui peut maintenant répondre aux gens.
On ajoute :

const Twitter = require('./src/twitter'); 
const twitter_rest = new Twitter(consumer_key, consumer_secret, token, token_secret);

au tout début de notre script index.js (en dessous de la déclaration des autres constantes) !

Dans la fonction twitter_stream.on(‘data’, function (obj) {}), là où nous en étions resté, on rajoute l’appel à twitter_rest.replyTo(id, text) qui va envoyer le tweet final.
Pour être certain que le tweet soit bien envoyé, on attend que la requête soit terminée et qu’elle ait réussi avec await.

if (obj.text.match(/\bpousse\b/i)) {
// Entre dans la condition si le mot "pousse" est trouvé,
// Et n'est pas entouré de caractères alphanumériques

// On peut répondre au tweet maintenant !
try {
await twitter_rest.replyTo(obj.id_str, "tu roules");
console.log("Le tweet a été envoyé avec succès !");
} catch (error) {
console.log("Impossible de répondre ! Voici l'erreur: ", error);
}
}

Notre robot sait répondre !
Vous pouvez le tester en tapant:

node index.js

Puis en faisant une action capable de déclencher le bot, sur Twitter. Il devrait réagir.
Si vous rencontrez une erreur, n’hésitez pas à console.log l’objet « obj » qui vous arrive dans .on(‘data’) et à regarder ce qui cloche.
Si votre bot n’arrive à écouter le stream / n’arrive pas à répondre, vérifier vos clés Twitter, sont-elles valides ?

Au cas-où vous ne saviez pas, pour interrompre le script, utilisez CTRL+C.

Si tout fonctionne, il ne resterait plus qu’à le laisser tourner et tout irait bien dans le meilleur des mondes…

4. Problème rencontré

…mais voilà, nous sommes confrontés à Twitter. Rien ne peut se passer comme prévu !
Après plusieurs tests personnels, il m’est apparu un problème bien épineux : Après quelques heures sans qu’aucun tweet ne soit reçu (oui, je ne suis pas très actif), le stream… plantait. Enfin pas complètement. Le script restait lancé, mais il ne recevait plus rien ! C’est un poil embêtant.

J’ai testé plusieurs combines pendant plusieurs jours, sans succès, et j’ai finalement trouvé un truc tout bête : relancer automatiquement le script si jamais Twitter n’envoie aucun tweet ou aucun signal « keep-alive » (CRLF) dans le stream.

Pour cela, j’ai encapsulé tout le script dans une fonction, et rajouté un timeout qui, si il arrive à terme, termine le stream en cours et en relance un nouveau. À chaque « keep-alive » ou tweet reçu, le décompte avant réinitialisation est remis à 0 grâce à la fonction clearTimeout(id).

Attention, fichier complet index.js qui entre en gare :

const consumer_key = consumer_token;
const consumer_secret = consumer_secret;
const token = access_token;
const token_secret = access_token_secret;

const Twitter = require('./src/twitter');
const TwitterStream = require('twitter-stream-api');

let twitter_rest = new Twitter(consumer_key, consumer_secret, token, token_secret);
let twitter_stream;
let timeout_id = 0;
let manual_reset = false;

///// FIN DE L'INITIALISATION

// FONCTIONS

function resetTimeout(sec = 120) {
if (timeout_id) {
clearTimeout(timeout_id);
}

timeout_id = setTimeout(function() {
// Le timeout est déclenché : on réinitialise le stream !
console.log("Inactivité : réinitialisation.");
manual_reset = true;
twitter_stream.close(); // Lance .on('connection aborted')

manual_reset = false;
// Relance le stream au bout de 20 secondes, pour éviter le rate limit agressif de Twitter
setTimeout(listenStreamAndAnswer, 20*1000);
}, sec*1000);
}

function listenStreamAndAnswer() {
twitter_stream = new TwitterStream({
consumer_key,
consumer_secret,
token,
token_secret
}, true);

twitter_stream.stream('statuses/filter', { // Initialisation du stream
track: "@Alkihis",
stall_warnings: true
});

twitter_stream.on('connection aborted', function () {
// La connexion est arrêtée : soit brutalement, soit par twitter_stream.close()
if (timeout_id) {
clearTimeout(timeout_id);
}

if (manual_reset) {
console.log("Fermeture du stream inactif.");
}
});

twitter_stream.on('connection success', function (httpStatusCode) {
console.log("Connexion établie.");
});

twitter_stream.on('connection rate limit', function (httpStatusCode) {
console.log("Un rate limit a été atteint (Code HTTP " + httpStatusCode + "). La connexion sera relancée dans 30 secondes.");
});

twitter_stream.on('data keep-alive', function () {
// On reçoit un keep-alive (CRLF), on réinitialise le timeout
resetTimeout();
});

twitter_stream.on('data', async function (obj) {
// On reçoit un tweet, on réinitialise le timeout
resetTimeout();

// On vérifie d'abord qu'on reçoit bien un tweet (on sait jamais),
// Twitter peut envoyer des données incohérentes
if (obj.text && obj.in_reply_to_screen_name) {

// On vérifie maintenant que le tweet me mentionne bien directement
// et ne soit pas un tweet "de passage", où je suis juste dans une conversation,
// mais ce tweet ne me répond pas directement.
// On vérifie également que je ne me réponds pas à moi-même (thread)
if (obj.in_reply_to_screen_name.toLowerCase() === 'alkihis' && obj.user.screen_name.toLowerCase() !== 'alkihis') {

// Maintenant, il faut vérifier que le tweet contienne bien le mot "pousse",
// pour pouvoir le traiter et répondre.
if (obj.text.match(/\bpousse\b/i)) {
// Entre dans la condition si le mot "pousse" est trouvé,
// Et n'est pas entouré de caractères alphanumériques

// On peut répondre au tweet maintenant !
try {
await twitter_rest.replyTo(obj.id_str, "tu roules");
console.log("Le tweet a été envoyé avec succès !");
} catch (error) {
console.log("Impossible de répondre ! Voici l'erreur: ", error);
}
}
}
}
else {
console.log("Donnée inconnue reçue: ", obj);
}
});
}

// FIN DECLARATION FONCTIONS

console.log("Démarrage du script...");
listenStreamAndAnswer();

Pfiou, voilà.
Si le stream ne reçoit rien au bout de 2 minutes (120 secondes), il se réinitialisera automatiquement. La réinitialisation fermera le stream, attendra 20 secondes et le relance.
C’est peut être pas hyper joli comme façon de faire, mais c’est simple et surtout : ça marche. Il faut compter également sur le fait que rien ne soit reçu pendant la phase d’inactivité.

De toute manière, sur des bots destinés à répondre à beaucoup de tweets, ce problème n’aura pas lieu (il faut, d’après mes tests, plus de 4 heures d’inactivité), la probabilité d’en rater est donc assez faible.

Problème résolu !

5. Déployer le robot

Pour mettre en place le robot et le laisser tourner en arrière plan, c’est relativement simple :
Le mieux reste encore d’utiliser screen (Unix). L’avantage est que le log à l’écran est conservé, et si vous constatez que votre bot a planté, vous pourrez retrouver facilement le problème.
Si vous ne savez pas ce que c’est, je vous laisse avec ce tuto de Ubuntu-fr.

Rien ne vous empêche néanmoins de coder un système de sauvegarde de log à votre bot, pour l’enregistrer sereinement dans un fichier. Vous pouvez aussi utiliser la tonne de modules disponibles rien que pour ça sur npm.

Ici, on va juste créer un nouveau screen avec un nom (pour le retrouver facilement) et lancer bêtement la tâche :

screen -S answer_bot
node index.js

Puis de taper sur les touches CTRL+A suivi de CTRL+D pour revenir à votre précédent terminal.

Pour revenir sur le terminal ayant lancé le bot de réponse, il suffit de taper

screen [-d] -r answer_bot

Le « -d » est facultatif, utilisez le si vous avez mal détaché la session la dernière fois que vous vous en êtes servi.
screen s’exécute tant que l’ordinateur n’est pas redémarré. Vous pouvez quitter votre session, il reste en arrière-plan, c’est parfait pour lancer des tâches sur des serveurs !

Lorsque vous souhaitez arrêter le bot, utiliser la commande précédente et utilisez le raccourci clavier CTRL+C pour stopper node.

Fini !

Assez écrit pour aujourd’hui, c’est fini pour moi !
Si des choses ne sont pas claires, n’hésitez encore fois pas à me demander de m’expliquer.
J’espère que ça vous a plu, à bientôt sur la twittosphère 😀

Laisser un commentaire