Retour d'expérience sur le développement d'un chatbot en Node.js avec serverless

Thiery Michel
Thiery MichelDecember 16, 2016
#bot#reference#serverless#node-js

Les bots ont aujourd'hui le vent en poupe, comme le démontre le nombre grandissant de librairies et services SaaS permettant de les implémenter/utiliser. Ceci est flagrant si on recherche par exemple le terme bot sur stackshare ou npm.

Cela est notamment dû aux avancées relativement récentes dans le domaine du Traitement du langage naturel (Natual Language Processing ou NLP), et au fait que l'interface texte est très courante (et très accessible) sur mobile.

Marmelab a décidé d'explorer cette technologie et pour ce faire, de réaliser un projet concret nommé Tobaccobot. Il s'agit d'un coach virtuel pour arrêter de fumer en un mois, avec qui l'utilisateur communique uniquement par SMS.

L'interface

Le principe du bot est très simple: une personne qui a envie d'arrêter de fumer s'inscrit au programme via une page web, avec son nom et son numéro de téléphone.

stop

A partir de là, toutes les interactions se font par SMS. Le fumeur reçoit un message qui lui demande combien de cigarettes il a fumé ce jour-là.

subscription

A partir de la réponse à cette question, le bot va déterminer un nombre maximum de cigarettes à ne pas dépasser pour la prochaine semaine - avec pour objectif d'aider le fumeur à arrêter totalement en 4 semaines.

Chaque matin, le fumeur recevra un SMS lui demandant combien de cigarette il a fumé la veille, de manière à évaluer la progression. En fonction de sa réponse, le bot l'encouragera ou le réprimandera. Et les réponses devront varier d'un jour sur l'autre, pour ne pas lasser le fumeur.

daily_message

A la fin de chaque semaine, le bot fixe un nouvel objectif à atteindre - forcément plus ambitieux que la semaine précédente.

end_of_week

A la fin de la 4ème semaine, le bot détermine si le fumeur est oui ou non parvenu à arrêter de fumer. Il envoie un message d'adieu et la conversation s'arrête là.

end

A tout moment, le fumeur peut décider d'interrompre le programme.

stop

Note: Nous ne sommes pas tabacologues chez marmelab - et pour tout dire, il n'y a même pas de gros fumeur chez nous. Ce cas d'utilisation a juste été choisi pour servir de support à une expérimentation technique. Si ce coach virtuel aide un jour quelqu'un à arrêter de fumer, alors nous aurons fait d'une pierre deux coups !

Le workflow de conversation

Pas évident de trouver un formalisme pour modéliser une interface conversationnelle. Nous avons tenté de dessiner des boites et des flèches, et nous sommes parvenus au résultat suivant :

end

Note: Après le début du développement, nous avons découvert un super outil pour modéliser un workflow à partir d'une description texte : code2flow.

Les technologies utilisées

Pour implémenter ce coach virtuel par SMS, nous avons choisi d'utiliser les technologies suivantes:

Nous allons revenir en détail sur les raisons du choix de ces technologies et leur utilisation dans les sections suivantes. Si vous voulez voir du code, sautez à la fin de l'article pour y trouver le lien vers la source du projet, que nous publions en licence MIT.

Comment ça ?! Pas de botkit !

Dans le monde Node.js, la librairie de référence pour implémenter des chatbots est Botkit. Cette librairie très populaire, bien qu'étant d'excellente qualité, ne correspond pas à notre cas d'utilisation.

Tout d'abord, botkit vise surtout les plateformes de chat (Slack, Messenger, etc.) mais ne supporte pas l'envoi et la réception de SMS. Il existe bien botkit-sms, mais ce projet n'est pas très actif, et utilise Twilio. Or nous avons choisi Octopush. Il aurait donc fallu développer notre propre adaptateur.

Ensuite, Botkit est prévu pour écouter sur un port l'arrivée de messages. Il s'agit d'un démon, un process node qui ne s'arrête jamais. Mais avec serverless, le service doit s'arrêter après le traitement de chaque message, et est stoppé de force s'il ne rend pas la main dans les 5 secondes. Il aurait donc fallu forcer botkit à quitter après chaque message en killant le process node - pas très propre.

