Le quiz de l'espace

Ou comment j'ai fait un bot en Python pour Mastodon

Depuis quelques mois, je maintiens un bot sur Mastodon qui pose des questions en lien avec l'astronomie, l'astrophysique et l'exploration spatiale. Son code source est disponible sur Gitlab. J'explique dans cet article son fonctionnement en détail dans l'espoir de vous donner envie de créer votre propre bot.

Se créer un compte sur Mastodon

Mastodon est un média social comparable à Twitter. Au contraire de Twitter, ce média est libre et fédéré, ce qui veut dire que le contenu n'est pas contrôlé par une seule entreprise multi-nationale, mais les utilisateurs sur des instances différentes peuvent communiquer entre eux. Si vous n'êtes pas familier de Mastodon, d'excellents articles sur le comme celui-ci sur le Framablog rentrent dans plus de détails.

La première étape dans la création d'un bot sur Mastodon est de se créer le compte par l'intermédiaire duquel le bot interagira avec les autres utilisateurs de Mastodon. Si vous êtes aventureux, vous pouvez même créer votre propre instance pour héberger les comptes de vos bots.

J'utilise habituellement botsin.space, une instance dédié aux bots et aux amis des bots. Le propriétaire de l'instance valide manuellement chaque demande de compte, donc il faut compter un ou deux jours avant que le compte soit créé. Pour cette raison, je créé un compte avant de commencer à coder quand j'ai une nouvelle idée de bot. Le temps que le compte soit validé, mon bot est codé, testé et prêt à être lancé.

Mastodon.py

Avant de commencer à coder, il faut installer l'API Mastodon.py qui est disponible sur Github. Cette API permet de faire avec des commandes Python tout ce qu'on peut faire avec l'interface web habituelle de Mastodon.

Dans un terminal Python, on import Mastodon.py et on se connecte à son compte


MASTODON_USERNAME = quizdelespace
MASTODON_URL = https://botsin.space
MASTODON_EMAIL = moi@moi.fr
MASTODON_PASSWORD = ************

client_id, client_secret = Mastodon.create_app(
        MASTODON_USERNAME,
        scopes=['read', 'write'],
        api_base_url=MASTODON_URL
    )
    
api = Mastodon(
    client_id,
    client_secret,
    api_base_url=MASTODON_URL
)

access_token = api.log_in(
    MASTODON_EMAIL,
    MASTODON_PASSWORD,
    scopes=["read", "write"]
)

api = Mastodon(
    client_id,
    client_secret,
    access_token,
    api_base_url=MASTODON_URL
)
	  

Ces commandes sont les mêmes quel que soit le bot, donc je les mets dans leur propre module botinit.py (sur Gitlab), pour pouvoir les ré-utiliser facilement. Je range aussi les informations de connexion au compte dans un fichier keys.txt à part. Comme ça, il suffit d'importer botinit.py, pour créer l'objet api et communiquer avec Mastodon.


from botinit import get_api
api = get_api('keys.txt')
	  
On peut alors envoyer des messages

api.status_post('Bonjour tout le monde !', visibility='public')
	  
Récupérer ses dernières notifications (ici, les dix dernières, avec limit=10)

notifications = api.notifications(limit=10)
	  
Partager des images

media = api.media_post('chemin/vers/mon/image.jpg')
api.status_post('Regardez cette image', media_ids=media.id)
	  
Créer des sondages

choix = api.make_poll(
    ['Apollo', 'Amarcel', 'Avélo'],
    expires_in=3600 # une durée en secondes : au bout de ce temps, le résultat du sondage sera affiché
)
api.status_post(
    'Comment s'appellent les missions lunaires américaines habités ?',
    poll = choix
)
	  

Et bien d'autres choses encore...

Stocker les questions

Au moment d'avoir l'idée de créer un bot, j'avais déjà écrit quelques dizaines de questions en lien avec l'espace. Pour qu'elles soient facilement lisible par un ordinateur, je les ai rangées dans un fichier JSON. Mon fichier data.json contient une liste de toutes les questions/réponses possibles.

