├── .babelrc
├── .eslintrc
├── .github
└── FUNDING.yml
├── .gitignore
├── .travis.yml
├── LICENSE.txt
├── README.md
├── docs
└── botml-logo.png
├── examples
├── bonjour.bot
├── calculator.bot
├── crypto.bot
├── echo.bot
├── expressions.bot
├── hello.bot
├── hello.js
├── index.html
├── knock.bot
├── nlp.bot
├── nlp.js
├── runkit.js
├── service.bot
└── workflow.bot
├── lib
├── blockParser.js
├── botml.js
├── cli.js
├── context.js
├── emitter.js
├── log.js
├── pattern.js
└── utils.js
├── npm-shrinkwrap.json
├── package.json
└── test
├── base.js
├── basexp.test.js
├── chain.test.js
├── code.test.js
├── dialogues.test.js
├── memory.untest.js
├── mocks
├── chain.bot
├── code.bot
├── dialogues.bot
├── multi-line-dialogue.bot
├── not-equal.bot
├── reference-list.bot
├── service.bot
├── switch-code.bot
├── switch.bot
├── validation.bot
└── workflows.bot
├── multi-line-dialogue.test.js
├── not-equal.test.js
├── reference-list.test.js
├── regexp.test.js
├── service.test.js
├── switch-code.test.js
├── switch.test.js
├── validation.test.js
└── workflows.test.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | ["@babel/preset-env", { "modules": false }]
4 | ],
5 | "plugins": [
6 | "@babel/plugin-transform-runtime"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "standard",
4 | "parserOptions": {
5 | "ecmaVersion": 8,
6 | "sourceType": "module"
7 | },
8 | "env" : {
9 | "es6": true,
10 | "node": true,
11 | "browser": true
12 | },
13 | "rules": {
14 | "no-console": 1,
15 | "no-template-curly-in-string": 1,
16 | "no-unmodified-loop-condition": 0,
17 | "object-curly-spacing": [2, "always"],
18 | "semi": [2, "never"]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: arnaud
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | node_modules/
3 | .cache
4 | dist
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "9"
4 |
5 | script: echo "npm test temporarily disabled"
6 |
7 | deploy:
8 | provider: npm
9 | email: $EMAIL
10 | api_key: $NPM_TOKEN
11 | on:
12 | branch: master
13 |
14 | notifications:
15 | email:
16 | recipients:
17 | - type@codename.co
18 | - arnaud@codename.co
19 | on_success: change
20 | on_failure: always
21 |
22 | branches:
23 | only:
24 | - master
25 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2016 - 2019 CODENAME SAS [https://codename.co]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | An open source and production-ready markup language
7 | for designing modern chatbots.
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | [Botml](https://codename.co/botml/) is
39 | a declarative and powerful **markup language for designing modern chatbots**
40 | (a.k.a. conversational bots).
41 |
42 | Anyone (developers *and* non-developers) can use it to create and **teach bots
43 | how to behave**.
44 | Define the behavior of your chatbot using the right tiny bit of
45 | [formatting](#format) and engage the conversation in no time.
46 | See for yourself: a [calculator bot](https://github.com/codename-co/botml/blob/master/examples/calculator.bot)
47 | written in only two lines.
48 |
49 | ## Table of Contents
50 |
51 | - [Install](#install)
52 | - [Usage](#usage)
53 | - [Features](#features)
54 | - [Format](#format)
55 | - [Examples](#examples)
56 | - [Develop](#develop)
57 | - [Contribute](#contribute)
58 | - [License](#license)
59 |
60 | ## Install
61 |
62 | This project uses [node](http://nodejs.org) and [npm](https://npmjs.com).
63 | Go check them out if you don't have them locally installed.
64 |
65 | ```sh
66 | $ npm i -g botml
67 | ```
68 |
69 | This will install both the `botml` node package and the `bot` client.
70 |
71 | *Optionally, if you use Atom as an editor, you may want to install syntax
72 | highlighting with the [`language-botml`](https://atom.io/packages/language-botml)
73 | package.*
74 |
75 | ## Usage
76 |
77 | Either run the cli:
78 |
79 | ```sh
80 | $ bot
81 | ```
82 |
83 | or use it in your code:
84 |
85 | ```js
86 | const Botml = require('botml/lib/botml')
87 | const bot = new Botml('alice.bot')
88 | bot.start()
89 | ```
90 |
91 | or even load it statically in your browser:
92 |
93 | ```html
94 |
95 |
104 | ```
105 |
106 | ## Features
107 |
108 | Existing features are of two sorts: basic features that cover a basic bot needs,
109 | and advanced features that enable richer conversational capabilities.
110 |
111 | Basic features:
112 |
113 | - [Dialogues](#dialogue)
114 | - [Random replies](#random-replies)
115 | - [Lists](#list)
116 | - [Prompts](#prompt)
117 | - [Workflows](#dialogue-workflow)
118 | - [Conditional branches](#conditional-branches)
119 | - [Variables](#variable)
120 |
121 | Advanced features:
122 |
123 | - [Services integrations](#service) (APIs)
124 | - [Scripts](#script)
125 | - [Triggers](#trigger)
126 | - [Regular expressions](#regular-expression)
127 | - [Stanford TokensRegex](#stanford-tokensregex)
128 | - [Natural Language Processing](#natural-language-processing)
129 |
130 | ## Format
131 |
132 | The format aims to achieve the most by using the least.
133 | With the right and minimal set of conventions, it can be very powerful.
134 |
135 | The general syntax follows **3 important conventions**:
136 |
137 | 1. The text *must* be written into blocks of lines separated by at least two
138 | line breaks ;
139 | 2. Each line *must* start with a one-character symbol that defines its type ;
140 | 3. A block type is inferred by the symbol of its heading line.
141 |
142 | The most basic `.bot` file would be:
143 |
144 | ```
145 | ! BOTML 1
146 |
147 | > Hello
148 | < Hello human!
149 | ```
150 |
151 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAIoAAAAAAAAAABsiYBCI2dvskCsW_6JCzE_nW-tBfA2WSnjPZMRrrmaRu8nnBJn042F__80lMAA)
152 |
153 | ### Blocks
154 |
155 | #### Specification
156 |
157 | The specification line is needed to tell Botml that it can load the file.
158 |
159 | This *must* be the first line of any `.bot` file.
160 |
161 | ```
162 | ! BOTML 1
163 | ```
164 |
165 | The `1` stands for the current version of the format.
166 |
167 | #### Comment
168 |
169 | Comments can help make your `.bot` file clearer.
170 |
171 | They *can* be used as standalone blocks or can be inserted within actionable
172 | blocks.
173 |
174 | They *cannot* be used inline.
175 |
176 | ```
177 | # COMMENT
178 | ```
179 |
180 | #### Dialogue
181 |
182 | Dialogues are the core concept of any bot. It defines how the human and the bot
183 | can interact.
184 |
185 | A dialogue *must* start with a `>` line, that defines what sentence(s) can
186 | activate the bot to respond.
187 |
188 | There *must* be one or multiple `<` lines *after* that define the bot
189 | response(s).
190 |
191 | There *can* be multiple back and forth by repeating this sequence within the
192 | same block.
193 |
194 | ```
195 | > MESSAGE
196 | < MESSAGE
197 | ```
198 |
199 | Example:
200 |
201 | ```
202 | > Hi
203 | < Hello there. Who are you?
204 | > *
205 | < Nice to meet you.
206 | ```
207 |
208 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAI_AAAAAAAAAABsj0BEICn20dZCh0WbuSTAXau7SBB6iW4AVbemNkmFJbMY-KeSdCb2FTu2MK_QYTfeKKF3B7fMzt59LTfy8s__2ZhgAA)
209 |
210 | #### Random replies
211 |
212 | Random replies in [dialogues](#dialogue) make a bot feel less rigid.
213 | When answering to a human, the bot chooses randomly in the reply candidates.
214 | Only one of the multiple reply candidates can be chosen by the bot.
215 |
216 | ```
217 | > MESSAGE
218 | < REPLY CANDIDATE #1
219 | < REPLY CANDIDATE #2
220 | ```
221 |
222 | Example:
223 |
224 | ```
225 | > Hello
226 | < Hi there
227 | < Howdy?
228 | ```
229 |
230 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAIgAAAAAAAAAABfiIPCEjuxxv0hVrmkVI_FfN6EgMhy4GBa8Ct5ZT8-nlfk__hTTAA)
231 |
232 | #### List
233 |
234 | Lists are helpful to assemble similar notions, or alternatives.
235 |
236 | A list *must* start with a `=` line, that defines the list name.
237 |
238 | It *must* have at least one list item but *can* have more. Each list item starts
239 | with the `-` symbol.
240 |
241 | ```
242 | = LIST_NAME
243 | - LIST_ITEM
244 | - LIST_ITEM
245 | ```
246 |
247 | A list item *can* reference yet another list.
248 |
249 | ```
250 | = LIST_NAME
251 | - LIST_ITEM
252 | - [ANOTHER_LIST]
253 | ```
254 |
255 | It *can* be referenced in a `>` line for referencing multiple variants of
256 | an input pattern.
257 |
258 | It *can* be referenced in a `<` line for referencing randomly one of the
259 | list items as a response.
260 |
261 | It *can* be referenced in a `?` line ([prompt](#prompt)) for referencing
262 | multiple predefined choices.
263 |
264 | Referencing a list is done by wrapping the list name with brackets:
265 | `[LIST_NAME]`.
266 |
267 | Example:
268 |
269 | ```
270 | = citrus
271 | - oranges
272 | - lemons
273 | - grapefruits
274 |
275 | = random_fruit
276 | - apples
277 | - apricots
278 | - bananas
279 | - [citrus]
280 |
281 | > I like [random_fruit]
282 | < Oh. I prefer [random_fruit].
283 |
284 | # which is the equivalent to:
285 | # > I like apples
286 | # > I like apricots
287 | # > I like bananas
288 | # > I like oranges
289 | # > I like lemons
290 | # > I like grapefruits
291 | # < Oh. I prefer apples
292 | # < Oh. I prefer apricots
293 | # < Oh. I prefer bananas
294 | # < Oh. I prefer oranges
295 | # < Oh. I prefer lemons
296 | # < Oh. I prefer grapefruits
297 | #
298 | # > I like [random_fruit]
299 | # < Oh. I prefer [random_fruit].
300 | ```
301 |
302 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAKqAAAAAAAAAABsqgBD0RHeckJzN9drYj1Bz8oOjFIIt6_2zWbQP2qZ-BIx0e1vOeshDa-ipXQgNJCFmdpRnFYuMGs_n1c7Pv5AH_xo2Z95xdOcJM-1chGNVzEz5Nu7tEhy-XojipTnnD8QLWmGq1gowSnIKuho_eQX_Gf_viHAAA)
303 |
304 | Lists can also be used in [prompts](#prompt).
305 |
306 | #### Prompt
307 |
308 | Prompts are predefined quick replies in reaction to a specific situation.
309 |
310 | They *must* be placed after a `<` line, at the end of a [dialogue](#dialogue).
311 |
312 | They *must* reference a [list](#list) to access all the quick replies.
313 |
314 | The number of quick replies *should* be kept minimal.
315 |
316 | ```
317 | = LIST_NAME
318 | - LIST_ITEM
319 | - LIST_ITEM
320 |
321 | ? [LIST_NAME]
322 | ```
323 |
324 | Example:
325 |
326 | ```
327 | = pizza_types
328 | - Peperroni
329 | - Margherita
330 | - Hawaiian
331 |
332 | > I need a pizza
333 | < What kind of pizza?
334 | ? [pizza_types]
335 | ```
336 |
337 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAJ0AAAAAAAAAABsnIBD0RIZBSWtX3orynwMXTL9VBARlfLR9Ywp35T5_DuOmzGu5O5_2Sv4ozr0nRADxlSMU5u80Dkvl1DiT1B0WRYTXhmv1FVenXNysHLt-cGTuug8SWShG2GJ3cBplT__IM0AAA)
338 |
339 | #### Service
340 |
341 | Services can leverage external APIs endpoints.
342 |
343 | A service *must* be declared in its own block starting with the `@` sign.
344 | It consists of a name and an JSON-formatted API endpoint (over http or https).
345 |
346 | It *can* (and most of the time *should*) accept a parameter by using the `$`
347 | sign within its endpoint.
348 |
349 | ```
350 | @ SERVICE_NAME ENDPOINT
351 | ```
352 |
353 | It *can* be consumed within a dialogue.
354 |
355 | When the ENDPOINT has a `$` sign, it *must* accept a parameter whose value will
356 | replace the `$` sign.
357 |
358 | The result of the service call can be filtered using an optional OUTPUT.
359 | It's a selector whose format is `/(\.\w)+/`.
360 |
361 | ```
362 | @ SERVICE_NAME( PARAMETER )[ OUTPUT ]
363 | ```
364 |
365 | Example:
366 |
367 | ```
368 | @ geo_domain http://freegeoip.net/json/$
369 |
370 | > Where is *{domain}
371 | @ geo_domain($domain).city
372 | < It is running from $.
373 | ```
374 |
375 | #### Script
376 |
377 | Scripts can be used to evaluate code.
378 |
379 | The language of the code is dependent of the language used of the parser used.
380 | The `botml` parser is in Javascript, thus Javascript code can be used.
381 |
382 | It *must* be inline within [dialogues](#dialogue) wrapped in `\``.
383 |
384 | It *can* access named [variables](#variable):
385 |
386 | - either from context: `context.variables.get('price')`
387 | - or with its named variable shorter form: `$price`
388 |
389 | Example:
390 |
391 | ```
392 | > It will cost you #{price} USD
393 | < `$price / 1000`k USD is a lot!
394 | ```
395 |
396 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAJFAAAAAAAAAABskMBD4RFphvlx5b-tGgoSRMRAU-vqTQoC2KBAl2w5TTuDp3fVtcECJjuWlW4_lW0GsjUyPPq7k81Gr-urVoc9WvmMTf_96EAA)
397 |
398 | #### Variable
399 |
400 | Variables are the way to detect, format, store and reuse meaningful information.
401 |
402 | A variable *can* be captured within a `>` line ([dialogue](#dialogue)).
403 |
404 | It *must* be either textual (`$`), numeric (`#`) or alphanumeric (`*`).
405 |
406 | It *can* be used in `<` lines.
407 |
408 | ```
409 | > My name is *{name}
410 | < Nice to meet you, $name
411 |
412 | > I am #{age} years old
413 | < Seems that you are $age
414 | ```
415 |
416 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAJpAAAAAAAAAABsmcBD4RF7i0YPXTp6tNRXvN4q653SJJr8Na3Rsl2LPAeGpRcwXyq94zigyHXZcW1ZMmskimR1-m0-pJXzXdCi15nBu3DZgKJarZ4puNdEhJivFiT4IU6wAR_MYXXw7rdb_NvSAA)
417 |
418 | The variable format is `${VARIABLE_NAME}` (with its numeric and alphanumeric
419 | equivalents). But for convenient of use, the format `$VARIABLE_NAME` can be used
420 | too for textual and numeric variables.
421 |
422 | A special `$` variable always refers to the last matching value of a dialogue or
423 | the result of the previous line (the result of a service consumption for
424 | instance).
425 |
426 | #### Regular Expression
427 |
428 | Regular expressions can be used in `>` lines to have more control on what to
429 | detect.
430 |
431 | A regular expression *must* be wrapped in `/` and *cannot* be mixed with
432 | [basic expressions](#basic-expressions).
433 |
434 | ```
435 | > /^I (?:.+\s)?(\w+) (?:.+\s)?(it|this)/
436 | < Cool bro.
437 | ```
438 |
439 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAI8AAAAAAAAAABsjoBEIClUAWBDpdTPmEq4qsZTkpPh7naqvtjwtdpy1iow43E29KT2HAO6O7CKef5TZnbarOwr__76_AA)
440 |
441 | In fact, the [XRegExp](http://xregexp.com/) library is used under the hood,
442 | giving you access to leading named captures, inline comments and mode modifiers.
443 |
444 | #### Dialogue workflow
445 |
446 | Dialogue workflows are a superset of the classical dialogues.
447 |
448 | A workflow *can* be used to determine a precise flow of conversation.
449 |
450 | It *must* start with a `~` line, that defines the list name.
451 |
452 | Only one workflow *can* start with a `<` dialogue. Such a workflow will be
453 | activated and used by default when the user connects to the bot.
454 |
455 | ```
456 | # grocery shopping
457 | > I want to buy *{items}
458 | < How many $items?
459 | > #{count} $items
460 | > #{count}
461 | < There you go.
462 | ```
463 |
464 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAJ0AAAAAAAAAABsnIBCMRIhLXFii7GDoPQfSvyI6ci47dFh--F8mvrrQPX8-PuPRfQ2zdx1RHtwM151KwJyj0RlcEW4Sf61u6Tt82RCeGi_IGYzgGZdMi53DGNNi00wjG-owPgS0X1B__I3tAA)
465 |
466 | #### Conditional branches
467 |
468 | Conditional branches are instructions that direct the bot to another part of the dialogue based on test conditions.
469 |
470 | Conditional branches start with `---` and listen for all typed information then test it with all cases. Each case being separated by `---`:
471 |
472 | ```
473 | ---
474 | > first input case
475 | < first reply
476 | ---
477 | > second input case
478 | < second reply
479 | ---
480 | > last input case
481 | < last reply
482 | ```
483 |
484 | Conditional branches work great with another feature labelled *checkpoint*.
485 |
486 | A checkpoint is a marker `~ CHECKPOINT_NAME` in the workflow, that makes returning to it at a later stage of the current dialog a breeze. It can be referred to with `~ [CHECKPOINT_NAME]`, which redirects the flow to the checkpoint mark:
487 |
488 | ```
489 | ~ ask_howdy
490 | < Hello stranger.
491 | ~ checkpoint_name
492 | < How are you?
493 | > meh
494 | < So you feel bad huh
495 | ~ [checkpoint_name]
496 |
497 | # Example of workflow working:
498 |
499 | # < Hello stranger.
500 | # < How are you?
501 | # > meh
502 | # < So you feel bad huh
503 | # < How are you?
504 | # > ...
505 | ```
506 |
507 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAJ4AAAAAAAAAABsnYBH4RHg3FloWZyyGrtkMa02BcUCzTYqkO3eR8AzaW0rR-pnBSyX43TpWlMLBhkEdycvv89P5PNaBqhNI8Hw-OP-1jT92t6q2SzAUcEFvFCnYZQjf-ZKLg5jLS7ZW2mJ-BT_6o4IAA)
508 |
509 | Both *checkpoints* and *lists* make working with conditional branches something really interesting:
510 |
511 | ```
512 | = mood
513 | - good
514 | - meh
515 | - great
516 | - ok
517 |
518 | = exceptions
519 | - fantastic
520 | - better than ever
521 |
522 | ~ ask_howdy
523 | < hello stranger.
524 | ~ listen_howdy
525 | < how are you?
526 | ? [mood]
527 | ---
528 | > meh
529 | < So you feel bad huh
530 | ~ [listen_howdy]
531 | ---
532 | > good
533 | < Oh, it is not bad ;)
534 | ~ [rechecker]
535 | ---
536 | > [exceptions]
537 | < Seems you really cool guy!
538 | ---
539 | > ok
540 | < Hmm, just ok? Okay then...
541 | ---
542 | > great
543 | < Nice! Let's continue then...
544 |
545 | ~ rechecker
546 | < Maybe it is more than good?
547 | > excellent
548 | < Much better!
549 | ```
550 |
551 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAL4AQAAAAAAAABtAFqXin8NlDeNivCZHPxoZmz1gxKwZZiHYHnjbFcPtykeA4-4YQF0udX2gU3AOAfe_Ez6xDSVmEqT9OzUUqxZfDYs6_xw6A-ofRakUnTEPKSl3N2EyaqO7F87mtgW7UOZsjwUd6-sgE5fWUWLmcB0dt2JPBO89oXWh-2SHHkOej4koXYNEFTyAZuHX5R8wPWarKrl7nyhj34NPxGHLofh9IW25t4pjxzAGlo9xjFZvoZBB38MHJJWhA9ZuccCQO0c7B29aQe-pQmaJg7IwYwm5xTXaHJbIG-1_h0YtWaYGHYfuBraxIApr1snCYD1ibYKjgohNMNTIE8zrogdbOQJza1yvDUuxDx7NT6GgZdW4mQ2nT49i2Sy55GPAaQP9Gjixb6svHN2YnOHpBoN8I2OQqs1s_6dA9s)
552 |
553 | It is also possible to use the "not equal" sign `!` in *conditional branching*:
554 |
555 | ```
556 | = mood
557 | - [bad_mood]
558 | - [good_mood]
559 |
560 | = bad_mood
561 | - meh
562 | - bad
563 |
564 | = good_mood
565 | - great
566 | - ok
567 |
568 | ~ ask_howdy
569 | < hello stranger.
570 | ~ listen_howdy
571 | < how are you?
572 | ? [mood]
573 | ---
574 | > ![mood]
575 | < I asked you how you are ; please let's start over with another answer?
576 | ~ [listen_howdy]
577 | ---
578 | > [good_mood]
579 | < Oh, it is awesome ;)
580 | ---
581 | > [bad_mood]
582 | < Hmm... bye then...
583 | ```
584 |
585 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAAJ5AQAAAAAAAABtAEpMEOgO3skg8015b8VckAMXbdalZ5wJoxZ80z-cbVuxG6HgpW3_T4bXh0KkQ_41bKuMYvRj4gL6h5Pl-O2SnvAsptiG4qw66N0RzxnF1OqZmb-NTo3sjwnF4Hn5uenwxVqVQjWpOSofNSebj1VVVUiSt-iBuYA1z_N3q9QpFnvp51jKrNcn_83pD8CptKNNexdxFP7wLS_cwybYoIwECC2c6V8iK0zx5mGRgUYNJrGsoETQ5gJLeSiXcBv_LNjjXrgpe1OHuYhAMaKILU8aumkty_Zia2lAty3f-rV1wmoFkoHrk9P93kXV)
586 |
587 | *Conditional branching* also work well with scripts as long as a value is returned. If a script returns the value `true`, the corresponding branch will be activated. If all tests are `false` *conditional branching* will skip all branches and continue with the current workflow:
588 |
589 | ```
590 | ~ ask_for_email
591 | > start email workflow
592 | ~ listen_email
593 | < Your email please?
594 | > *{email}
595 | ---
596 | ` !/^.+@.+\..+/.test('$email')
597 | < This email $email seems not legit!
598 | ~ [listen_email]
599 | ---
600 | < Cool. We'll reach you over at $email
601 | ```
602 |
603 | [▶️ Try this script on bubl.es](https://bubl.es/#XQAAAALuAAAAAAAAAABsuwBH4Qmh0szgRnW9MzztSBto_voZD0pQVqDwXgBxNpbrJXMpRvhOKCM1DYc9Oz26vNE2X6Zq2SQVKg1QEbLCyFEnOVI1zZWIEADm32aDDazI2BcC2kjJibDwzgaIzjZsKUYjr9IYXp4V_3NbaV2egh2Vok2QauWbs1XotwLllwo9-fJ_oPGCK6fh7wBhSyqWpYregsYHE_vdEbOFztp9VFzcvm-A2QzeLJmuoQ6jaP_WSJgA)
604 |
605 | #### Trigger
606 |
607 | Triggers are a way for the bot to be integrated with an other program.
608 |
609 | There are two types of events: standard events and custom events.
610 |
611 | **Standard events** are as follow:
612 |
613 | - `'start'`
614 | - `'patterns:set'` with two String parameters: pattern name & value
615 | - `'match'` with two String parameters: label, pattern
616 | - `'current-dialogue-start'` with one String parameter: dialogueLabel
617 | - `'reply'` with one String parameter: message
618 | - `'smart-replies'` with one Array parameter: replies
619 | - `'current-dialogue-end'` with one String parameter: dialogueLabel
620 | - `'variable:set'` with two String parameters: variable name & value
621 | - `'quit'`
622 | - `'*'` catches *all* the standard and custom events
623 |
624 | **Custom events** *can* be triggered within [dialogues](#dialogue).
625 |
626 | A custom event *must* have a name.
627 |
628 | It *can* have parameters. Parameters *can* rely on named [variables](#variable).
629 |
630 | ```
631 | @ trigger( 'EVENT_NAME' [, PARAMETER] )
632 | ```
633 |
634 | Example:
635 |
636 | ```
637 | > hello
638 | < hi
639 | @ trigger('said_hi')
640 | ```
641 |
642 | Then handle the 'said_hi' event in your code according to your needs:
643 |
644 | ```js
645 | bot.on('said_hi', () => console.log('The bot said hi'));
646 | ```
647 |
648 | #### Stanford TokensRegex
649 |
650 | [TokensRegex](http://nlp.stanford.edu/software/tokensregex.shtml) is a framework
651 | for defining advanced patterns based of priori Natural Language Processing such
652 | as Named Entities and Parts-of-Speech tagging.
653 |
654 | ```
655 | > ([ner: PERSON]+) /was|is/ /an?/ []{0,3} /painter|artist/
656 | < An accomplished artist you say.
657 | ```
658 |
659 | This feature is enabled through code integration.
660 | See [an example](https://github.com/codename-co/botml/blob/master/examples/nlp.bot).
661 |
662 | #### Natural Language Processing
663 |
664 | NLP can be enabled through
665 | code integration.
666 | See [an example](https://github.com/codename-co/botml/blob/master/examples/nlp.js).
667 |
668 | ## Examples
669 |
670 | See the [`examples/`](https://github.com/codename-co/botml/tree/master/examples) directory.
671 |
672 | ## Develop
673 |
674 | Start the unit tests with:
675 |
676 | ```sh
677 | npm test
678 | npm run autotest # this is for continuous testing
679 | ```
680 |
681 | Use the CLI for trying botml scripts from the console:
682 |
683 | ```sh
684 | node lib/cli.js
685 | node lib/cli.js examples/echo.bot # load script from a local file
686 | debug=true node lib/cli.js # add logging verbosity
687 | ```
688 |
689 | In CLI mode, a few special commands can help with debugging too:
690 |
691 | - /stats
692 | - /inspect
693 | - /block
694 | - /activators
695 | - /current
696 |
697 | ## Contribute
698 |
699 | Feel free to dive in! [Open an issue](https://github.com/codename-co/botml/issues/new) or submit PRs.
700 |
701 | ## License
702 |
703 | Botml is MIT licensed.
704 |
--------------------------------------------------------------------------------
/docs/botml-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eniagreen/botml/6c8ef6615c55a5ddf024dcc53673bc3b924bfa9c/docs/botml-logo.png
--------------------------------------------------------------------------------
/examples/bonjour.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A french-speaking bot.
4 |
5 | = bonjour
6 | - bonjour
7 | - salut
8 |
9 | > [bonjour]
10 | < [bonjour]. Comment t'appelles-tu ?
11 | > Je m'appelle *{nom}
12 | > Mon nom est *{nom}
13 | > Je suis *{nom}
14 | > *{nom}
15 | < Je suis ravie de te rencontrer $nom !
16 |
--------------------------------------------------------------------------------
/examples/calculator.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A simple calculator.
4 | #
5 | # Example:
6 | # > 1 + 1
7 | # < 1 + 1 = 2
8 | # > 4 * 100 + 55 % 6
9 | # < 4 * 100 + 55 % 6 = 401
10 |
11 | > *
12 | < $ = `$`
13 |
--------------------------------------------------------------------------------
/examples/crypto.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | @ crypto_price https://min-api.cryptocompare.com/data/price?fsym=$coin&tsyms=EUR
4 |
5 | > #{amount} ${coin}
6 | @ crypto_price().EUR
7 | < ~ `$amount * $` €
8 |
--------------------------------------------------------------------------------
/examples/echo.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A basic echo bot that repeats what he's told.
4 |
5 | > *
6 | < $
7 |
--------------------------------------------------------------------------------
/examples/expressions.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A bot with natural language processing integration from CoreNLP.
4 | #
5 | # Example:
6 | # > I like to move it or > I really need this
7 | # < Do you really like it? > So you need this, huh?
8 |
9 |
10 | # Using a basic expression
11 | # This conversation doesn't always catch the Verb position correctly.
12 |
13 | > I * (it|this)
14 | < Do you really $?
15 | < So you $, huh?
16 |
17 |
18 | # Using a regular expression
19 | # This conversation doesn't always catch the Verb position correctly either.
20 |
21 | > /^I (?:.+\s)?(\w+) (?:.+\s)?(it|this)/
22 | < Do you really $1 $2?
23 | < So you $1 $2, huh?
24 |
25 |
26 | # Using the Stanford TokensRegex syntax
27 | # This one relies on natural language processing capabilities to query the right
28 | # words and will catch the Verb correctly all the time.
29 |
30 | > I [ !{ tag:/VB.*/ } ]* (?$verb [{ tag: /VB.*/ }]) []* (?$what /it|this/)
31 | < Do you really $1 $2?
32 | < So you $1 $2, huh?
33 |
--------------------------------------------------------------------------------
/examples/hello.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A simple yet polite bot.
4 | #
5 | # Examples:
6 | # > Hi! or > Hello Mr. Robot
7 | # < Hello. What's your name? < Hey. What's your name?
8 | # > Alice > My name is Bob
9 | # < Nice to meet you Alice! < Nice to meet you Bob!
10 |
11 | = hello
12 | - hello
13 | - hi
14 | - hey
15 | - good day
16 | - greetings
17 | - aloha
18 |
19 | > [hello]
20 | < [hello]. What's your name?
21 | > name is *{name}
22 | > I'm *{name}
23 | > *{name}
24 | < Nice to meet you $name!
25 |
--------------------------------------------------------------------------------
/examples/hello.js:
--------------------------------------------------------------------------------
1 | // Prerequisites
2 | const chalk = require('chalk')
3 |
4 | // Initialization
5 | const Botml = require('../lib/botml')
6 | let bot = new Botml('./examples/hello.bot')
7 |
8 | // Capture all events
9 | // eslint-disable-next-line no-console
10 | bot.on('*', (event, ...args) => console.log(chalk.dim('Received event'), event, chalk.dim(JSON.stringify(args))))
11 | // Start the chatbot
12 | bot.start()
13 |
14 | // Bonus: gracefull terminate the bot on Ctrl-C
15 | process.on('SIGINT', bot.stop)
16 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 | Botml examples
3 |
4 |
5 |
11 |
45 |
--------------------------------------------------------------------------------
/examples/knock.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A bot that knows its jokes.
4 | #
5 | # Example:
6 | # > Knock, knock
7 | # < Who's there?
8 | # > Bob
9 | # < Bob who?
10 |
11 | > knock
12 | < Who's there?
13 |
14 | > *
15 | < $ who?
16 |
--------------------------------------------------------------------------------
/examples/nlp.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A bot with integrated natural language processing,
4 | # using the Stanford TokensRegex syntax.
5 |
6 | # I am a person
7 | > [{ word:I }] [{ word:am }]
8 | < I bet you are.
9 | < This is so you.
10 |
11 | # I eat a yummy kimchi
12 | > [{ word:I }] [{ tag:VBP }]
13 | < I bet you do.
14 |
15 | # I can do this
16 | > [{ word:I }] [{ word:can }]
17 | < I bet you can.
18 |
19 | # I did my homework
20 | > [{ word:I }] [{ word:did }]
21 | > [{ word:I }] [{ tag:VBD }]
22 | < I bet you did.
23 | < You did that?!
24 |
25 | # I will tell you
26 | > [{ word:I }] [{ word:will }]
27 | < I bet you will.
28 | < Or will you?
29 |
30 | > ([{ tag:VB }]) [{ word:you }]
31 | < Are you trying to $1 me?
32 | < So you want to $1 me, huh?
33 |
--------------------------------------------------------------------------------
/examples/nlp.js:
--------------------------------------------------------------------------------
1 | // Prerequisites
2 | try {
3 | require('pos')
4 | require('pos-chunker')
5 | require('chalk')
6 | } catch (e) {
7 | // eslint-disable-next-line no-console
8 | console.error('First: npm i pos pos-chunker chalk\n')
9 | }
10 | const chalk = require('chalk')
11 |
12 | // Initialization
13 | const Botml = require('../lib/botml')
14 | let bot = new Botml()
15 |
16 | // NLP
17 | const pos = require('pos')
18 | const chunker = require('pos-chunker')
19 | function extractPOS (sentence) {
20 | let words = new pos.Lexer().lex(sentence)
21 | return new pos.Tagger().tag(words).map(t => `${t[0]}/${t[1]}`).join(' ')
22 | }
23 | bot.addPatternCapability(
24 | { label: 'TokensRegex',
25 | match: /\[\s*\{\s*(?:word|tag|lemma|ner|normalized):/i
26 | }, (pattern) => ({
27 | label: 'TokensRegex',
28 | test: (input) => chunker.chunk(extractPOS(input), pattern).indexOf('{') > -1,
29 | exec: (input) => {
30 | let pos = extractPOS(input)
31 | let chunks = chunker.chunk(pos, pattern)
32 | // let test = chunks.indexOf('{') > -1
33 | let match = chunks.match(/\{([^}]+)\}/gi).map(s => s.replace(/^\{([^}]+)\}$/, '$1').replace(/\/\w+/g, ''))
34 | // console.log([input, pos, chunks, test, match].join('\n\n'))
35 | return match
36 | },
37 | toString: () => pattern.toString()
38 | })
39 | )
40 |
41 | // Capture all events
42 | // eslint-disable-next-line no-console
43 | bot.on('*', (event, ...args) => console.log(chalk.dim('Received event'), event, chalk.dim(JSON.stringify(args))))
44 |
45 | // Load & start the chatbot
46 | bot.load('./examples/nlp.bot')
47 | bot.start()
48 |
49 | // Bonus: gracefull terminate the bot on Ctrl-C
50 | process.on('SIGINT', bot.stop)
51 |
--------------------------------------------------------------------------------
/examples/runkit.js:
--------------------------------------------------------------------------------
1 | const SCRIPT = 'https://raw.githubusercontent.com/codename-co/botml/master/examples/hello.bot'
2 | const Botml = require('botml/lib/botml')
3 | const bot = new Botml(SCRIPT)
4 | bot.start(true)
5 |
--------------------------------------------------------------------------------
/examples/service.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # Rely on a remote API using a named service and last reference.
4 | #
5 | # Example:
6 | # < Hello there. What service do you need?
7 | # - [domain geolocation] or [your ip]
8 | # > domain geolocation
9 | # < For which domain?
10 | # > github.com
11 | # < It is running from San Francisco.
12 | #
13 | # > Hello
14 | # < Hello there. What service do you need?
15 | # - [domain geolocation] or [your ip]
16 | # > your ip
17 | # < Your IP address is 93.184.216.34
18 |
19 |
20 | # 1. Define the main workflow that prompts the user for the service he intends
21 | # to use
22 |
23 | ~ workflow
24 | < Hello there. What service do you need?
25 | ? [services]
26 |
27 | # The workflow relies on a prompt list
28 | = services
29 | - geolocate a web domain
30 | - my ip
31 |
32 |
33 | # 2. Define the two available services.
34 |
35 | # 2a. The domain geolocation service
36 |
37 | @ geo_domain http://api.ipstack.com/$?access_key=f0585c75e5f853c1387d618ca967a4f6
38 |
39 | > geolocate a web domain
40 | < For which domain?
41 | > *{domain}
42 | @ geo_domain($domain).country_name
43 | < It is running from $.
44 |
45 | # 2b. The ip address service
46 |
47 | @ ip http://httpbin.org/ip
48 |
49 | > my ip
50 | @ ip().origin
51 | < Your IP address is $.
52 |
53 |
54 | # 3. Make the workflow executable again.
55 |
56 | > hello
57 | > hi
58 | > hey
59 | ~ [workflow]
60 |
--------------------------------------------------------------------------------
/examples/workflow.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | # A bot that has a lot to ask you.
4 | #
5 | # Example:
6 | # < Hello sir. Please answer this quick survey to help us better know you. Alright?
7 | # > Ok
8 | # < Great!
9 | # How old are you?
10 | # > 25
11 | # < Alright
12 | # And where were you born?
13 | # > I was born in Bermuda Island
14 | # < Ok
15 | # What is your email address?
16 | # > email@example.org
17 | # < Perfect. We'll keep in touch with you over 'email@example.org'.
18 |
19 | ~ survey
20 | < [hello] sir. Please answer this quick survey to help us better know you. [ok]?
21 | > [ok]
22 | < Great!
23 | ~ [age]
24 | ~ [city of birth]
25 | ~ [email address]
26 | < [ok]. We'll keep in touch with you over $email.
27 |
28 | ~ age
29 | < How old are you?
30 | > #{age}
31 | > #{age} years old
32 | < [ok]
33 |
34 | ~ city of birth
35 | < And where were you born?
36 | > * in *{city}
37 | > * near *{city}
38 | > *{city}
39 | < [ok]
40 |
41 | ~ email address
42 | < What is your email address?
43 | > * is *{email}
44 | > *{email}
45 |
46 | # dictionnaries
47 |
48 | = hello
49 | - hello
50 | - hi
51 | - hey
52 | - good day
53 |
54 | = ok
55 | - ok
56 | - alright
57 | - perfect
58 |
59 | # Make the workflow executable again.
60 |
61 | > [hello]
62 | ~ [survey]
63 |
--------------------------------------------------------------------------------
/lib/blockParser.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk')
2 | const { Fsm } = require('machina')
3 | const XRegExp = require('xregexp')
4 | const log = require('./log')
5 | const { patternify } = require('./pattern')
6 | const Utils = require('./utils')
7 |
8 | const TYPES = {
9 | '>': 'dialogue',
10 | '<': 'output',
11 | '?': 'prompt',
12 | '=': 'list',
13 | '@': 'service',
14 | '~': 'workflow',
15 | '`': 'code',
16 | '#': 'comment',
17 | '-': 'switch'
18 | }
19 |
20 | let removeBlockCurrentLine = (block) =>
21 | block.replace(/^.*$\n?/m, '')
22 |
23 | let removeBlockLinesOfType = (block, type) => type === '<'
24 | ? block.replace(RegExp(`(^\\s*${type}.*$\\n?(\\s{2}.*\\n)*)+`, 'm'), '')
25 | : block.replace(RegExp(`(^\\s*${type}.*$\\n?)+`, 'm'), '')
26 |
27 | // hacky patch that ensures the next action/line will be handled
28 | // TODO: implement a better solution
29 | let ensureNextActionWillBeHandled = (block) =>
30 | `# remove me\n${block}`
31 |
32 | let isReferencingWorkflow = (text) =>
33 | /^\s*~\s*\[/.test(text)
34 |
35 | let isReferencingCheckpoint = (text, blockHistory) =>
36 | blockHistory.filter(b => b.type === 'checkpoint' && b.event === getFromBrackets(text)).length &&
37 | /^\s*~\s*\[/.test(text)
38 |
39 | let isCheckpoint = (text) =>
40 | /^\s*~\s*\w/.test(text)
41 |
42 | let getFromBrackets = (text) =>
43 | text.match(/^\s*~\s*\[([^\]]+)\]\s*$/m) !== null
44 | ? text.match(/^\s*~\s*\[([^\]]+)\]\s*$/m)[1]
45 | : false
46 |
47 | let checkpointName = (text) =>
48 | text.match(/^~\s+(\w[\w_-]*)/)[1]
49 |
50 | let targetName = (text, name) =>
51 | text.match(XRegExp(`~ ${name}`)) !== null
52 | ? text.match(XRegExp(`~ (${name})`))[1]
53 | : false
54 |
55 | let isNotEqual = (text) =>
56 | /^\s*>.*!\[\s*/.test(text)
57 |
58 | let getLastCase = (block) =>
59 | block.match(/(---\n(\S.+\n?(\s{2})?)+)/g)
60 |
61 | let getLastCaseFromSwitchHistory = (history) =>
62 | getLastCase(history[history.length - 1].remainingBlock)[0]
63 |
64 | let getSwitchHistory = (history) =>
65 | history.filter(bh => bh.event === 'beforeSwitchInit')
66 |
67 | let mergeActionsNames = (block) =>
68 | block.match(/(>.*\n)*/m)[0].replace(/>/mg, '').replace(/\n /g, ' | ')
69 |
70 | let getArrayOfLines = (block) =>
71 | block.match(/^(\w*.*)$/mg)
72 |
73 | let filterActionsResults = (block, name) =>
74 | getArrayOfLines(block).map(i => i.trim()).splice(name.split(' | ').length - 1)
75 |
76 | function interpolateActionsResults (results) {
77 | let wrapped
78 | let [...data] = results
79 | if (typeof data[0] !== 'object') {
80 | wrapped = true
81 | data = [data]
82 | }
83 | data = data.map(r => {
84 | const result = r[1]
85 | if (/^~ \[.*\]$/.test(result)) {
86 | const workflow = this.context.workflows.get(result.replace(/~ \[(.*)\]/, '$1'))
87 | if (workflow) {
88 | r[1] = getArrayOfLines(workflow.block)[0]
89 | } else {
90 | const checkpoint = loadCheckpoint(result, this.blockHistory)
91 | if (checkpoint) {
92 | r[1] = getArrayOfLines(checkpoint)[0]
93 | } else {
94 | const jumpto = getFromBrackets(result)
95 | const block = this.block.match(RegExp(`(^~ ${jumpto}\\n)(.*)`, 'mg'))
96 | if (block !== null) {
97 | r[1] = block[0].replace(`~ ${jumpto}\n`, '')
98 | } else {
99 | r[1] = getArrayOfLines(jumpTo(this.blockHistory, this.block))[0]
100 | }
101 | }
102 | }
103 | }
104 | if (/^~ (?!\[).*/.test(result)) r[1] = r[2]
105 | return r
106 | })
107 | return wrapped ? data[0] : data
108 | }
109 |
110 | function isReferencingJumpTo (blockHistory, block) {
111 | const switchHistory = getSwitchHistory(blockHistory)
112 | if (switchHistory.length) {
113 | const referenceName = getFromBrackets(block)
114 | const lastCase = getLastCaseFromSwitchHistory(switchHistory)
115 | const jumpTarget = targetName(lastCase, referenceName)
116 | return jumpTarget === referenceName
117 | } else {
118 | return false
119 | }
120 | }
121 |
122 | function jumpTo (blockHistory, block) {
123 | const switchHistory = getSwitchHistory(blockHistory)
124 | const referenceName = getFromBrackets(block)
125 | return referenceName
126 | ? getLastCaseFromSwitchHistory(switchHistory).replace(RegExp(`(.*?\n)*?(?:~ ${referenceName})`), '')
127 | : removeBlockCurrentLine(block)
128 | }
129 |
130 | function interpolateReferencingWorkflow (context, text) {
131 | if (isReferencingWorkflow(text)) {
132 | let workflowRef = getFromBrackets(text)
133 | let workflow = context.workflows.get(workflowRef)
134 | if (workflow) {
135 | log.debug('[parser]', '_interpolateReferencingWorkflow', workflowRef)
136 | let lineToReplace = RegExp(`^\\s*~\\s*\\[${workflowRef}\\]\\s*$`, 'm')
137 | let workflowRawWithoutHeading = workflow.block.replace(/^\s*~[a-z](?:[a-z0-9-_]*[a-z0-9])?$\n/m, '')
138 |
139 | // hacky code that ensures the next action/line will be handled not as a response candidate but as a response
140 | workflowRawWithoutHeading += '\n@ trigger(\'nothing\')'
141 | return text.replace(lineToReplace, workflowRawWithoutHeading)
142 | } else {
143 | log.warn('[parser]', `The workflow '${workflow}' does not exist.`)
144 | }
145 | }
146 | return text
147 | }
148 |
149 | function loadCheckpoint (text, blockHistory) {
150 | const checkpointName = getFromBrackets(text)
151 | let loadedBlock
152 | blockHistory.map(b => {
153 | if (b.type === 'checkpoint' && b.event === checkpointName) {
154 | loadedBlock = removeBlockCurrentLine(b.remainingBlock)
155 | }
156 | })
157 | return loadedBlock
158 | }
159 |
160 | const blockType = (block) => typeof block !== 'object' && TYPES[block.trimLeft()[0]]
161 |
162 | /**
163 | * States:
164 | * - uninitialized
165 | * - [ code | comment | dialogue | output | prompt | switch | service | workflow ]*
166 | * - terminated
167 | */
168 | let Parser = Fsm.extend({
169 | initialize: function (block, context, blockHistory = [{ type: 'init', remainingBlock: block }]) {
170 | this.type = blockType(block)
171 | this.initialBlock = block
172 | this.block = block
173 | this.blockHistory = blockHistory
174 | this.context = context
175 | this.emitter = this.context.emitter
176 | this.utils = new Utils(context)
177 | this.startOfBlock = true
178 | if (this.type === 'workflow') {
179 | this.label = this.block.match(/^\s*[<>=~\-@?`]\s*(.+)$/m)[1]
180 | log.debug('[parser]', 'type workflow', { label: this.label })
181 | this.block = removeBlockCurrentLine(this.block)
182 | this.markBlockHistory({ type: 'checkpoint', event: this.label, remainingBlock: this.block })
183 | log.debug('[parser]', 'type workflow', { historySize: this.blockHistory.length })
184 | }
185 | this.switchModel = {
186 | switchStatement: false,
187 | startIteration: false,
188 | currentCase: 0,
189 | switchCases: [],
190 | type: null,
191 | captureSignType: function (block) {
192 | const capturedSignType = block.match(XRegExp('(?:---\n {2})([`>])'))[1]
193 | switch (capturedSignType) {
194 | case '`':
195 | this.type = 'code'
196 | break
197 | case '>':
198 | this.type = 'word'
199 | break
200 | }
201 | },
202 | captureCases: function (block) {
203 | let capturedCases = block.match(/(---\n( {2}.+\n)*)/g)
204 | let capturedDefaultCase = getLastCase(block)
205 |
206 | capturedCases.pop()
207 | capturedCases.push(capturedDefaultCase[0])
208 | this.switchCases = this.getSwitchCases(block, capturedCases)
209 | this.switchStatement = true
210 | },
211 | getSwitchCases: function (block, cases) {
212 | return cases.map(c => block.replace(/^\s*---\s*\n(([^\n]*\n)+)\s*---\s*\n(.*\n*)*/, removeBlockCurrentLine(c).trim()))
213 | },
214 | validate: function () {
215 | return this.currentCase <= this.switchCases.length - 1
216 | },
217 | reset: function () {
218 | this.switchStatement = false
219 | this.startIteration = false
220 | this.currentCase = 0
221 | this.switchCases = []
222 | }
223 | }
224 | this.context = Object.assign(this.context, { switchModel: this.switchModel, actionsMapping: this.actionsMapping })
225 | },
226 |
227 | toString: function () {
228 | return JSON.stringify({ type: this.type, block: this.block })
229 | },
230 |
231 | initialState: 'uninitialized',
232 |
233 | states: {
234 | uninitialized: {},
235 |
236 | comment: {
237 | _onEnter: function () {
238 | this.block = removeBlockLinesOfType(this.block, '#')
239 | this.next()
240 | }
241 | },
242 |
243 | dialogue: {
244 | _onEnter: function () {
245 | this.activators(true, isNotEqual(this.block.match(/^.*$\n?/m)[0])) // preload
246 |
247 | // Emit dialogue-actions event
248 | !this.context.switchModel.switchStatement &&
249 | this.emitter.emit('dialogue-actions', this.actionsMapping({ type: 'dialogue' }))
250 |
251 | // Emit switch-actions event
252 | if (/^.*\n---/.test(this.block)) {
253 | this.emitter.emit('switch-actions', this.actionsMapping({ type: 'switch' }))
254 | }
255 |
256 | // trigger the next instruction
257 | this.block = removeBlockLinesOfType(this.block, '>')
258 | this.markBlockHistory({ type: 'dialogue', event: 'removeBlockLinesOfType', remainingBlock: this.block })
259 | this.block = ensureNextActionWillBeHandled(this.block)
260 | if (this.startOfBlock) this.next(false)
261 | delete this.startOfBlock
262 | }
263 | },
264 |
265 | output: {
266 | _onEnter: function () {
267 | delete this.startOfBlock
268 | let responseCandidates = this.block
269 | .match(/(^\s*<.*$\n?(\s{2}(?![<>=~\-@?`]).*\n?)*)+/m)[0]
270 | .replace(/\n\s+/g, '\\n')
271 | .split(/\s*<\s*/).filter(s => s).map(s => s.trim())
272 | let message = this.utils.interpolateLists(this.utils.random(responseCandidates))
273 | message = message.replace(/`([^`]*)`/g, (m, script) => {
274 | try {
275 | return this.utils.evalCode(this, script)
276 | } catch (e) {
277 | log.warn('[parser]', 'Error while running script', e, { script })
278 | return '??'
279 | }
280 | })
281 | if (message) {
282 | this.utils.say(message)
283 | }
284 |
285 | // trigger the next instruction
286 | this.block = removeBlockLinesOfType(this.block, '<')
287 | this.markBlockHistory({ type: 'output', event: 'removeBlockLinesOfType', remainingBlock: this.block })
288 | this.next()
289 | }
290 | },
291 |
292 | prompt: {
293 | _onEnter: function () {
294 | const list = this.block.match(/^\s*\?\s*\[([^\]]+)\]/m)[1]
295 | const getReplies = this.context.lists.get(list)
296 | let processedReplies = []
297 |
298 | if (!getReplies) throw new Error(`List undefined: '${list}'`)
299 | getReplies.value.map(l => {
300 | (/\[.+\]/.test(l))
301 | ? processedReplies.push(...this.context.lists.get(l.match(/^\[([^\]]+)\]$/)[1]).value)
302 | : processedReplies.push(l)
303 | })
304 |
305 | log.info('[parser]', chalk.dim('smart replies:'), processedReplies.map(s => `[${s}]`).join(chalk.dim(', ')))
306 |
307 | // Emit prompt-actions event
308 | this.emitter.emit('prompt-actions', this.actionsMapping({ type: 'prompt', names: processedReplies }))
309 | this.emitter.emit('smart-replies', processedReplies)
310 |
311 | // trigger the next instruction
312 | this.block = removeBlockCurrentLine(this.block)
313 | this.markBlockHistory({ type: 'prompt', event: 'removeBlockCurrentLine', remainingBlock: this.block })
314 | this.block = ensureNextActionWillBeHandled(this.block)
315 | this.next()
316 | }
317 | },
318 |
319 | switch: {
320 | _onEnter: function () {
321 | this.context.switchModel.reset()
322 | this.context.switchModel.captureCases(this.block)
323 | this.context.switchModel.captureSignType(this.block)
324 |
325 | // Emit switch-actions event
326 | this.emitter.emit('switch-actions', this.actionsMapping({ type: 'switch' }))
327 |
328 | // trigger the next instruction
329 | this.markBlockHistory({ type: 'switch', event: 'beforeSwitchInit', remainingBlock: this.block })
330 | this.block = this.block.replace(/^\s*---\s*\n(([^\n]*\n)+)\s*---\s*\n(.*\n*)*/, this.context.switchModel.switchCases[this.context.switchModel.currentCase++])
331 | this.markBlockHistory({ type: 'switch', event: 'setFirstCase', remainingBlock: this.block })
332 | this.next()
333 | }
334 | },
335 |
336 | service: {
337 | _onEnter: async function () {
338 | let [label, value, output] = this.line.match(/^(\w+)\s*\(([^)]*)\)(\.[.\w[\]]+)?\s*$/i).slice(1) // eslint-disable-line no-unused-vars
339 |
340 | // Case 1. Trigger
341 | if (label === 'trigger') {
342 | this.line.replace(/^trigger\('([^']+)'(?:\s*,\s*(.*))?\)$/m, (match, eventName, value) => {
343 | try { value = JSON.parse(value) } catch (e) {}
344 |
345 | if (value) value = this.utils.interpolateVariables(value)
346 | value = value || this.context.variables.get('$')
347 |
348 | this.emitter.emit(eventName, value)
349 | })
350 | this.block = removeBlockCurrentLine(this.block)
351 | this.markBlockHistory({ type: 'service', event: 'removeBlockCurrentLine', remainingBlock: this.block })
352 | this.block = ensureNextActionWillBeHandled(this.block)
353 | this.next()
354 |
355 | // Case 2. Service
356 | } else if (this.context.services.has(label)) {
357 | try {
358 | const result = await this.utils.service(label, output)
359 | log.debug('[parser]', JSON.stringify(result))
360 | this.block = removeBlockCurrentLine(this.block)
361 | this.markBlockHistory({ type: 'service', event: 'removeBlockCurrentLine', remainingBlock: this.block })
362 | this.block = ensureNextActionWillBeHandled(this.block)
363 | this.next()
364 | } catch (error) {
365 | log.warn('[parser]', `Error while executing service '${label}'`, error)
366 | throw error
367 | }
368 | } else {
369 | throw Error(`'Unknown service: ${label}'`)
370 | }
371 | }
372 | },
373 |
374 | workflow: {
375 | _onEnter: function () {
376 | if (isReferencingCheckpoint(this.block, this.blockHistory)) {
377 | // case: ~ [checkpoint]
378 | this.markBlockHistory({ type: 'checkpoint', event: 'before:loadingReferencingCheckpoint', remainingBlock: this.block })
379 | this.block = loadCheckpoint(this.block, this.blockHistory)
380 | this.markBlockHistory({ type: 'checkpoint', event: 'after:loadingReferencingCheckpoint', remainingBlock: this.block })
381 | } else if (isReferencingJumpTo(this.blockHistory, this.block)) {
382 | // case: ~ [jumpTo]
383 | this.markBlockHistory({ type: 'jumpTo', event: 'before:jumpTo', remainingBlock: this.block })
384 | this.block = jumpTo(this.blockHistory, this.block)
385 | this.markBlockHistory({ type: 'jumpTo', event: 'after:jumpTo', remainingBlock: this.block })
386 | } else if (isReferencingWorkflow(this.block)) {
387 | // const workflowName = getFromBrackets(this.block)
388 | // case: ~ [workflow_ref]
389 | this.context.activeCheckpoint = false
390 | this.markBlockHistory({ type: 'subworkflow', event: 'before:interpolatingReferencingWorkflow', remainingBlock: this.block })
391 | this.block = interpolateReferencingWorkflow(this.context, this.block)
392 | this.markBlockHistory({ type: 'subworkflow', event: 'after:interpolatingReferencingWorkflow', remainingBlock: this.block })
393 | } else if (isCheckpoint(this.block)) {
394 | // case: ~ checkpoint
395 | this.context.activeCheckpoint = true
396 | const checkpoint = checkpointName(this.block)
397 | this.markBlockHistory({ type: 'checkpoint', event: checkpoint, remainingBlock: this.block })
398 | this.block = removeBlockCurrentLine(this.block)
399 | } else {
400 | throw Error(`Malformed workflow definition: ${this.line}`)
401 | }
402 |
403 | // trigger the next instruction
404 | delete this.startOfBlock
405 | this.next()
406 | }
407 | },
408 |
409 | code: {
410 | _onEnter: function () {
411 | let triggerNext = (type, event) => {
412 | this.markBlockHistory({ type, event, remainingBlock: this.block })
413 | this.block = ensureNextActionWillBeHandled(this.block)
414 | this.next()
415 | }
416 | const result = this.utils.evalCode(this, this.line)
417 | if (this.handleBreakingReturnCode(result)) {
418 | log.debug('[parser]', 'stopped by breaking return code', result)
419 | return
420 | }
421 |
422 | this.context.variables.set('$', result)
423 |
424 | if (this.context.switchModel.type === 'code' && this.context.switchModel.switchStatement && !result) {
425 | this.block = this.context.switchModel.switchCases[this.context.switchModel.currentCase++]
426 | if (this.context.switchModel.currentCase <= this.context.switchModel.switchCases.length - 1) {
427 | // go to next case
428 | triggerNext('switch', 'code:setNextCase')
429 | } else {
430 | // set default case if every cases was checked
431 | triggerNext('switch', 'code:setDefaultCase')
432 | }
433 | } else {
434 | this.block = removeBlockCurrentLine(this.block)
435 | // trigger the next instruction
436 | triggerNext('code', 'removeBlockCurrentLine')
437 | }
438 | }
439 | },
440 |
441 | codeBlock: {
442 | _onEnter: function () {
443 | const result = this.utils.evalCode(this, this.line, false)
444 | if (this.handleBreakingReturnCode(result)) {
445 | log.debug('[parser]', 'stopped by breaking return code', result)
446 | return
447 | }
448 |
449 | this.context.variables.set('$', result)
450 |
451 | this.block = this.block.replace(/^\s*```\s*\n(([^\n]*\n)+)\s*```\s*\n/, '')
452 | this.markBlockHistory({ type: 'code', event: 'removeCodeBlock', remainingBlock: this.block })
453 | this.block = ensureNextActionWillBeHandled(this.block)
454 | this.next()
455 | }
456 | },
457 |
458 | terminated: {
459 | _onEnter: () => log.debug('[parser]', 'End of block')
460 | }
461 | },
462 |
463 | next: function (process = true) {
464 | this.deferUntilTransition()
465 |
466 | // 1. Use the current line type as current state
467 | let type = blockType(this.block) || 'terminated'
468 |
469 | // 2. Store the first line stripped from its symbol
470 | if (type !== 'terminated') {
471 | // special case: the inner block script
472 | if (/^\s*```\s*\n/.test(this.block)) {
473 | type = 'codeBlock'
474 | this.line = this.block.match(/^\s*```\s*\n(([^\n]*\n)+)\s*```\s*\n/)[1].trim()
475 | } else {
476 | // general case
477 | this.line = this.block.match(/^\s*[#<>=~\-@?`]\s*(.+)$/m)[1].trim()
478 | }
479 | }
480 |
481 | if (!process) delete this.startOfBlock
482 |
483 | // 3. Transition to the identified state
484 | this.transition(type)
485 |
486 | delete this.startOfBlock
487 | },
488 |
489 | markBlockHistory: function ({ type, event, remainingBlock }) {
490 | log.debug('[parser]', 'mark block history', type, event)
491 | this.blockHistory.push({ type, event, remainingBlock })
492 | },
493 |
494 | handleBreakingReturnCode (code) {
495 | switch (true) {
496 | case code === '':
497 | return true
498 | case code === '':
499 | this.next()
500 | return true
501 | case /^$/.test(code):
502 | const steps = code.match(/^$/)[1]
503 | this.goto(steps)
504 | return true
505 | }
506 | return false
507 | },
508 |
509 | // checkpointNameOrSteps: either a checkpoint name or the number of steps we want to go back/forth in history
510 | goto: function (checkpointNameOrSteps) {
511 | log.info('[parser]', 'goto checkpoint', checkpointNameOrSteps)
512 | log.debug({ history: this.blockHistory })
513 |
514 | // let checkpoint
515 | if (isNaN(checkpointNameOrSteps)) {
516 | const checkpointName = checkpointNameOrSteps
517 | // context.parser.goto('ask-for-email')
518 | this.block = `~ [${checkpointName}]`
519 | this.next()
520 |
521 | /*
522 | checkpoint = this.blockHistory.reverse().find(e => e.type === 'checkpoint' && e.event === checkpointName)
523 |
524 | if (checkpoint) {
525 | log.info('[parser]', 'Found checkpoint', checkpointName)
526 | // reset the block
527 | this.block = checkpoint.remainingBlock
528 | // reset the blockHistory
529 | for (let i = 0; i <= this.blockHistory.length; i++) {
530 | if (this.blockHistory[i] === checkpoint) {
531 | this.blockHistory = this.blockHistory.slice(0, i)
532 | }
533 | }
534 | // continue with the parsing
535 | this.next()
536 | } else {
537 | log.warn('[parser]', 'Could not find checkpoint', checkpointName)
538 | }
539 | */
540 | } else {
541 | const steps = checkpointNameOrSteps
542 | if (steps >= 0) {
543 | log.error('[parser]', `Cannot goto +${steps} with the current botml implementation`)
544 | return
545 | }
546 | // reset the blockHistory
547 | log.trace('[parser]', 'blockHistory:before', this.blockHistory)
548 | log.trace('[parser]', 'block:before', this.block)
549 | this.blockHistory = this.blockHistory.reverse().slice(0, Math.max(this.blockHistory - 1, -steps)).reverse()
550 | this.block = this.blockHistory[this.blockHistory.length - 1].remainingBlock
551 | log.trace('[parser]', 'block:after', this.block)
552 | log.trace('[parser]', 'blockHistory:after', this.blockHistory)
553 | // continue with the parsing
554 | this.next()
555 | }
556 | },
557 |
558 | activators: function (forceReloading = false, notEqual = false) {
559 | if (this.context.switchModel.startIteration && forceReloading) {
560 | if (!this.context.switchModel.validate()) {
561 | this.context.switchModel.reset()
562 | return []
563 | }
564 | this.block = this.context.switchModel.switchCases[this.context.switchModel.currentCase++]
565 | this.markBlockHistory({ type: 'switch', event: 'word:setNextCase', remainingBlock: this.block })
566 | }
567 | if (!forceReloading && this._activators) return this._activators
568 | const isTheFirstLineOfBlockAnActivator = this.block.match(/^\s*>\s*/) !== null
569 | let _activators = isTheFirstLineOfBlockAnActivator && this.block.match(/(^\s*>.*$\n)+/m)
570 | if (!_activators) return this._activators || []
571 | this._activators = _activators && _activators[0].split(/^\s*>\s*/m).filter(s => s)
572 | this._activators = this._activators.map(a => patternify(a, this.context, notEqual))
573 | return this._activators || []
574 | },
575 |
576 | activable: function () {
577 | return this.activators() && this.activators().length > 0
578 | },
579 |
580 | remaining: function () {
581 | return this.block.length > 0
582 | },
583 |
584 | actionsMapping: function (actionType) {
585 | switch (actionType.type) {
586 | case 'switch':
587 | if (!this.context.switchModel.switchStatement) {
588 | this.block = removeBlockLinesOfType(this.block, '>')
589 | this.context.switchModel.reset()
590 | this.context.switchModel.captureCases(this.block)
591 | this.context.switchModel.captureSignType(this.block)
592 | }
593 | const cases = this.context.switchModel.switchCases
594 | const switchNames = cases.map((c, i) => {
595 | const list = c.match(/^> \[(.*)\]/)
596 | const dialogue = c.match(/^[>`] (.*)$/m)
597 | const defaultCase = c.match(/^[<`] (.*)$/m)
598 | if (list !== null) return this.context.lists.get(list[1]).value.join(' | ')
599 | if (dialogue !== null) return dialogue[1]
600 | if (defaultCase !== null && i === cases.length - 1) return '🛑default'
601 | })
602 | const switchResults = interpolateActionsResults.apply(this, [cases.map(c => getArrayOfLines(c).map(i => i.trim()))])
603 | return switchNames.map((a, i) => ({
604 | name: a,
605 | type: actionType.type,
606 | result: a === '🛑default'
607 | ? ['🛑default', ...switchResults[i]]
608 | : switchResults[i]
609 | }))
610 | case 'dialogue':
611 | const block = this.block.trim()
612 | // if '> [list]'
613 | if (/^\[\w+\]$/.test(block.match(/^> (.*)$/m)[1])) {
614 | const list = this.context.lists.get(block.match(/^> \[(.*)\]$/m)[1]).block
615 | const interpolatedList = list.replace(/^- \[.*\]/mg, (match) =>
616 | this.context.lists.get(match.replace(/- \[(.*)\]/, '$1')).block.replace(/= .*\n/, '')
617 | )
618 | return [{
619 | name: interpolatedList.match(/- (.*)/mg).map(n => n.replace(/- /mg, '')).join(' | '),
620 | type: actionType.type,
621 | result: interpolateActionsResults.apply(this, [getArrayOfLines(block).map(i => i.trim())])
622 | }]
623 | // if '> word'
624 | } else {
625 | const name = mergeActionsNames(block)
626 | const result = interpolateActionsResults.apply(this, [filterActionsResults(block, name)])
627 | return [{
628 | name,
629 | type: actionType.type,
630 | result
631 | }]
632 | }
633 | case 'prompt':
634 | let dialogueActions = []
635 | let workflowActions = []
636 | // check dialogues
637 | this.context.dialogues.forEach(d => {
638 | actionType.names.map(n => {
639 | if (RegExp(`(?:^|[\\s,;—])(?:\\W*)(${d.label[0] === '*' ? '\\' : ''}${d.label})(?:\\W*)(?!\\w)`, 'gi').test(n)) {
640 | const name = mergeActionsNames(d.block)
641 | const result = interpolateActionsResults.apply(this, [filterActionsResults(d.block, name)])
642 | dialogueActions.push({ name, type: actionType.type, result })
643 | }
644 | })
645 | })
646 | // check workflows
647 | this.context.workflows.forEach(w => {
648 | let name = w.block.match(/^> .*$/m)
649 | if (name !== null) {
650 | name = name[0].replace(/^> /, '')
651 | actionType.names.map(n => {
652 | if (RegExp(`(?:^|[\\s,;—])(?:\\W*)(${/^\*/.test(name) ? '\\' : ''}${name})(?:\\W*)(?!\\w)`, 'gi').test(n)) {
653 | const name = mergeActionsNames(w.block)
654 | const result = interpolateActionsResults.apply(this, [filterActionsResults(w.block, name)])
655 | workflowActions.push({ name, workflowName: w.label, type: actionType.type, result })
656 | }
657 | })
658 | }
659 | })
660 | return [...dialogueActions, ...workflowActions]
661 | // catch all possible actions
662 | case 'all':
663 | let allDialogues = []
664 | let allWorkflows = []
665 | // catch all dialogues
666 | this.context.dialogues.forEach(d => {
667 | const name = mergeActionsNames(d.block)
668 | const result = interpolateActionsResults.apply(this, [filterActionsResults(d.block, name)])
669 | allDialogues.push({ name, type: 'dialogue', result })
670 | })
671 | // catch all workflows
672 | this.context.workflows.forEach(w => {
673 | if (w.block.match(/^> /)) {
674 | const name = mergeActionsNames(w.block)
675 | const result = interpolateActionsResults.apply(this, [filterActionsResults(w.block, name)])
676 | allWorkflows.push({ name, workflowName: w.label, type: 'workflow', result })
677 | }
678 | })
679 | return [...allDialogues, ...allWorkflows]
680 | }
681 | }
682 |
683 | })
684 |
685 | module.exports = Parser
686 |
--------------------------------------------------------------------------------
/lib/botml.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const path = require('path')
3 | const chalk = require('chalk')
4 | const fetch = require('isomorphic-unfetch')
5 | const Context = require('./context')
6 | const Emitter = require('./emitter')
7 | const Parser = require('./blockParser')
8 | const log = require('./log')
9 | const Utils = require('./utils')
10 | const { execPattern } = require('./pattern')
11 |
12 | class Botml {
13 | constructor (files) {
14 | this.emitter = new Emitter()
15 | this.context = new Context(this.emitter)
16 |
17 | this.utils = new Utils(this.context)
18 | this.currentDialogue = undefined
19 |
20 | if (files) {
21 | this.load(files)
22 | }
23 | }
24 |
25 | async load (files = []) {
26 | console.time('loaded in') // eslint-disable-line no-console
27 |
28 | if (typeof files === 'string') {
29 | files = [ files ]
30 | }
31 |
32 | files.forEach(async file => {
33 | if (file.startsWith('http://') || file.startsWith('https://')) {
34 | await this._loadURL(file)
35 | } else if (IS_NODE_ENV && fs.lstatSync(file).isDirectory()) {
36 | this._loadDirectory(file)
37 | } else if (IS_NODE_ENV) {
38 | this._loadFile(file)
39 | } else {
40 | log.error(`Could not load`, file)
41 | }
42 | })
43 |
44 | log.debug(chalk.dim(`${this.utils.stats()} `))
45 | console.timeEnd('loaded in') // eslint-disable-line no-console
46 | }
47 |
48 | on (eventName, triggerFn) {
49 | log.debug(`event ${eventName}`)
50 | this.emitter.on(eventName, triggerFn)
51 | }
52 |
53 | start (withPrompt = false) {
54 | this.emitter.emit('start')
55 |
56 | // Emit start-actions event
57 | this.emitter.emit('all-actions', this.context.actionsMapping.call(this, { type: 'all' }))
58 |
59 | // Handle the case where one of the workflows must be activated and used by
60 | // default when the user connects to the bot.
61 | if (this.context.workflows.size) {
62 | let workflow = new Parser(Array.from(this.context.workflows)[0][1].block, this.context)
63 | log.debug('Loaded workflow', workflow.label)
64 | this._handleDialogue(workflow)
65 | }
66 |
67 | if (withPrompt) this.prompt()
68 | }
69 |
70 | stop () {
71 | this.emitter.emit('quit')
72 | process.exit()
73 | }
74 |
75 | addPatternCapability ({ label, match }, func) {
76 | this.context.patterns.set(label, { label, match, func })
77 | }
78 |
79 | prompt () {
80 | this.utils.prompt(input => {
81 | this.send(input)
82 | this.prompt()
83 | })
84 | }
85 |
86 | send (input) {
87 | log.debug('[botml]', chalk.bold(`> ${input}`))
88 | // reset
89 | this.context.saidSomething = false
90 |
91 | // 1. Check for special commands
92 | if (input.startsWith('/')) {
93 | switch (input) {
94 | case '/quit':
95 | case '/exit':
96 | return this.stop()
97 | case '/stats':
98 | log.info(this.utils.stats())
99 | this.emitter.emit('debug', this.utils.stats())
100 | break
101 | case '/inspect':
102 | const variables = JSON.stringify(this.context.variables.toString())
103 | log.info(chalk.bold('variables'), variables)
104 | this.emitter.emit('debug', { variables })
105 | const workflows = JSON.stringify(this.context.workflows.toString())
106 | log.info(chalk.bold('workflows'), workflows)
107 | this.emitter.emit('debug', { workflows })
108 | break
109 | case '/block':
110 | if (this.currentDialogue) {
111 | const block = this.currentDialogue.dialogue.block
112 | log.info({ block })
113 | this.emitter.emit('debug', { block })
114 | }
115 | break
116 | case '/activators':
117 | log.debug('current dialogue activators:')
118 | const localActivators = this.currentDialogue ? this.currentDialogue.dialogue.activators() : undefined
119 | if (localActivators) {
120 | log.info(localActivators.join(' , '))
121 | } else {
122 | log.info('no local activators')
123 | }
124 | log.debug('global dialogue activators:')
125 | const dialogueActivators = Object.keys(this.context.dialogues).map(k => this.context.dialogues[k].activators())
126 | dialogueActivators.forEach(activators => {
127 | log.info(activators.join(' , '))
128 | })
129 | // log.debug('global workflow activators:')
130 | // const workflowActivators = Object.keys(this.context.workflows).map(k => this.context.workflows[k].activators())
131 | // workflowActivators.forEach(activators => {
132 | // log.info(activators.join(' , '))
133 | // })
134 | this.emitter.emit('debug', { localActivators, dialogueActivators }) // , workflowActivators
135 | break
136 | case '/current':
137 | if (this.currentDialogue) {
138 | log.info(this.currentDialogue.label, '\n' + this.currentDialogue.dialogue.block)
139 | this.emitter.emit('debug', { currentDialogue: this.currentDialogue })
140 | } else {
141 | log.info('No current dialogue.')
142 | }
143 | break
144 | default:
145 | log.info('Unknown command')
146 | }
147 | return
148 | }
149 |
150 | let handle = (input, dialogue, label) => {
151 | log.trace('[botml]', 'handle', { input, label })
152 | dialogue.activators(label === 'switch') &&
153 | dialogue.activators().filter(p => RegExp(p.source, p.flags).test(input)).some(pattern => {
154 | this.context.switchModel.reset()
155 |
156 | // log.debug(`match ${label}`, pattern);
157 | this.emitter.emit('match', label, pattern.toString())
158 |
159 | // capture variables
160 | // alpha. remove existing unnamed captures
161 | this.context.variables.forEach(variable => {
162 | if (/^\$\d*$/.test(variable)) {
163 | this.context.variables.delete(variable)
164 | }
165 | })
166 |
167 | // named and unnamed captures
168 | let captures = execPattern(input, pattern)
169 | this.context.variables.set('$', captures['$1'])
170 | Object.keys(captures).forEach(varName => {
171 | this.context.variables.set(varName, captures[varName])
172 | })
173 |
174 | if (!this.context.saidSomething) {
175 | return this._handleDialogue(dialogue)
176 | }
177 | })
178 | }
179 |
180 | // 2. Check for the current ongoing dialogue
181 | if (!this.context.saidSomething) {
182 | if (this.currentDialogue) {
183 | handle(input, this.currentDialogue.dialogue, this.currentDialogue.label)
184 | }
185 | }
186 | // 3. Check for the switch statement
187 | if (!this.context.saidSomething && this.context.switchModel && this.context.switchModel.switchStatement) {
188 | this.context.switchModel.startIteration = true
189 | // Start looping throught cases
190 | while (this.context.switchModel.startIteration && this.currentDialogue) {
191 | handle(input, this.currentDialogue.dialogue, 'switch')
192 | }
193 | }
194 | // 4. Check for a matching intent
195 | if (!this.context.saidSomething) {
196 | this.context.dialogues.forEach((dialogue, label) =>
197 | handle(input, dialogue, label)
198 | )
199 | }
200 | // 5. Check for a matching workflow
201 | if (!this.context.saidSomething) {
202 | this.context.workflows.forEach((workflow, label) =>
203 | handle(input, workflow, label)
204 | )
205 | }
206 | // nothing
207 | if (!this.context.saidSomething) {
208 | this.emitter.emit('no-dialogue', 'No matching dialogue found.')
209 | log.warn('[botml]', 'No matching dialogue found.')
210 | } else {
211 | log.debug('[botml]', 'found a match')
212 | }
213 | }
214 |
215 | // Scans a directory recursively looking for .bot files
216 | _loadDirectory (dir) {
217 | fs.readdirSync(dir).forEach(file => {
218 | file = path.resolve(dir, file)
219 | if (fs.lstatSync(file).isDirectory()) {
220 | this._loadDirectory(file)
221 | } else if (file.endsWith('.bot')) {
222 | this._loadFile(file)
223 | }
224 | })
225 | }
226 |
227 | // Fetch a .bot file from an URL
228 | async _loadURL (url) {
229 | try {
230 | const script = await (await fetch(url)).text()
231 | this._parse(script)
232 | } catch (err) {
233 | log.warn(err)
234 | }
235 | }
236 |
237 | // Load a .bot file
238 | _loadFile (file) {
239 | if (!file.endsWith('.bot')) return
240 | let content = fs.readFileSync(file).toString()
241 | this._parse(content)
242 | }
243 |
244 | parse (content) {
245 | this._parse(content)
246 | }
247 |
248 | _parse (content) {
249 | let blocks = content
250 | // convert CRLF into LF
251 | .replace(/\r\n/g, '\n')
252 | // remove specification
253 | .replace(/^!\s+BOTML\s+\d\s*/i, '')
254 | // remove comments
255 | .replace(/^#.*$\n/igm, '')
256 | // split blocks by linebreaks
257 | .split(/\n{2,}/)
258 | // remove empty blocks
259 | .filter(block => block)
260 | // trim each of them
261 | .map(block => block.trim())
262 |
263 | blocks.forEach(block => {
264 | let b = new Parser(block, this.context)
265 | if (!b.label) b.label = b.block.match(/^\s*[<>=~\-@?`]\s*(.+)$/m)[1]
266 | switch (b.type) {
267 | case 'service':
268 | b.value = b.label.match(/^(\w+)\s+([^\s]+)\s*$/)[2]
269 | b.label = b.label.match(/^(\w+)\s+([^\s]+)\s*$/)[1]
270 | break
271 | case 'list':
272 | b.value = b.block
273 | .replace(/^\s*=.+$\n\s*-/m, '')
274 | .split(/^\s*-\s*/m).map(s => s.trim())
275 | break
276 | }
277 |
278 | if (this.context[`${b.type}s`].has(b.label)) {
279 | log.warn(`${b.type} "${b.label}" already set.`)
280 | }
281 | this.context[`${b.type}s`].set(b.label, b)
282 | })
283 | }
284 |
285 | _handleDialogue (dialogue) {
286 | log.trace('[botml]', '_handleDialogue')
287 | if (!this.currentDialogue) {
288 | this.emitter.emit('current-dialogue-start', dialogue.label)
289 | }
290 |
291 | let dialogParser
292 | if (this.context.activeCheckpoint) {
293 | dialogParser = new Parser(dialogue.block, this.context, dialogue.blockHistory)
294 | } else {
295 | dialogParser = new Parser(dialogue.block, this.context)
296 | }
297 |
298 | this.currentDialogue = {
299 | label: this.currentDialogue ? this.currentDialogue.label : dialogue.label,
300 | dialogue: dialogParser
301 | }
302 | this.currentDialogue.dialogue.next()
303 |
304 | if (!this.currentDialogue.dialogue.remaining()) {
305 | this.emitter.emit('current-dialogue-end', this.currentDialogue.label)
306 | this.currentDialogue = undefined
307 | this.context.activeCheckpoint = false
308 | }
309 |
310 | return this.context.saidSomething
311 | }
312 | }
313 |
314 | const IS_NODE_ENV = typeof process !== 'undefined' && process && process.versions && process.versions.node
315 |
316 | Botml.version = require('../package.json').version
317 |
318 | exports['default'] = Botml
319 | module.exports = Botml
320 |
--------------------------------------------------------------------------------
/lib/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const Botml = require('./botml')
4 | const chalk = require('chalk')
5 | const log = require('./log')
6 |
7 | let argv = require('yargs')
8 | .locale('en')
9 | .usage(`Usage: $0 [OPTIONS] [files...]
10 | $0 [ --help | -v | --version ]`)
11 | .showHelpOnFail(false, 'Specify --help for available options')
12 | .option('voice', { default: true, describe: 'OSX only: enable text-to-speech' })
13 | .boolean('voice')
14 | .option('debug', { default: false, describe: 'Show debugging information' })
15 | .boolean('debug')
16 | .help()
17 | .alias('h', 'help')
18 | .version()
19 | .alias('v', 'version')
20 | .example('$0 --voice=false alice.bot')
21 | .argv
22 |
23 | if (argv.voice) process.env.enableVoice = true
24 | if (argv.debug) process.env.debug = true
25 |
26 | log.info(chalk.bold('Botml interactive console'))
27 |
28 | let bot = new Botml()
29 | let files = argv._
30 | if (files.length === 0) {
31 | log.warn(chalk.dim('No .bot files defined. Loading the hello.bot example. Say hi!'))
32 | files = [ './examples/hello.bot' ]
33 | }
34 | bot.load(files)
35 |
36 | function exitHandler ({ exit }, err) {
37 | if (err) log.error(err.stack)
38 | if (exit) bot.stop()
39 | }
40 | process.on('exit', exitHandler.bind(null))
41 | process.on('SIGINT', exitHandler.bind(null, { exit: true }))
42 | process.on('uncaughtException', exitHandler.bind(null, { exit: true }))
43 |
44 | bot.start(true)
45 |
--------------------------------------------------------------------------------
/lib/context.js:
--------------------------------------------------------------------------------
1 | const log = require('./log')
2 |
3 | class WatchMap extends Map {
4 | static create (name, emitter) {
5 | // this complexity comes from babel limitations https://babeljs.io/docs/en/caveats/#classes
6 | const instance = new Map()
7 | // eslint-disable-next-line no-proto
8 | instance['__proto__'] = WatchMap.prototype
9 | instance.name = name
10 | instance.emitter = emitter
11 | return instance
12 | }
13 | set (key, value) {
14 | super.set(key, value)
15 |
16 | log.debug(value && value.toString ? value.toString() : { value })
17 | this.emitter.emit(`${this.name}:set`, key, value)
18 | }
19 | toString () {
20 | return Array.from(this.entries())
21 | }
22 | }
23 |
24 | class Context {
25 | constructor (emitter) {
26 | this.emitter = emitter
27 | this.dialogues = new Map()
28 | this.lists = new Map()
29 | this.services = new Map()
30 | this.variables = WatchMap.create('variable', emitter)
31 | this.workflows = WatchMap.create('workflow', emitter)
32 | this.patterns = WatchMap.create('pattern', emitter)
33 | }
34 | }
35 |
36 | module.exports = Context
37 |
--------------------------------------------------------------------------------
/lib/emitter.js:
--------------------------------------------------------------------------------
1 | const { EventEmitter } = require('events')
2 | const log = require('./log')
3 | const chalk = require('chalk')
4 |
5 | class WatchEmitter extends EventEmitter {
6 | emit (eventName, ...args) {
7 | log.debug(chalk.gray('[emit]', eventName, ...args))
8 | super.emit(eventName, ...args)
9 | super.emit('*', eventName, ...args)
10 | }
11 | }
12 |
13 | module.exports = WatchEmitter
14 |
--------------------------------------------------------------------------------
/lib/log.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk')
2 |
3 | function stringify (arg) {
4 | if (typeof arg !== 'object') return arg
5 | return arg.block ? arg.toString() : JSON.stringify(arg)
6 | }
7 |
8 | function inspectArgs (args) {
9 | return args.map(stringify).join(' ')
10 | }
11 |
12 | function debug (...args) {
13 | if (process.env.debug) console.log(chalk.dim(`DEBUG: ${inspectArgs(args)}`)) // eslint-disable-line no-console
14 | }
15 |
16 | function error (...args) {
17 | console.error(...args) // eslint-disable-line no-console
18 | }
19 |
20 | function info (...args) {
21 | console.log(chalk.dim(`INFO: ${inspectArgs(args)}`)) // eslint-disable-line no-console
22 | }
23 |
24 | function trace (...args) {
25 | if (process.env.debug) console.log(chalk.dim(chalk.grey(`TRACE: ${inspectArgs(args)}`))) // eslint-disable-line no-console
26 | }
27 |
28 | function warn (...args) {
29 | console.log(chalk.dim(`${chalk.red('WARN: ')} ${inspectArgs(args)}`)) // eslint-disable-line no-console
30 | }
31 |
32 | module.exports = {
33 | debug,
34 | error,
35 | info,
36 | trace,
37 | warn
38 | }
39 |
--------------------------------------------------------------------------------
/lib/pattern.js:
--------------------------------------------------------------------------------
1 | const XRegExp = require('xregexp')
2 |
3 | const BASIC_EXPRESSION_INTERPOLATIONS = [
4 | // escape characters '.' and '?'
5 | { search: /[.?]/g, replaceWith: '\\$&' },
6 | // '#{varName}' => '(? \d[\d\,\.\s]* )'
7 | { search: /#\{([a-z][\w_]*)\}/g, replaceWith: '(?<$1>\\d[\\d\\,\\.\\s]*)' },
8 | // '${varName}' => '(? [a-z]+ )'
9 | { search: /\$\{([a-z][\w_]*)\}/g, replaceWith: '(?<$1>[a-z]+)' },
10 | // '*{varName}' => '(? .* )'
11 | { search: /\*\{([a-z][\w_]*)\}/g, replaceWith: '(?<$1>.*)' },
12 | // '$varName' => '(? [a-z]+ )'
13 | { search: /\$([a-z][\w_]*)/g, replaceWith: '(?<$1>[a-z]+)' },
14 | // '#' => '(\d+)'
15 | { search: /(^|[\s,;—])#(?!\w)/g, replaceWith: '$1(\\d+)' },
16 | // '*' => '(.*)'
17 | { search: /(^|[\s,;—])\*(?!\w)/g, replaceWith: '$1(.*)' },
18 | // '[list_name]' => '(?:list_item_1|list_item_2)'
19 | { search: /!*\[(\w+)\]/g, replaceWith: (m, l, c) => `(${c.lists.get(l.toLowerCase()).value.join('|')})` }
20 | ]
21 |
22 | // XRegExp-ifies a string or already-defined pattern
23 | function patternify (rawPattern, context, notEqual) {
24 | let pattern
25 | context.patterns.forEach(({ label, match, func }) => {
26 | if (match.test(rawPattern)) {
27 | pattern = func(rawPattern)
28 | }
29 | })
30 |
31 | if (pattern) return pattern
32 |
33 | // is it already a pattern?
34 | if (/^\/.+\/$/m.test(rawPattern)) {
35 | pattern = rawPattern.toString().match(/^\/(.+)\/$/m)[1]
36 | return XRegExp(pattern)
37 | } else {
38 | // Nah, it's a basic expression
39 | pattern = rawPattern.trim()
40 | // .replace(/\(([^\)]+)\)/g, '(?:$1)?')
41 | BASIC_EXPRESSION_INTERPOLATIONS.forEach(({ search, replaceWith }) => {
42 | if (typeof replaceWith === 'string') {
43 | pattern = pattern.replace(search, replaceWith)
44 | } else {
45 | pattern = pattern.replace(search, (m, l) => ((replacement) => {
46 | // Check if the list contains reference to another list
47 | while (replacement.match(search) !== null) {
48 | replacement.match(search).map(rl => {
49 | const referencingListName = rl.slice(1, rl.length - 1)
50 | const referencingListPattern = replaceWith(rl, referencingListName, context)
51 | const referencingListReg = new RegExp(`\\[${referencingListName}\\]`, 'g')
52 | replacement = replacement.replace(referencingListReg, referencingListPattern.slice(1, referencingListPattern.length - 1))
53 | })
54 | }
55 | return replacement
56 | })(replaceWith(m, l, context)))
57 | }
58 | })
59 | return notEqual
60 | ? XRegExp(`^((?!^${pattern}$).)+(?!\\w)`, 'ig')
61 | : XRegExp(`(?:^|[\\s,;—])${pattern}(?!\\w)`, 'ig')
62 | }
63 | }
64 |
65 | // Execute a XRegExp pattern and formats the captures as output
66 | function execPattern (input, pattern) {
67 | let captures = !pattern.label ? XRegExp.exec(input, pattern) : pattern.exec(input)
68 | let keys = Object.keys(captures).filter(key => !['index', 'input', 'groups'].includes(key))
69 | captures = keys.map(key => ({ [key.match(/^\d+$/) ? `$${parseInt(key)}` : key]: captures[key] })).splice(1)
70 | return captures.length > 0 ? captures.reduce((a, b) => Object.assign(a, b)) : []
71 | }
72 |
73 | module.exports = {
74 | patternify,
75 | execPattern
76 | }
77 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk')
2 | const fetch = require('isomorphic-unfetch')
3 | const { exec } = require('child_process')
4 | const log = require('./log')
5 |
6 | class Utils {
7 | constructor (context) {
8 | this.context = context
9 | this.emitter = this.context.emitter
10 | }
11 |
12 | random (array) {
13 | return array[Math.floor(Math.random() * array.length)]
14 | }
15 |
16 | prompt (callback) {
17 | let stdin = process.stdin
18 | let stdout = process.stdout
19 |
20 | this.context.saidSomething = false
21 |
22 | stdin.resume()
23 | stdout.write(chalk.bold('> '))
24 |
25 | stdin.once('data', data => callback(data.toString().trim()))
26 | }
27 |
28 | interpolateVariables (text, { allowUndefined = true } = {}) {
29 | function prepareResult (value) {
30 | return !allowUndefined && value === undefined ? '' : value
31 | }
32 | return text.replace(/\$([a-z][\w_-]*)(\.[.\w[\]]*[\w\]])/g, (match, variable, output) => {
33 | try {
34 | const result = this.context.variables.get(variable.toLowerCase()) // eslint-disable-line no-unused-vars
35 | const value = eval(`result${output}`) // eslint-disable-line no-eval
36 | return prepareResult(value)
37 | } catch (err) {
38 | log.error({ err }, { output })
39 | }
40 | }).replace(/[#$]\{?([a-z][\w_-]*)\}?/g, (match, variable) => {
41 | const value = this.context.variables.get(variable.toLowerCase())
42 | return prepareResult(value)
43 | }).replace(/(\$\d*(?![\w\d]))/g, (match, variable) => {
44 | const value = this.context.variables.get(variable.toLowerCase())
45 | return prepareResult(value)
46 | })
47 | }
48 |
49 | interpolateLists (text) {
50 | return text.replace(/\[([\w-]+)\]/g, (match, listName) => {
51 | let list = this.context.lists.get(listName.toLowerCase())
52 | return list ? this.random(list.value) : listName
53 | })
54 | }
55 |
56 | stats () {
57 | let keys = Object.keys(this.context)
58 | return keys.map(key => {
59 | let size = this.context[key].size
60 | return size > 0 ? `${size} ${key}` : undefined
61 | }).filter(stat => stat !== undefined).join(', ')
62 | }
63 |
64 | say (something) {
65 | // remove "<"
66 | something = something.replace(/^\s*<\s*/, '')
67 | // interpolate variables
68 | something = this.interpolateVariables(something)
69 | // interpolate lists
70 | something = this.interpolateLists(something)
71 | // Titleize
72 | something = something.charAt(0).toUpperCase() + something.slice(1)
73 | // emit the event
74 | this.emitter.emit('reply', something)
75 | // write
76 | log.info('[botml]', chalk.bold(`< ${something}`))
77 | this.context.saidSomething = true
78 | // speak
79 | if (process.env.enableVoice) exec(`say -v Ava "${something}"`)
80 | }
81 |
82 | _trackServiceVariables (serviceName, props) {
83 | Object.keys(props).forEach(key => {
84 | const value = props[key]
85 | this.context.variables.set(`last_service_${key}`, value)
86 | this.context.variables.set(`service_${serviceName}_${key}`, value)
87 | })
88 | }
89 |
90 | async service (name, output) {
91 | let url = this.context.services.get(name).value
92 | url = this.interpolateVariables(url, { allowUndefined: false })
93 | url = encodeURI(url)
94 | log.debug('[utils]', 'service', { name, url, output })
95 | this._trackServiceVariables(name, { name, url, output })
96 | let result
97 | try {
98 | result = await (await fetch(url)).json()
99 | } catch (err) {
100 | log.error('[utils]', 'service', { name, url, output })
101 | throw new Error(`Could not parse the result of the service '${name}'`)
102 | }
103 | this._trackServiceVariables(name, { raw_result: result })
104 | log.debug('[utils]', 'service called:', name, JSON.stringify({ raw_result: result }).slice(0, 120))
105 | // eslint-disable-next-line no-eval
106 | result = output ? eval(`result${output}`) : result
107 | log.debug('[utils]', 'service called:', name, JSON.stringify({ result }).slice(0, 120))
108 | this._trackServiceVariables(name, { result })
109 | this.context.variables.set('$', result)
110 | return result
111 | }
112 |
113 | evalCode (parser, code, returns = true) {
114 | // prepare the variable arguments
115 | const keys = Array.from(this.context.variables.keys())
116 | const vars = Object.assign({}, ...keys.map(key => {
117 | const keyStr = key.startsWith('$') ? key : `$${key}`
118 | return { [keyStr]: this.context.variables.get(key) }
119 | }))
120 | const args = ['context', ...Object.keys(vars)].join(', ')
121 | const varsValues = Object.values(vars)
122 |
123 | // eslint-disable-next-line no-useless-call
124 | return ((str, context) => {
125 | const evaled = returns ? `((${args}) => (\n${str}\n))` : `((${args}) => {\n${str}\n})`
126 | try {
127 | // eslint-disable-next-line no-eval, no-useless-call
128 | return eval(evaled).call(null, context, ...varsValues)
129 | } catch (e) {
130 | log.warn('[utils]', 'evalCode', 'Error while running script', { evaled })
131 | return undefined
132 | }
133 | }).call(null, code, Object.assign({}, this.context, {
134 | say: m => this.say(m),
135 | service: this.service,
136 | emit: this.emitter.emit,
137 | goto: (...args) => parser.goto(...args),
138 | parser
139 | }))
140 | }
141 | }
142 |
143 | module.exports = Utils
144 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "botml",
3 | "version": "3.0.1",
4 | "description": "Botml is a powerful markup language for designing modern chatbots.",
5 | "keywords": [
6 | "bot",
7 | "chatbot",
8 | "aiml",
9 | "chatscript",
10 | "buddyscript",
11 | "rivescript",
12 | "siml",
13 | "bot framework",
14 | "superscript"
15 | ],
16 | "author": "Arnaud Leymet ",
17 | "license": "MIT",
18 | "homepage": "https://codename.co/botml/",
19 | "repository": {
20 | "type": "git",
21 | "url": "https://github.com/codename-co/botml.git"
22 | },
23 | "bugs": {
24 | "url": "https://github.com/codename-co/botml/issues"
25 | },
26 | "files": [
27 | "dist",
28 | "docs",
29 | "examples",
30 | "lib"
31 | ],
32 | "directories": {
33 | "lib": "lib",
34 | "example": "examples"
35 | },
36 | "main": "dist/botml.js",
37 | "module": "lib/botml.js",
38 | "scripts": {
39 | "lint": "eslint lib test",
40 | "test": "mocha test/**/*.test.js",
41 | "preautotest": "npm i -g mocha",
42 | "autotest": "supervisor -q -t -n exit -x mocha -- -b",
43 | "precommit": "npm run lint && npm test",
44 | "prepush": "npm run lint && npm test",
45 | "build": "parcel build lib/botml.js --public-url ./ --target browser --global Botml -o botml"
46 | },
47 | "preferGlobal": true,
48 | "bin": {
49 | "bot": "lib/cli.js"
50 | },
51 | "dependencies": {
52 | "chalk": "^4.0.0",
53 | "isomorphic-unfetch": "^3.0.0",
54 | "machina": "^4.0.2",
55 | "xregexp": "^4.3.0",
56 | "yargs": "^15.3.1"
57 | },
58 | "devDependencies": {
59 | "@babel/core": "^7.10.2",
60 | "@babel/plugin-transform-runtime": "^7.10.1",
61 | "@babel/preset-env": "^7.10.2",
62 | "babel-eslint": "^10.1.0",
63 | "chai": "^4.2.0",
64 | "decache": "^4.6.0",
65 | "eslint": "^7.2.0",
66 | "eslint-config-standard": "^14.1.1",
67 | "eslint-plugin-import": "^2.20.2",
68 | "eslint-plugin-node": "^11.1.0",
69 | "eslint-plugin-standard": "^4.0.1",
70 | "husky": "^4.2.5",
71 | "mocha": "^7.2.0",
72 | "parcel-bundler": "^1.12.4",
73 | "standard": "^14.3.4",
74 | "supervisor": "^0.12.0"
75 | },
76 | "engines": {
77 | "node": ">= 8"
78 | },
79 | "browser": {
80 | "fs": false
81 | },
82 | "runkitExampleFilename": "./examples/runkit.js"
83 | }
84 |
--------------------------------------------------------------------------------
/test/base.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 | const { assert } = require('chai')
3 | const Botml = require('../lib/botml')
4 | // process.env.debug = false
5 |
6 | function toArray (text) {
7 | return text.toString().split(/\n/).map(l => l.trim()).filter(l => l)
8 | }
9 |
10 | function runDialogueTests (description, testCases) {
11 | return runCustomTests(description, testCases, (test, bot) => {
12 | const dialogue = []
13 | bot.on('reply', reply => {
14 | dialogue.push(`< ${reply}`)
15 | })
16 | if (test.autostart) bot.start(false)
17 | const expectedDialogue = toArray(test.expectedDialogue)
18 | const inputSequence = expectedDialogue.filter(l => l.match(/^>/)).map(l => l.replace(/^>\s*/, ''))
19 | inputSequence.forEach(input => {
20 | dialogue.push(`> ${input}`)
21 | bot.send(input)
22 | })
23 | assert.deepEqual(dialogue.map(l => l.toLowerCase()), expectedDialogue.map(l => l.toLowerCase()))
24 | })
25 | }
26 |
27 | function runCustomTests (description, testCases, testFunction) {
28 | describe(description, function () {
29 | testCases.forEach(testCase => {
30 | describe(testCase.file, () => {
31 | testCase.tests.forEach(test => {
32 | it(test.label, () => {
33 | let bot = new Botml(`./test/mocks/${testCase.file}`)
34 | testFunction(test, bot)
35 | })
36 | })
37 | })
38 | })
39 | })
40 | }
41 |
42 | module.exports = {
43 | runDialogueTests,
44 | runCustomTests,
45 | toArray
46 | }
47 |
--------------------------------------------------------------------------------
/test/basexp.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 | const assert = require('chai').assert
3 | const { patternify, execPattern } = require('../lib/pattern.js')
4 | const Context = require('../lib/context.js')
5 |
6 | const INPUT_TEXT = 'I would like to buy 10 potatoes'
7 |
8 | let tests = [
9 | // exact same sentence
10 | { pattern: INPUT_TEXT, shouldMatch: true },
11 | // part of sentence
12 | { pattern: 'would', shouldMatch: true },
13 | // part of word
14 | { pattern: 'wou', shouldMatch: false },
15 | // one of
16 | { pattern: '(I|You) would like', shouldMatch: true, captures: { '$1': 'I' } },
17 | // special characters
18 | { pattern: 'I would like *', shouldMatch: true, captures: { '$1': 'to buy 10 potatoes' } },
19 | { pattern: 'buy # potatoes', shouldMatch: true, captures: { '$1': '10' } },
20 | // named variables
21 | { pattern: 'I would $verb to', shouldMatch: true, captures: { '$1': 'like', verb: 'like' } },
22 | { pattern: 'I would ${verb} to', shouldMatch: true, captures: { '$1': 'like', verb: 'like' } }, // eslint-disable-line no-template-curly-in-string
23 | // captures
24 | { pattern: 'I * to * # *{what}', shouldMatch: true, captures: { '$1': 'would like', '$2': 'buy', '$3': '10', '$4': 'potatoes', what: 'potatoes' } }
25 | ]
26 |
27 | describe('Basic expressions', function () {
28 | tests.forEach(test => {
29 | describe(`"${test.pattern}"`, () => {
30 | let pat = patternify(test.pattern, new Context())
31 | it(`should ${test.shouldMatch ? '' : 'not '}match`, () => {
32 | if (test.shouldMatch) {
33 | assert.match(INPUT_TEXT, pat)
34 | } else {
35 | assert.notMatch(INPUT_TEXT, pat)
36 | }
37 | })
38 | if (test.captures) {
39 | it('should have the right captures', () => {
40 | let captures = execPattern(INPUT_TEXT, pat)
41 | assert.deepEqual(captures, test.captures)
42 | })
43 | }
44 | })
45 | })
46 | })
47 |
--------------------------------------------------------------------------------
/test/chain.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('chaining', [{
4 | file: 'chain.bot',
5 | tests: [
6 | {
7 | label: 'trigger a simple dialogue (dialogue precedes workflows)',
8 | expectedDialogue: `
9 | > 1
10 | < 2
11 | `
12 | },
13 | {
14 | label: 'trigger a simple dialogue (dialogue is set after workflows)',
15 | expectedDialogue: `
16 | > 3
17 | < 4
18 | `
19 | },
20 | {
21 | label: 'chain simple dialogues',
22 | expectedDialogue: `
23 | > 1
24 | < 2
25 | > 3
26 | < 4
27 | > 1
28 | < 2
29 | `
30 | },
31 | {
32 | label: 'trigger a workflow twice',
33 | expectedDialogue: `
34 | > work
35 | < flow
36 | > work
37 | < flow again
38 | > work
39 | < flow
40 | > work
41 | < flow again
42 | `
43 | },
44 | {
45 | label: 'chain dialogues and workflows',
46 | expectedDialogue: `
47 | > 1
48 | < 2
49 | > work
50 | < flow
51 | > 3
52 | < 4
53 | > work
54 | < flow
55 | > work
56 | < flow again
57 | > 1
58 | < 2
59 | `
60 | },
61 | {
62 | label: 'chain dialogues, workflows and catch-alls',
63 | expectedDialogue: `
64 | > 1
65 | < 2
66 | > work
67 | < flow
68 | > bob
69 | < catch
70 | > work
71 | < flow
72 | > work
73 | < flow again
74 | > work
75 | < flow
76 | > 1
77 | < 2
78 | `
79 | }
80 | ]
81 | }])
82 |
--------------------------------------------------------------------------------
/test/code.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('code', [{
4 | file: 'code.bot',
5 | tests: [
6 | {
7 | label: 'run a code line and play with variables',
8 | expectedDialogue: `
9 | > start who
10 | < first name?
11 | > john
12 | < last name?
13 | > doe
14 | < Ok, I will call you "dear john doe"\\nand what is your email?
15 | > john@doe.com
16 | < your email validity: true
17 | `
18 | },
19 | {
20 | label: 'run a code that relies on context',
21 | expectedDialogue: `
22 | > start using context
23 | < let's start\\ngive me a number
24 | > 9
25 | < psst!
26 | < here!
27 | < did you just hear that?
28 | < You just gave me the number 9
29 | `
30 | },
31 | {
32 | label: 'run into triggers',
33 | expectedDialogue: `
34 | > go triggers go
35 | < action 1 done
36 | < action 2 done
37 | < action 3 and 4 done
38 | `
39 | }
40 | ]
41 | }])
42 |
--------------------------------------------------------------------------------
/test/dialogues.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('dialogues', [{
4 | file: 'dialogues.bot',
5 | tests: [
6 | {
7 | label: 'reacting at the end-of-dialogue triggers another dialogue',
8 | autostart: true,
9 | expectedDialogue: `
10 | < hi
11 | > yes
12 | < you chose yes
13 | `
14 | },
15 | {
16 | label: 'reacting at the end-of-dialogue triggers a workflow',
17 | autostart: true,
18 | expectedDialogue: `
19 | < hi
20 | > no
21 | < you chose no
22 | `
23 | },
24 | {
25 | label: 'reacting at the end-of-dialogue triggers a catch-all',
26 | autostart: true,
27 | expectedDialogue: `
28 | < hi
29 | > maybe
30 | < you chose wrong
31 | `
32 | }
33 | ]
34 | }])
35 |
--------------------------------------------------------------------------------
/test/memory.untest.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | const Botml = require('../lib/botml')
4 | const { iterate } = require('leakage')
5 |
6 | describe('botml', () => {
7 | it('does not leak when doing stuff', () => {
8 | iterate(() => {
9 | const instance = new Botml('./test/mocks/code.bot')
10 | instance.start(false)
11 | })
12 | })
13 | })
14 |
--------------------------------------------------------------------------------
/test/mocks/chain.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | > 1
4 | < 2
5 |
6 | > abc
7 | < def
8 | > ghi
9 | < jkl
10 |
11 | ~ workflow
12 | > work
13 | < flow
14 | > work
15 | < flow again
16 |
17 | > 3
18 | < 4
19 |
20 | ~ catch-all
21 | > *
22 | < catch
23 |
--------------------------------------------------------------------------------
/test/mocks/code.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | ~ default
4 | < hi
5 |
6 | # global activator
7 | > start who
8 | ~ [who]
9 |
10 | ~ who
11 | < first name?
12 | > ${first_name}
13 | < last name?
14 | > ${last_name}
15 | ` context.variables.set('full_name', `Dear ${$first_name} ${$last_name}`)
16 | < Ok, I will call you "$full_name"\nand what is your email?
17 | > *{email}
18 | ` context.variables.set('email_valid', /^[^@]+@[^.]+\..{2,}/i.test($email))
19 | < your email validity: $email_valid
20 | # ` /^[^@]+@[^.]+\..{2,}/i.test($email)
21 | # < your email validity: $
22 |
23 | ~ use-context
24 | > start using context
25 | < let's start\ngive me a number
26 | > #{number}
27 | ` context.say('psst!')
28 | ` context.say('here!')
29 | < did you just hear that?
30 | ```
31 | const msg = `You just gave me the number ${$number}`
32 | context.variables.set('custom_msg', msg)
33 | ```
34 | < $custom_msg
35 |
36 | ~ triggers
37 | > go triggers go
38 | @ trigger('action1')
39 | < action 1 done
40 | @ trigger('action2')
41 | < action 2 done
42 | @ trigger('action3')
43 | @ trigger('action4')
44 | < action 3 and 4 done
45 |
46 | ~ catch-all
47 | > *
48 | < catch $
49 |
--------------------------------------------------------------------------------
/test/mocks/dialogues.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | ~ default
4 | < hi
5 | ? [default-suggestions]
6 |
7 | = default-suggestions
8 | - yes
9 | - no
10 | - maybe
11 |
12 | > yes
13 | < you chose yes
14 |
15 | ~ no
16 | > no
17 | < you chose no
18 |
19 | ~ catch-all
20 | > *
21 | < you chose wrong
22 |
--------------------------------------------------------------------------------
/test/mocks/multi-line-dialogue.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | ~ workflow
4 | < Hi there!
5 | How are you?
6 | 😃
7 | ~ ask-again
8 | ? [options]
9 | ---
10 | > okay
11 | < Could you tell me
12 | What's stopping you to feel great?
13 | ~ [explanations]
14 | ---
15 | > good
16 | < Ohhh
17 | This is great
18 | 😉
19 | ---
20 | > bad
21 | < But Why?
22 | ~ [jumpto]
23 | ---
24 | > *
25 | ~ jumpto
26 | < I don't understand
27 | 🤔
28 | ~ [ask-again]
29 |
30 | ~ explanations
31 | < Your mood?
32 | You didn't get enough sleep?
33 | Are you ill?
34 | ? [reasons]
35 | > [reasons]
36 | < Okay then
37 | Bye bye
38 | 🙂
39 |
40 | = reasons
41 | - mood
42 | - i am ill
43 | - have to more sleeping
44 |
45 | = options
46 | - okay
47 | - bad
48 | - good
--------------------------------------------------------------------------------
/test/mocks/not-equal.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | = mood
4 | - [bad_mood]
5 | - [good_mood]
6 |
7 | = bad_mood
8 | - meh
9 | - bad
10 |
11 | = good_mood
12 | - great
13 | - ok
14 |
15 | # worflow with not-equal case
16 | ~ ask_howdy
17 | < hello stranger. how are you?
18 | ~ listen_howdy
19 | < howdy?
20 | ? [mood]
21 | ---
22 | > ![mood]
23 | < I asked you how you are ; please let's start over with another answer?
24 | ~ [listen_howdy]
25 | ---
26 | > [good_mood]
27 | < Oh, it is awesome ;)
28 | ---
29 | > [bad_mood]
30 | < Hmm... bye then...
31 |
--------------------------------------------------------------------------------
/test/mocks/reference-list.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | = vegetables
4 | - onion
5 | - tomato
6 | - carrot
7 |
8 | = fruits
9 | - apple
10 | - banana
11 |
12 | # List which contain references to another lists
13 | = products
14 | - [vegetables]
15 | - [fruits]
16 |
17 | = alcohol
18 | - wine
19 | - beer
20 |
21 | # List which contain references to another lists and original list item
22 | = drinks
23 | - [alcohol]
24 | - soda
25 | - water
26 |
27 | > start
28 | < What do you want to buy?
29 | > [products]
30 | < Nice! Choose a drink.
31 | ? [drinks]
32 | > [drinks]
33 | < Great!
34 |
--------------------------------------------------------------------------------
/test/mocks/service.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | @ geo_domain http://api.ipstack.com/$?access_key=f0585c75e5f853c1387d618ca967a4f6
4 |
5 | > geolocate a web domain
6 | < For which domain?
7 | > *{domain}
8 | @ geo_domain($domain).country_name
9 | < It is running from $
10 |
--------------------------------------------------------------------------------
/test/mocks/switch-code.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | ~ ask_for_email
4 | > start email workflow
5 | ~ listen_email
6 | < Your email please?
7 | > *{email}
8 | ---
9 | ` /forgot/.test($email)
10 | < Did you forget your email. Please return here when you recollect.
11 | ---
12 | ` !/^.+@.+\..+/.test($email)
13 | < This email $email seems not legit!
14 | ~ [listen_email]
15 | ---
16 | < Cool. We'll reach you over at $email
17 |
--------------------------------------------------------------------------------
/test/mocks/switch.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | = mood
4 | - good
5 | - meh
6 | - great
7 | - ok
8 | - brilliant
9 | - [exceptions]
10 |
11 | = exceptions
12 | - fantastic
13 | - better than ever
14 |
15 | ~ hi_workflow
16 | < Hi, what is your name?
17 | > *{name}
18 | < Nice to meet you $name
19 | ~ [ask_howdy]
20 | < Bye my friend!
21 |
22 | # worflow for switch statement with word
23 | ~ ask_howdy
24 | < how are you?
25 | ~ listen_howdy
26 | < howdy?
27 | ? [mood]
28 | ---
29 | > meh
30 | < So you feel bad huh
31 | ~ [listen_howdy]
32 | ---
33 | > good
34 | < Oh, it is not bad ;)
35 | ~ [rechecker]
36 | ---
37 | > [exceptions]
38 | < Seems you really cool guy!
39 | ---
40 | > ok
41 | < Hmm, just ok? Okay then...
42 | ---
43 | > brilliant
44 | ~ [jump_to]
45 | ---
46 | > great
47 | ~ jump_to
48 | < Nice! Let's continue then...
49 |
50 | ~ rechecker
51 | < Maybe it is more than good?
52 | > excellent
53 | < Much better!
54 |
55 | # worflow for switch statement with code
56 | ~ ask_for_email
57 | > start email workflow
58 | ~ listen_email
59 | < Your email please?
60 | > *{email}
61 | ---
62 | ` !/^.+@.+\..+/.test($email)
63 | < This email $email seems not legit!
64 | ~ [listen_email]
65 | ---
66 | < Cool. We'll reach you over at $email
67 | ~ [hi_workflow]
68 |
--------------------------------------------------------------------------------
/test/mocks/validation.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 2
2 |
3 | ~ default
4 | < auto welcome
5 |
6 | ~ workflow
7 | > start
8 | ~ [workflow-steps]
9 |
10 | ~ workflow-steps
11 | < let's start
12 | ~ [ask-for-email]
13 | < end of workflow
14 |
15 | ~ ask-for-email
16 | # ~ email-validation-checkpoint
17 | < your email please?
18 | > *{email}
19 | < you said: $
20 | ```
21 | const email = $email
22 | const REGEX = /\w[\w\._\-+]*@\w[\w\-]*\.\w{2,}/
23 | if (!REGEX.test(email)) {
24 | context.say('validation error')
25 | // context.goto('ask-for-email')
26 | // return ''
27 | context.parser.block = '~ [ask-for-email]\n< end of workflow'
28 | return ''
29 | } else {
30 | context.say('validation OK')
31 | }
32 | ```
33 | < thank you
34 |
--------------------------------------------------------------------------------
/test/mocks/workflows.bot:
--------------------------------------------------------------------------------
1 | ! BOTML 1
2 |
3 | ~ default
4 | < hi
5 |
6 | # global activator
7 | > start workflow from a global activator
8 | ~ [workflow-steps]
9 |
10 | # main workflow with its workflow activator
11 | ~ workflow
12 | > start workflow from a workflow activator
13 | ~ [workflow-steps]
14 |
15 | ~ workflow-steps
16 | < step 1
17 | ~ [flow-1]
18 | ~ [flow-2]
19 | ~ [flow-3]
20 | < step 6
21 |
22 | # workflow with a local activator
23 | ~ flow-1
24 | < step 2
25 | > *
26 | < step 3
27 |
28 | # workflow with its regex activator
29 | ~ flow-2
30 | > /this|that/
31 | < step 4
32 |
33 | # workflow with only execution code
34 | ~ flow-3
35 | < step 5
36 |
37 | ~ worfklow-a
38 | > start workflow-a
39 | < ok?
40 | > ok
41 | ~ [workflow-b]
42 |
43 | ~ workflow-b
44 | < B
45 |
46 | ~ catch-all
47 | > *
48 | < catch
49 |
--------------------------------------------------------------------------------
/test/multi-line-dialogue.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('multi-line-dialogue', [{
4 | file: 'multi-line-dialogue.bot',
5 | tests: [
6 | {
7 | label: 'multi-line-dialogue:switch-->first-case-->workflow',
8 | autostart: true,
9 | expectedDialogue: `
10 | < Hi there!\\nHow are you?\\n😃
11 | > okay
12 | < Could you tell me\\nWhat's stopping you to feel great?
13 | < Your mood?\\nYou didn't get enough sleep?\\nAre you ill?
14 | > have to more sleeping
15 | < Okay then\\nBye bye \\n🙂
16 | `
17 | },
18 | {
19 | label: 'multi-line-dialogue:switch-->second-case',
20 | autostart: true,
21 | expectedDialogue: `
22 | < Hi there!\\nHow are you?\\n😃
23 | > good
24 | < Ohhh\\nThis is great\\n😉
25 | `
26 | },
27 | {
28 | label: 'multi-line-dialogue:switch-->third-case-->jumpto-->default-case-->checkpoint-->second-case',
29 | autostart: true,
30 | expectedDialogue: `
31 | < Hi there!\\nHow are you?\\n😃
32 | > bad
33 | < But Why?
34 | < I don't understand\\n🤔
35 | > good
36 | < Ohhh\\nThis is great\\n😉
37 | `
38 | },
39 | {
40 | label: 'multi-line-dialogue:switch-->default-case-->second-case',
41 | autostart: true,
42 | expectedDialogue: `
43 | < Hi there!\\nHow are you?\\n😃
44 | > yummy
45 | < I don't understand\\n🤔
46 | > good
47 | < Ohhh\\nThis is great\\n😉
48 | `
49 | }
50 | ]
51 | }])
52 |
--------------------------------------------------------------------------------
/test/not-equal.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('not-equal', [{
4 | file: 'not-equal.bot',
5 | tests: [
6 | {
7 | label: 'case with not equal input',
8 | autostart: true,
9 | expectedDialogue: `
10 | < hello stranger. how are you?
11 | < howdy?
12 | > what?
13 | < I asked you how you are ; please let's start over with another answer?
14 | < howdy?
15 | > bad
16 | < Hmm... bye then...
17 | `
18 | }, {
19 | label: 'case with equal input',
20 | autostart: true,
21 | expectedDialogue: `
22 | < hello stranger. how are you?
23 | < howdy?
24 | > great
25 | < Oh, it is awesome ;)
26 | `
27 | }
28 | ]
29 | }])
30 |
--------------------------------------------------------------------------------
/test/reference-list.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('reference-list', [{
4 | file: 'reference-list.bot',
5 | tests: [
6 | {
7 | label: 'start vegetables/alcohol reference-list',
8 | expectedDialogue: `
9 | > start
10 | < What do you want to buy?
11 | > onion
12 | < Nice! Choose a drink.
13 | > beer
14 | < Great!
15 | `
16 | }, {
17 | label: 'start fruits/water reference-list',
18 | expectedDialogue: `
19 | > start
20 | < What do you want to buy?
21 | > apple
22 | < Nice! Choose a drink.
23 | > water
24 | < Great!
25 | `
26 | }
27 | ]
28 | }])
29 |
--------------------------------------------------------------------------------
/test/regexp.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 | const assert = require('chai').assert
3 | const { patternify, execPattern } = require('../lib/pattern.js')
4 | const Context = require('../lib/context.js')
5 |
6 | const INPUT_TEXT = 'I would like to buy 10 potatoes'
7 |
8 | let tests = [
9 | // part of sentence
10 | { pattern: /like/, shouldMatch: true },
11 | // named variables
12 | { pattern: '/^I would (?.+) to/', shouldMatch: true, captures: { '$1': 'like', verb: 'like' } },
13 | // captures
14 | { pattern: '/^I (.*) to (.*) (\\d+) (?.*)/', shouldMatch: true, captures: { '$1': 'would like', '$2': 'buy', '$3': '10', '$4': 'potatoes', what: 'potatoes' } }
15 | ]
16 |
17 | describe('Regular expressions', function () {
18 | tests.forEach(test => {
19 | describe(`"${test.pattern}"`, () => {
20 | let pat = patternify(test.pattern, new Context())
21 | it(`should ${test.shouldMatch ? '' : 'not '}match`, () => {
22 | if (test.shouldMatch) {
23 | assert.match(INPUT_TEXT, pat)
24 | } else {
25 | assert.notMatch(INPUT_TEXT, pat)
26 | }
27 | })
28 | if (test.captures) {
29 | it('should have the right captures', () => {
30 | let captures = execPattern(INPUT_TEXT, pat)
31 | assert.deepEqual(captures, test.captures)
32 | })
33 | }
34 | })
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/test/service.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('service', [{
4 | file: 'service.bot',
5 | tests: [
6 | {
7 | label: 'call a service and exploit its result',
8 | expectedDialogue: `
9 | > geolocate a web domain
10 | < For which domain?
11 | > google.com
12 | `
13 | }
14 | ]
15 | }])
16 |
--------------------------------------------------------------------------------
/test/switch-code.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('switch-code', [{
4 | file: 'switch-code.bot',
5 | tests: [
6 | {
7 | label: 'switch:code-->first-case',
8 | expectedDialogue: `
9 | > start email workflow
10 | < Your email please?
11 | > forgot
12 | < Did you forget your email. Please return here when you recollect.
13 | `
14 | }, {
15 | label: 'switch:code-->default-case',
16 | expectedDialogue: `
17 | > start email workflow
18 | < Your email please?
19 | > example@gmail.com
20 | < Cool. We'll reach you over at example@gmail.com
21 | `
22 | }, {
23 | label: 'switch:code-->second-case-->default-case',
24 | expectedDialogue: `
25 | > start email workflow
26 | < Your email please?
27 | > examplegmail.com
28 | < This email examplegmail.com seems not legit!
29 | < Your email please?
30 | > example@gmail.com
31 | < Cool. We'll reach you over at example@gmail.com
32 | `
33 | }
34 | ]
35 | }])
36 |
--------------------------------------------------------------------------------
/test/switch.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('switch', [{
4 | file: 'switch.bot',
5 | tests: [
6 | {
7 | label: 'switch:word-->first-case-->checkpoint-->fourth-case',
8 | autostart: true,
9 | expectedDialogue: `
10 | < Hi, what is your name?
11 | > john
12 | < Nice to meet you john
13 | < How are you?
14 | < howdy?
15 | > meh
16 | < So you feel bad huh
17 | < howdy?
18 | > ok
19 | < Hmm, just ok? Okay then...
20 | `
21 | }, {
22 | label: 'switch:word-->second-case-->workflow',
23 | autostart: true,
24 | expectedDialogue: `
25 | < Hi, what is your name?
26 | > john
27 | < Nice to meet you john
28 | < How are you?
29 | < howdy?
30 | > good
31 | < Oh, it is not bad ;)
32 | < Maybe it is more than good?
33 | > excellent
34 | < Much better!
35 | `
36 | }, {
37 | label: 'switch:word-->default-case',
38 | autostart: true,
39 | expectedDialogue: `
40 | < Hi, what is your name?
41 | > john
42 | < Nice to meet you john
43 | < How are you?
44 | < howdy?
45 | > great
46 | < Nice! Let's continue then...
47 | < Bye my friend!
48 | `
49 | }, {
50 | label: 'switch:code-->default-case-->workflow-->switch:word-->default-case',
51 | expectedDialogue: `
52 | > start email workflow
53 | < Your email please?
54 | > type@codename.co
55 | < Cool. We'll reach you over at type@codename.co
56 | < Hi, what is your name?
57 | > john
58 | < Nice to meet you john
59 | < How are you?
60 | < howdy?
61 | > great
62 | < Nice! Let's continue then...
63 | < Bye my friend!
64 | `
65 | }, {
66 | label: 'switch:code-->first-case-->checkpoint-->default-case-->workflow-->switch:word-->first-case-->checkpoint-->second-case-->workflow',
67 | expectedDialogue: `
68 | > start email workflow
69 | < Your email please?
70 | > typecodename.co
71 | < This email typecodename.co seems not legit!
72 | < Your email please?
73 | > type@codename.co
74 | < Cool. We'll reach you over at type@codename.co
75 | < Hi, what is your name?
76 | > john
77 | < Nice to meet you john
78 | < How are you?
79 | < howdy?
80 | > meh
81 | < So you feel bad huh
82 | < howdy?
83 | > good
84 | < Oh, it is not bad ;)
85 | < Maybe it is more than good?
86 | > excellent
87 | < Much better!
88 | `
89 | }, {
90 | label: 'switch:code-->third-case-->prompt',
91 | autostart: true,
92 | expectedDialogue: `
93 | < Hi, what is your name?
94 | > john
95 | < Nice to meet you john
96 | < How are you?
97 | < howdy?
98 | > better than ever
99 | < Seems you really cool guy!
100 | `
101 | }, {
102 | label: 'switch:code-->fifth-case-->jump-to-->default-case',
103 | autostart: true,
104 | expectedDialogue: `
105 | < Hi, what is your name?
106 | > john
107 | < Nice to meet you john
108 | < How are you?
109 | < howdy?
110 | > brilliant
111 | < Nice! Let's continue then...
112 | < Bye my friend!
113 | `
114 | }
115 | ]
116 | }])
117 |
--------------------------------------------------------------------------------
/test/validation.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('validation', [{
4 | file: 'validation.bot',
5 | tests: [
6 | {
7 | label: 'use code, parser and blockHistory to handle validation scenarios',
8 | expectedDialogue: `
9 | > start
10 | < let's start
11 | < your email please?
12 | > no
13 | < you said: no
14 | < validation error
15 | < your email please?
16 | > I said no
17 | < you said: I said no
18 | < validation error
19 | < your email please?
20 | > NEVER EVER!
21 | < you said: NEVER EVER!
22 | < validation error
23 | < your email please?
24 | > user@example.org
25 | < you said: user@example.org
26 | < validation ok
27 | < thank you
28 | < end of workflow
29 | `
30 | }
31 | ]
32 | }])
33 |
--------------------------------------------------------------------------------
/test/workflows.test.js:
--------------------------------------------------------------------------------
1 | const { runDialogueTests } = require('./base')
2 |
3 | runDialogueTests('workflows', [{
4 | file: 'workflows.bot',
5 | tests: [
6 | {
7 | label: 'auto start',
8 | autostart: true,
9 | expectedDialogue: `
10 | < hi
11 | `
12 | }, {
13 | label: 'start workflow from a global activator',
14 | expectedDialogue: `
15 | > start workflow from a global activator
16 | < step 1
17 | < step 2
18 | `
19 | }, {
20 | label: 'start workflow from a workflow activator',
21 | expectedDialogue: `
22 | > start workflow from a workflow activator
23 | < step 1
24 | < step 2
25 | `
26 | }, {
27 | label: 'do not start a workflow from an invalid activator',
28 | expectedDialogue: `
29 | > something else
30 | < catch
31 | `
32 | }, {
33 | label: 'complete a workflow',
34 | expectedDialogue: `
35 | > start workflow from a workflow activator
36 | < step 1
37 | < step 2
38 | > whatever
39 | < step 3
40 | > this
41 | < step 4
42 | < step 5
43 | < step 6
44 | `
45 | }, {
46 | label: 'another referencing workflow',
47 | expectedDialogue: `
48 | > start workflow-a
49 | < ok?
50 | > ok
51 | < b
52 | `
53 | }
54 | ]
55 | }])
56 |
--------------------------------------------------------------------------------