Enfin, puisqu'il est prévu pour s'exécuter sous forme de tâche de fond, botkit persiste le contexte des conversations en mémoire. Ce contexte est vidé à chaque redémarrage. Donc nativement, il n'est pas facile de conserver un contexte de conversation en mode serverless.

Il est bien entendu possible de fournir à botkit un stockage de conversation personnalisé (pour sauvegarder vers dynamodDb dans notre cas). Mais botkit impose trois tables: users, channels et teams, dont au moins deux n'ont aucun sens dans notre cas (channels et teams). Il aurait fallu tout de même les implémenter ou du moins les mocker.

Au vu de toutes ces limitations, nous avons décidé que botkit n'était pas approprié pour notre application.

AWS Lambda

Vous connaissez peut-être le principe d'AWS lambda: c'est un hébergement PaaS (platform-as-a-service) semblable à heroku, où on ne déploie que... des fonctions. Dans ce contexte, une application est un ensemble de fonctions qui sont appelées en réponse à des événements (par exemple requête HTTP ou cron). Et c'est API Gateway, autre service d'Amazon, qui se charge de router les appels à une API HTTP vers une fonction lambda pour en calculer la réponse.

Cela permet de n'exécuter le code que quand il est nécessaire, et de se passer d'un serveur web.

L'activité de notre bot est très ponctuelle : il relance l'utilisateur une fois par jour, et n'attend qu'une réponse par jour. Faire tourner un serveur pour rien 99% du temps serait du gâchis dans ce cas; l'approche AWS lambda est toute indiquée.

Sous le capot, AWS utilise Docker pour stocker les fonctions lambda. Il réveille un conteneur lorsqu'une lambda est sollicitée, et le rendort après quelques minutes d'inactivité. Mais tout cela se fait automatiquement, et le développeur ne voit, lui, que des fonctions. Donc hormis le "serveur" d'API Gateway, qui est en fait juste un reverse proxy géant mutualisé, AWS facture uniquement l'hébergement lambda à l'appel de fonction, c'est-à-dire à la requête. Et c'est extrêmement bon marché (le premier million de requêtes est gratuit).

Serverless

Serverless est une librairie JS open-source qui permet d'utiliser AWS lambda facilement, en automatisant la configuration et le déploiement sur AWS. Cette librairie prend en charge non seulement AWS lambda bien sûr, mais aussi API Gateway pour les événements HTTP, ainsi que cron et Dynamodb pour la base de donnée dans notre cas.

Serverless utilise un fichier de configuration serverless.yml, dans lequel on déclare les lambdas (functions) et les resources (resources) utilisées par les lambdas. Voici pour exemple celui de tobaccobot :

service: tobaccobot

functions:
  botConversation:
    handler: src/serverless/index.botConversation # la fonction exportée avec le nom botConversation dans le fichier index.js
    events: # ce qui déclenche l'appel de cette fonction
      - http: # la partie HTTP sert à configurer API Gateway
          method: POST
          integration: lambda
          path: bot_conversation # le path dans l'url
          cors: true # L'API HTTP accepte les appels de n'importe quel domaine (CORS)
  getBotConversation:
    handler: src/serverless/index.botConversation
    events:
      - http:
          method: GET # la même fonction doit répondre en POST et en GET, contrainte d'octopush (voir plus loin)
          integration: lambda
          path: bot_conversation
          cors: true
  dailyMessage:
    handler: src/serverless/index.dailyMessage
    events:
      - schedule: # ici ce n'est pas une requête HTTP qui déclenche l'appel mais un cron
          rate: cron(0 8 ? * * *)
          enabled: true
  setupTables:
    handler: src/serverless/index.setupTables # pas d'events, on ne peut donc l'appeler qu'avec l'API AWS
  subscribe:
    handler: src/serverless/index.subscribe
    events:
      - http:
          method: POST
          integration: lambda
          path: subscribe
          cors: true
  reportData:
    handler: src/serverless/index.reportData
    events:
      - http:
          method: POST
          integration: lambda
          path: report_data
          cors: true