Chaque question/réponse contient un dictionnaire avec une question, sa réponse, une petite explication. Dans le fichier JSON, la question, la réponse et l'explication sont elles-mêmes sous forme de dictionnaire.


# Dictionnaire de question/réponse
{
  'question': # Un dictionnaire avec tous les détails de la question
  'answers': # Un dictionnaire avec tous les détails concernant les réponses acceptées
  'explanation': # Un dictionnaire avec tous les détails concernant l'explication
}
	  

En commençant ce projet, j'avais dans l'idée de faire un bot qui pose des questions en français et un autre qui pose les mêmes questions en anglais. Dans mon fichier data.json j'ai pour chaque question, chaque réponse et chaque explication la version anglaise et la version française. Ainsi, il suffit de préciser au niveau de la ligne de commande la langue à utiliser lorsque l'on démarre le bot.

En outre, certaines questions ont des images. S'il y a une image ou non ainsi que le nom de l'image sont aussi indiquées dans mon fichier JSON. Les explications sont sous exactement le même format que les questions.


# Dictionnaire de question (ou d'explication)
{
  'has_image': # True si une image doit être ajoutée à la question, False sinon
  'fr': # L'intitulé de la question en français
  'en': # L'intitulé de la question en anglais
  'image': # Le nom du fichier avec l'image
}
	  

À une question donnée, il y a souvent plusieurs réponses possibles. Par exemple, la translitération d'un nom russe et l'écriture cyrillique du même nom peuvent toutes les deux être acceptables. Je ne veux pas non plus que le bot soit trop sévère, donc si la réponse est une valeur numérique, les gens qui répondent n'ont pas besoin de connaître la valeur exacte à 4 chiffres significatifs près. Si la réponse est une date, trouver la bonne décennie est déjà suffisamment difficile (je suis nulle pour me souvenir des dates !).

Il y a donc trois types de questions qui sont traitées de façons différentes par le bot.


# Dictionnaire de réponse si la réponse est un mot ou une phrase
{
  'type': 'phrase'
  'fr': # La liste des réponses acceptables en français
  'en': # La liste des réponses acceptables en anglais
}

# Dictionnaire de réponse si la réponse est un chiffre ou une date
{
  'type': # 'date' ou 'numerical'
  'value': # Réponse exacte à la question
}
	  

Définir le comportement du bot

Avant de commencer à coder, il faut se demander comment on veut que le bot se comporte. Pour le quiz de l'espace, je voulais qu'il pose une question et que les gens essayent de répondre. Si quelqu'un trouve la bonne réponse, alors il donne une petite explication de la réponse et il pose la question suivante. Si jamais une question est trop compliquée et que personne ne trouve la réponse au bout de quelques heures, alors il donne la réponse, et passe à la question suivante.

Poser une première question

En pratique, après avoir ouvert la connection avec Mastodon, on lit le fichier qui contient les questions et on en tire une au hasard.


from botinit import get_api
api = get_api('keys.txt')

with open('data.json', 'r') as f:
    contents = json.load(f)

line = questions[random.randint(0, len(questions)-1)]
question = line['question']
explanation = line['explanation']
answer = line['answer']
	  

