Retour d'expérience sur le développement d'un chatbot en Node.js avec serverless
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.
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à.
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.
A la fin de chaque semaine, le bot fixe un nouvel objectif à atteindre - forcément plus ambitieux que la semaine précédente.
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à.
A tout moment, le fumeur peut décider d'interrompre le programme.
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 :
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:
- Node.js pour la partie serveur, en mode Serverless avec AWS lambda
- DynamoDb pour le stockage de l'état du fumeur
- Octopush pour l'envoi et la réception de SMS
- nlp compromise pour le traitement en langage naturel (ou Natural Language Processing, NLP)
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()
.
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.
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 nonnull
lorsque l'objet n'a pas été trouvé. - Les opérations
createTable
etdeleteTable
, 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 appelantsms.set_sms_fields_1([...])
{ch2}
, les valeurs sont spécifiées en appelantsms.set_sms_fields_2([...])
{ch3}
, les valeurs sont spécifiées en appelantsms.set_sms_fields_3([...])
{prenom}
, les valeurs sont spécifiées en appelantsms.set_recipients_first_names([...])
{nom}
, les valeurs sont spécifiées en appelantsms.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 SMSdailyMessage
est exécuté par un cron qui envoie le message journalier à chaque utilisateur, le message étant basé sur l'état de l'utilisateurbotConversation
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
:
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