resources:
  Resources:
    # une table dynamodb pour stocker les infos des fumeurs
    DynamoDbSmokerTable: # Les noms de ressources doivent être uniques
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: smoker
        AttributeDefinitions:
          - AttributeName: phone
            AttributeType: S # string
        KeySchema:
          - AttributeName: phone
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 5
          WriteCapacityUnits: 5
    # une policy IAM pour permettre aux lambdas d'accéder à cette table dynamodb
    DynamoDBSmokerIamPolicy: # Y compris les noms des policies
      Type: AWS::IAM::Policy
      DependsOn: DynamoDbSmokerTable
      Properties:
        PolicyName: lambda-dynamodb-smoker # Ce nom doit également être unique
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:DescribeTable
                - dynamodb:GetItem
                - dynamodb:PutItem
                - dynamodb:UpdateItem
                - dynamodb:DeleteItem
                - dynamodb:Scan
              Resource: arn:aws:dynamodb:*:*:table/smoker
        Roles:
          - Ref: IamRoleLambdaExecution
    # une autre table dynamodb pour stocker les infos des fumeurs qui sont arrivés au bout du programme
    DynamoDbArchiveTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: archive
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 5
          WriteCapacityUnits: 5
    # comme la précédente, il faut une policy pour la rendre accessible
    DynamoDBArchiveIamPolicy:
      Type: AWS::IAM::Policy
      DependsOn: DynamoDbArchiveTable
      Properties:
        PolicyName: lambda-dynamodb-archive
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:DescribeTable
                - dynamodb:GetItem
                - dynamodb:PutItem
                - dynamodb:UpdateItem
                - dynamodb:DeleteItem
                - dynamodb:Scan
              Resource: arn:aws:dynamodb:*:*:table/archive
        Roles:
          - Ref: IamRoleLambdaExecution

provider:
  name: aws
  runtime: nodejs4.3
  stage: dev
  region: eu-west-1
  cfLogs: true

plugins:
  - serverless-webpack
  - serverless-offline

custom:
  webpack: ./webpack.config.serverless.js # notre conf webpack
  serverless-offline: # la conf pour l'exécution en local
    babelOptions:
      presets: ["es2015-node4", "es2016"]
      plugins: ["add-module-exports", "transform-runtime"]

Serverless fournit sa propre version du package aws-sdk, déjà configuré avec les bons accès. Et cela inclut les accès IAM.

Serverless : les pièges

Le hic principal avec serverless, c'est l'environnement de développement. Un développeur n'a pas de service AWS qui tourne sur son poste de travail. Comment tester les fonctions lambda dans ce contexte ?

Le plugin serverless-webpack permet de servir les lambdas en local, mais il ne suit pas la spécification API gateway. Heureusement, il existe le plugin serverless-offline, qui émule AWS lambda et API gateway. Il accepte aussi une configuration babel. C'est un must have !

Serverless a eu une mise à jour majeure entre les versions 0.5 et 1.0, et l'on trouve encore beaucoup de documentation concernant la version précédente. Ne soyez pas étonné que le copier/coller depuis Stack Overflow ne donne rien, et lisez la doc officielle.

Les logs des lambdas sont consultables grâce à la commande serverless logs -f [lambdaName]. Peu importe le nombre de conteners utilisés par AWS: tous les logs d'une lambda sont rassemblés chronologiquement.

Serverless consigne automatiquement le résultat des console.error() et console.info(), mais il ignore les console.log().

aws logs

API Gateway ne peut retourner que du JSON. Il est donc impossible d'utiliser une lambda pour servir du HTML, ou une image qui serait générée.

Node.js

Pour ce qui est du code en lui-même, AWS lambda utilise Node 4.3.2.

Serverless compresse le code de la fonction lambda dans un fichier zip. Les packages node ne sont pas inclus, et AWS lambda n'accepte pas d'en installer de son côté. Pour utiliser des packages externes, il faut donc concaténer notre code et celui de ses dépendances dans une seule fonction - c'est le travail d'un module bundler. Nous avons choisi Webpack, que nous utilisons couramment pour le développement frontend.

Serverless fournit également un plugin webpack pour automatiser la construction des fichiers à déployer:

plugins:
  - serverless-webpack
custom:
  webpack: chemin/vers/webpack.config.js

D'ailleurs, quitte à utiliser webpack, autant ajouter babel également, histoire de profiter des dernières nouveautés d'ES6. Rien de nouveau de ce côté là.