On récupère la dernière notification reçue par le bot et on la range dans la variable since_post (ça nous sera utile ensuite). Puis on poste la question avec son image si elle en a une, et de sorte que la question soit visible par tout le monde (avec l'option visibility='public').


since_post = api.notifications(limit=1)[0]

if question['has_image']:
    media = api.media_post('./images/' + question['image'])
else:
    media = None

question_post = api.status_post(
    question['fr'],
    visibility='public',
    media_id=media
)
	  
Une question posée par le bot.

On attend un peu et on récupère les réponses reçues. On ne veut pas voir les notifications qui ont été reçus avant de poser la question, ce qu'on précise avec le mot clé since_id=since_post. On ne s'intéresse pas non plus aux favoris, ni aux re-pouets, donc on utilise le mot clé mentions_only=True.


sleep(600)
notifications = api.notifications(since_id=since_post, mentions_only=True )
	  

Chaque élément dans la liste notifications est un dictionnaire contenant toutes les informations relatives au message, à son envoyeur et à son destinataire. Ici, on ne veut que le contenu du message qu'on extrait avec les mots clés .status.content.


for message in notifications:
    print(message.status.content)
	  

Le contenu de chaque message est au format HTML. Si par hasard, la réponse à la question est contenue dans une des balises, on ne veut pas que le bot considère cette réponse comme juste. On enlève donc les balises, pour éviter qu'elles génèrent de faux positifs. Je vous passe la façon dont j'ai enlevé les balises HTML, car je compte améliorer cette partie là du code. On se retrouve alors avec une chaine de caractères.


toot = notifications[0]
suggested = toot.status.content
suggested = sans_balises(suggested)
	  

Regarder si une réponse est juste

Une fois qu'on a récupéré une réponse, extrait son contenu et enlevé les balises HTML, il reste à déterminer si la réponse proposée suggested est juste. Il y a trois types de réponses (voir la section sur le stockage des questions).

Si la réponse est un mot ou une phrase, on regarde simplement si une des réponses acceptables est contenue dans la réponse proposée. Je considère que « Apollo 10 », « Apollo10 » et «apollo 10 » sont la même réponse, donc je passe tous les caractères en minuscules et j'enlève les espaces.


if answer['type'] = 'phrase':
    ok = False
    for a in answer:
        if a.lower().replace(" ", "") in suggested.lower().replace(" ", ""):
            ok = True
	  

Si la réponse est un nombre, on cherche dans la réponse suggérée une regex qui ressemble à un ou plusieurs chiffres, suivi ou non par une virgule et d'autres chiffres. Comme les ordinateurs utilisent des points pour séparer les décimales des chiffres à virgule, alors qu'en français on utilise des virgules, il faut aussi remplacer les virgules par des points pour pouvoir faire des maths avec


nombres = re.findall("[+-]?\d+[\.,]?\d*", suggested)
nombres = [float(e.replace(",", ".")) for e in nombres]
	  

Puis pour chacun de ces nombres, on regarde s'il est contenu dans un intervalle de 10% autours de la valeur attendue.


ok = False
solution = float(answer['value'])	      
if answer['type'] == 'numerical':
    if val < 1.1*solution and val > .9*solution:
        ok = True
	  

Si la réponse est une date, on fait une recherche avec une regex un peu plus simple, puis on regarde si la réponse proposée est la bonne à 10 années près.


nombres = re.findall("\d+", suggested)
nombres = [e.replace(",", ".") for e in nombres]

ok = False
solution = float(answer['value'])	      
if answer['type'] == 'date':
    if val < solution + 10 and val > solution - 10:
        ok = True
	  

Donner la bonne réponse

Lorsque quelqu'un a donné la bonne réponse, alors le bot félicite la personne et explique la réponse. Le mot-clé in_reply_to=question_post sert à ce que la réponse apparaisse dans le fil de la question. En écrivant '@'+toot.account.acct, la personne qui a écrit le message toot sera mentionnée et recevra une notification.


if ok:
    since_post = api.status_post(
        "Bravo à @" + toot.account.acct + "qui a été lae plus rapide. La réponse était " + answer[0] + explanation['fr']
        visibility='public',
        in_reply_to_id=question_post
)
	  
Le bot qui donne la solution à une question. Enfin, on met en favori les messages avec la bonne réponse

api.status_favourite(toot.status)
	  

Conclusion

Vous avez vu comment mon bot envoie des messages avec ou sans image, gère les questions et détermine la réponse juste. Si vous avez des questions, venez m'en parler sur Mastodon !