├── .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 | Botml 3 |

4 | 5 |

6 | An open source and production-ready markup language
7 | for designing modern chatbots. 8 |

9 | 10 |
11 | 12 |

13 | 14 | npm 15 | 16 | 17 | npm downloads 18 | 19 | 20 | David 21 | 22 | 23 | travis 24 | 25 | 26 | JavaScript Style Guide 27 | 28 | 29 | test 30 | 31 | 32 | License 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 | --------------------------------------------------------------------------------