Un inconvénient de webpack est que certaines librairies que l'on a l'habitude d'utiliser côté serveur ne fonctionnent plus. C'est par exemple le cas de config, qui lit les fichiers de config au moment de l'exécution. Ce problème est nuancé par la disponibilité d'un plugin permettant de reproduire le mécanisme de manière transparente: webpack-config-plugin.

DynamoDb

DynamoDb est une base de donnée clef/valeur relativement simple, semblable à Redis. Elle permet de définir un table avec une clef de partition qui sert d'identifiant unique pour l'objet. Si on veut, on peut ajouter une clef de tri, mais dans ce cas la clef de partition n'est plus unique et c'est la clef de tri qui fait la différence.

Dans notre cas nous avons choisi une seule clef de partition: le numéro de téléphone de l'utilisateur.

Mis à part les clefs, un document dynamoDb n'a aucune validation et accepte tout format.

Aws-sdk fournit l'objet dynamoDb pour interroger le service dynamoDb. Il propose également une interface web très facile d'utilisation.

aws dynamodb web GUI

Dynamo DB : les pièges

DynamoDb retourne des objets avec une structure un peu particulière, qui précise le type de chaque champ:

{
  name: {
    S: "john"; // une clef est ajouté pour préciser le type de donnée de l'attribut, ici une string
  }
}

Il est fastidieux de convertir ce format en simple format JSON et inversement. Heureusement, il existe la librairie dynamodb-oop qui réalise cette transformation et offre une api légèrement plus agréable.

Il faut néanmoins faire attention à 2 points :

  • Une opération getItem retourne un objet vide ({}) et non null lorsque l'objet n'a pas été trouvé.
  • Les opérations createTable et deleteTable, bien qu'acceptant un callback, retournent lorsque l'opération à été initialisée et non pas terminée.

Pour être sûr que ce type d'opération est achevée, il faut utiliser dynamloDb.waitFor qui permet d'attendre un événement, en l'occurrence tableExists et tableNotExists. Par exemple pour createTable :

function createTable(params) {
  return new Promise((resolve, reject) => {
    dynamoDB.on("error", (operation, error) => reject(error));

    dynamoDB.client.createTable(params, err => {
      if (err) {
        reject(err);
        return;
      }

      dynamoDB.client.waitFor(
        "tableExists",
        params,
        (errTableExists, result) => {
          if (errTableExists) return reject(errTableExists);
          return resolve(result);
        }
      );
    });
  });
}

A noter que côté AWS, serverless gère la création de la table automatiquement.

Pour émuler le stockage sur dynamo DB en local, il existe un module dynamodb-local. Il n'offre par contre pas d'interface web pour consulter et éditer le contenu dynamodb aisément. dynamodb-local ne propose qu'une console beaucoup trop limitée, puisqu'elle demande de coder les opérations à réaliser en javascript en utilisant aws-sdk. Cette console est accessible sur le port 8000.

Octopush

Pour l'envoi des SMS nous avons choisi Octopush qui est le moins cher, malgré une api orientée campagne de publicité.

Pour utiliser Octopush il existe un module node: octopush.

L'utilisation est très simple:

// On crée une instance de SMS avec nos credentials
const sms = new octopush.SMS(config.octopush.user_login, config.octopush.api_key);

// On appelle ensuite un certain nombre de fonctions de configuration, par exemple:
sms.set_sms_text(message);
sms.set_sms_recipients([phone]); // Attention, il faut passer un tableau
sms.set_sms_request_id(sms.uniqid()); // Il est possible de spécifier un identifiant que l'on génère de notre côté
...
// L'envoi du sms
sms.send((error, result) => {
    ...
});

Il est à noter qu'Octopush supporte le publipostage comme le suggère le fait que set_sms_recipients accepte un tableau de numéros de téléphones.

Il est alors possible de remplacer des variables dans le texte. Malheureusement, elles ne sont qu'au nombre de 5:

  • {ch1}, les valeurs sont spécifiées en appelant sms.set_sms_fields_1([...])
  • {ch2}, les valeurs sont spécifiées en appelant sms.set_sms_fields_2([...])
  • {ch3}, les valeurs sont spécifiées en appelant sms.set_sms_fields_3([...])
  • {prenom}, les valeurs sont spécifiées en appelant sms.set_recipients_first_names([...])
  • {nom}, les valeurs sont spécifiées en appelant sms.set_recipients_last_names([...])

Octopush : les pièges

Pour gérer les réponses des utilisateurs, il faut fournir une URL qu'Octopush appellera avec la réponse. Pour se conformer aux spécifications d'Octopush, cette URL doit répondre immédiatement sans retourner de contenu. Le traitement par l'application cliente doit donc s'effectuer de manière asynchrone après avoir répondu à Octopush.

Octopush demande également que cette url soit interrogeable en GET pour pouvoir la tester depuis un navigateur.... La vérification de cette url n'est pas automatisée pour l'instant, et peut leur prendre jusqu'à une journée...

Octopush ne récupère que les SMS en réponse à un message attendant une réponse (option_with_replies). Cela signifie que si l'utilisateur envoie plusieurs messages successifs, seul le premier sera pris en compte.

Nous avons eu besoin d'une quatrième variable pour l'un de nos messages et avons simplement utilisé la variable prenom dans ce cas.

Au moment de l'écriture de cet article, la documentation d'octopush précise à tort que set_recipients_first_names remplacera les chaines {nom} et que set_recipients_last_names remplacera les chaines {prenom}.

Tobaccobot en détail

La logique de conversation

Le workflow de conversation montre que ce bot est en fait une machine à état tout-à-fait classique. Une action (une requête HTTP, un cron) fait passer l'objet smoker d'un état à un autre en fonction de certaines règles.

Il existe de nombreuses librairies pour implémenter une machine à état, mais vu la simplicité de la logique de tobaccobot, nul besoin d'aller chercher plus loin que quelques if imbriqués dans une fonction.

La signature de cette fonction est (etat, action) => état. Si vous pratiquez la programmation fonctionnelle ou React, vous reconnaissez sans doute ce pattern: c'est celui d'un reducer. Et une librairie a fait beaucoup parler d'elle pour une implémentation de ce pattern à destination de React: c'est redux. Utilisant cette librairie de façon intensive sur des projets frontend, nous avons naturellement commencé par elle pour implémenter la logique de conversation. Mais en définitive, redux n'apporte rien de plus que la fonction reduce() native dans notre cas, et nous avons fini par supprimer cette dépendance.

Voici par exemple un extrait du code qui, à partir de l'état du smoker déduit d'un nombre de cigarettes consommées, déduit le message à envoyer:

export default evaluation => {
  if (evaluation.backFromBad === 1) {
    return backFromBad();
  }

  if (evaluation.backFromBad === 2) {
    return backFromReallyBad(evaluation.targetConsumption);
  }

  if (evaluation.backFromBad > 2) {
    return backFromBadCombo();
  }

  const lastDelta = evaluation.delta.slice(-1)[0];
  const previousDelta = evaluation.delta.slice(-2)[0];
  if (lastDelta <= -3) {
    if (evaluation.delta.length >= 2 && previousDelta <= -3) {
      return continuedGreatProgress(lastDelta);
    }
    return greatProgress(lastDelta);
  }

  if (evaluation.state === "bad") {
    if (evaluation.combo.hit === 2) {
      return reallyBad(reallyBadLinks[(evaluation.combo.repeatition - 1) % 3]);
    }
    if (evaluation.combo.hit > 2) {
      return badCombo(
        evaluation.combo.hit,
        evaluation.targetConsumption,
        badComboLinks[(evaluation.combo.repeatition - 1) % 3]
      );
    }

    return bad(evaluation.targetConsumption);
  }

  if (evaluation.combo.hit === 2) {
    return reallyGood();
  }

  if (evaluation.combo.hit > 2) {
    return goodCombo(evaluation.combo.hit);
  }

  return good();
};

Pour le contenu des messages backFromBad(), backFromReallyBad() et les autres, jetez un oeil à la source.

Les side effects

Dans notre machine a état, les actions ont deux effets: changer l'état du smoker, et un ensemble d'opérations qui ne sont pas répercutées dans l'état du smoker (stockage dans dynamodb, envoi de SMS, logs). Cet ensemble d'opérations n'est pas modélisable par une fonction pure (au sens de la programmation fonctionnelle), on les appelle des side effects. Très souvent, ces side effects sont des opérations asynchrones.

Pour gérer ces opérations asynchrones, plutôt que d'utiliser les callbacks, nous avons utilisé les générateurs. Et nous nous sommes aidés de sg, une petite librairie créé par Marmelab. sg gère l'ordonnancement des tâches asynchrones avec des générateurs (comme le fait co.js), mais au lieu de retourner des promesses directement, sg retourne des effets décrivant quoi faire (comme le fait redux-saga).

Les générateurs permettent de décrire le flux des actions asynchrones de manière synchrone et, avec les effets, on peut tester l'ordonnancement des opérations sans avoir à ce soucier de leurs implémentations.

L'effet le plus couramment utilisé est call. Il s'agit simplement de l'appel d'une fonction asynchrone. Par exemple, avec le générateur suivant:

export default function* dailyMessageSaga(smokers) {
  const dailySmokers = yield call(getDailySmokers, smokers);
  const { asked = [], dubious = [], qualified = [] } = yield call(
    sortSmokersByState,
    dailySmokers
  );

  yield call(notifyDubious, dubious);

  // Users with asked state haven't answered the previous day, we send them a message for the current day anyway
  yield call(notifyQualified, [...asked, ...qualified]);
}

Il est possible d'écrire les tests de cette façon:

describe("dailyMessageSaga", () => {
  let iterator;

  before(() => {
    iterator = dailyMessageSaga("users");
  });

  it("should call getDailySmokers with users passed to the saga", () => {
    const { value } = iterator.next();
    expect(value).toEqual(call(getDailySmokers, "users"));
  });

  it("should call sortSmokersByState with users returned by getDailySmokers", () => {
    const { value } = iterator.next("dailySmokers");
    expect(value).toEqual(call(sortSmokersByState, "dailySmokers"));
  });

  it("should call notifyQualified with qualified and asked key then notifyDubious with dubious key", () => {
    let { value } = iterator.next({
      asked: ["asked"],
      qualified: ["qualified"],
      dubious: "dubious",
    });
    expect(value).toEqual(call(notifyDubious, "dubious"));
    value = iterator.next().value;
    expect(value).toEqual(call(notifyQualified, ["asked", "qualified"]));
  });
});

Découpage

Passons maintenant à l'implémentation de notre bot. Il est composé de 3 lambdas :

  • subscribe répond au post du formulaire ; il crée un utilisateur et envoie le premier SMS
  • dailyMessage est exécuté par un cron qui envoie le message journalier à chaque utilisateur, le message étant basé sur l'état de l'utilisateur
  • botConversation est appelé par Octopush et traite les réponses de l'utilisateur

Passons rapidement sur la lambda subscribe, qui est activée par une route POST appelée par un simple formulaire statique hébergé sur s3.

subscribe:
  handler: src/serverless/index.subscribe
  events:
    - http:
        method: POST
        integration: lambda
        path: subscribe
        cors: true

La fonction reçoit un nom et un numéro de téléphone, avec lesquels elle initialise un utilisateur, puis lui envoie un message de bienvenue.

Traitement des messages entrants

La lambda botConversation est appelé par Octopush via une route POST:

really_good
botConversation:
  handler: src/serverless/index.botConversation
  events:
    - http:
        method: POST
        integration: lambda
        path: bot_conversation
        cors: true

Elle récupère l'auteur du message puis analyse son message, récupère son état depuis DynamoBD, calcule le changement d'état nécessaire, met à jour l'état dans DynamoDB, et envoie la réponse à l'utilisateur.

La logique dépend de l'état de l'utilisateur:

  • état welcomed : C'est l'état dans lequel l'utilisateur est juste après avoir reçu le premier SMS. On attend un nombre (la consommation de cigarettes initiale), et on s'en sert pour calculer la consommation à atteindre pour chacune des 4 semaines du programme. Son état passe alors à qualified, et on lui envoie l'objectif par sms. Si l'on reçoit autre chose qu'un nombre, son état passe à dubious, et on lui annonce qu'on lui redemandera demain.
  • état asked : C'est l'état dans lequel est l'utilisateur après avoir reçu le SMS quotidien lui demandant sa consommation de la veille. On attend un nombre correspondant au nombre de cigarette fumées, et on ajoute ce nombre dans l'historique des consommations. Puis on analyse son historique de consommation pour déterminer quel message lui envoyer.

Si l'utilisateur envoie stop, quel que soit son état, on met fin au programme, en lui envoyant un message de confirmation.

Traitement des messages sortants

La lambda dailyMessage est activée tous les jours à 8h par un cron:

dailyMessage:
  handler: src/serverless/index.dailyMessage
  events:
    - schedule:
        rate: cron(0 8 ? * * *)
        enabled: false

La syntaxe cron pour AWS prend 6 paramètres: minutes, heures, jour du mois, mois, jour de la semaine, et année. On ne peut pas activer simultanément le jour du mois et le jour de la semaine ; pour ignorer l'un des deux on utilise le caractère ?.

La lambda dailyMessage récupère tous les utilisateurs avec la commande scan de DynamoDB. scan accepte en paramètres batchSize et exclusiveStartKey, qui permettent de réaliser la commande en batch. batchSize spécifie le nombre de résultats à retourner, et exclusiveStartKey précise la clef à partir de laquelle reprendre la requête.

Le résultat de scan inclut la dernière clef retournée. Pour exécuter les traitements en série, nous utilisons une récursion sur le générateur.

function* dailyMessage() {
  /// ... first batch
  yield* dailyMessage(lastKey);
}

yield* permet d'invoquer un générateur dans le contexte du générateur courant. Ce n'est donc pas l'itérateur qui se trouve produit, mais la première valeur produite par celui-ci. En résumé, c'est comme si l'on réalisait un copier/coller du code du générateur produit.

Ensuite, chaque utilisateur est trié suivant son état dubious/qualified, et le nombre de jours restants. Les utilisateurs dubious sont les utilisateurs qui se sont inscris, mais n'ont jamais ou mal répondu à la première question. dailyMessage va alors les relancer.

Enfin, les utilisateur vont être triés selon le nombre de jours qu'il leur reste:

  • S'ils sont à la fin du programme: si leur consommation est descendue à 0 cigarettes sur les 3 derniers jours, nous les félicitons. Sinon, nous les invitons à recommencer.
  • S'ils sont à la fin d'une semaine: nous spécifions un nouvel objectif.
  • Das tout les autres cas: On décrémente le nombre de jour restant et on demande à l'utilisateur combien de cigarettes il a fumé hier

L'implémentation de cette machine a été assez simple se compose de quelques if impriqués - rien de très notable, à part l'apport bénéfique de sg qui simplifie les side effects.

Traitement du langage naturel

De plus en plus de librairies rendant le traitement en langage naturel (ou NLP pour Natural Language Processing) accessible apparaissent, et notemment en node.js:

Le NLP est un sujet coeur pour les bots quand il s'agit de traiter les questions. De notre côté, nous n'avions qu'à traiter des réponses, et dans un cadre très restreint.

nlp nous a simplement permis de récupérer le nombre de cigarettes dans les messages envoyés par l'utilisateur.

Que celui nous réponde at least 15 cigarettes, no more than fifteen cigarettes or 15, nlp nous retourne 15.

Conclusion

Le projet tobaccobot, a été l'occasion de nous familiariser avec plusieurs de technologies: serverless, aws lambda, aws dynamoDb, octopush.

Serverless est un outil puissant, mais mettre en place le bon environnement de développement a demandé beaucoup d'expérimentation pour trouver la bonne configuration. De plus, nous avons passé beaucoup de temps à nous documenter et à configurer l'environnement serverless comparé à un serveur traditionnel. Cela dit, ce travail ayant été réalisé, la mise en place sera bien plus rapide à l'avenir.

Une fois la partie serverless mise en place, le bot en lui même s'est révélé simple à implémenter, puisqu'il s'agit de prendre un événement (sms ou cron) et un état en entrée et de mettre à jour l'état et générer un message en sortie. La modélisation de la conversation est donc la partie la plus difficile.

Il aurait été intéressant d'avoir à gérer une interaction avec un groupe d'utilisateur, ou une interaction plus variée. Tout bien considéré, cela reste une bonne introduction à la réalisation d'un bot.

Le code de notre tobaccobot est disponible sur github: https://github.com/marmelab/tobaccobot

Did you like this article? Share it!