├── test ├── plugin │ ├── chatgpt.spec.ts │ ├── utils.spec.ts │ └── plugin.spec.ts ├── e2e │ ├── todo.spec.ts │ ├── delay.spec.ts │ └── register-bot.spec.ts ├── lib │ ├── template.spec.ts │ ├── utils.spec.ts │ └── vm.spec.ts ├── engine │ ├── population.spec.ts │ ├── pattern.spec.ts │ ├── botscript.spec.ts │ └── conditions.spec.ts ├── command │ └── http-method.spec.ts ├── dialogue │ ├── sorted.spec.ts │ └── flow.spec.ts ├── conditions │ └── conditional-flow.spec.ts └── directives │ ├── plugin.spec.ts │ └── directive.spec.ts ├── src ├── plugins │ ├── wordnet.ts │ ├── index.ts │ ├── addTimeNow.ts │ ├── transformToNumber.ts │ ├── normalize.ts │ ├── noReplyHandle.ts │ ├── utils │ │ └── clean.ts │ ├── nlu.ts │ ├── chatgpt.ts │ └── built-in.ts ├── interfaces │ ├── map-value.ts │ ├── reply.ts │ ├── activator.ts │ ├── map-activator.ts │ └── types.ts ├── version.ts ├── lib │ ├── template.ts │ ├── regex.ts │ ├── logger.ts │ ├── vm2.ts │ ├── vm.ts │ ├── clean.ts │ └── utils.ts ├── engine │ ├── index.ts │ ├── next.ts │ ├── response.ts │ ├── trigger.ts │ ├── request.ts │ ├── struct.ts │ ├── pattern.ts │ ├── machine.ts │ ├── context.ts │ └── botscript.ts ├── common.ts └── vendor │ └── words-count │ ├── LICENSE │ └── words-count.ts ├── examples ├── hello.bot ├── definition.bot ├── basic.bot ├── data │ └── list.json ├── clever.bot ├── register.bot └── demo.md ├── tea.yaml ├── .travis.yml ├── .mocharc.yml ├── tsconfig.json ├── .editorconfig ├── .github └── workflows │ ├── gh-pages.yml │ └── ci.yml ├── tslint.json ├── LICENSE ├── .gitignore ├── package.json ├── gulpfile.js ├── demo.html └── README.md /test/plugin/chatgpt.spec.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/plugins/wordnet.ts: -------------------------------------------------------------------------------- 1 | // TODO: wordnet lookup 2 | -------------------------------------------------------------------------------- /examples/hello.bot: -------------------------------------------------------------------------------- 1 | + hello bot 2 | - Hello, human! 3 | -------------------------------------------------------------------------------- /src/interfaces/map-value.ts: -------------------------------------------------------------------------------- 1 | export interface IMapValue { 2 | [x: string]: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: no-var-requires 2 | const { version } = require('../package.json'); 3 | 4 | export { 5 | version, 6 | }; 7 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0x452244cFD2293a8a9270bCc725eFc6924663B1B6' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /test/e2e/todo.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | describe('TODO', () => { 4 | it('simple test', async () => { 5 | expect(1 + 1).eq(2); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/interfaces/reply.ts: -------------------------------------------------------------------------------- 1 | import { Struct } from '../engine'; 2 | import { IMapValue } from './map-value'; 3 | 4 | export interface IReply { 5 | captures: IMapValue; 6 | candidate?: number; 7 | dialog?: Struct; 8 | } 9 | -------------------------------------------------------------------------------- /examples/definition.bot: -------------------------------------------------------------------------------- 1 | ! hello 2 | - hello 3 | - hi 4 | - xin chào 5 | - hey 6 | - hola 7 | - howdy 8 | - hai 9 | - yo 10 | 11 | ! yes 12 | - yes 13 | - yeah 14 | - yep 15 | - yup 16 | ^ af 17 | 18 | ! no 19 | - no 20 | - nope 21 | -------------------------------------------------------------------------------- /src/interfaces/activator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pattern activator 3 | */ 4 | export interface IActivator { 5 | source: string; 6 | test(input: string): boolean; 7 | exec(input: string): string[]; 8 | toString(): string; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/template.ts: -------------------------------------------------------------------------------- 1 | import { compile } from 'handlebars'; 2 | 3 | function interpolate(template: string, data: any) { 4 | const temp = compile(template); 5 | return temp(data); 6 | } 7 | 8 | export { 9 | interpolate, 10 | }; 11 | -------------------------------------------------------------------------------- /src/interfaces/map-activator.ts: -------------------------------------------------------------------------------- 1 | import { IActivator } from './activator'; 2 | 3 | /** 4 | * Mapp activator 5 | */ 6 | export interface IMapActivator { 7 | id: string; 8 | trigger: string; 9 | pattern: RegExp | IActivator; 10 | } 11 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Export plugins for Node/Browser. 3 | */ 4 | export * from './addTimeNow'; 5 | export * from './noReplyHandle'; 6 | export * from './normalize'; 7 | export * from './nlu'; 8 | export * from '../version'; 9 | export * from './chatgpt'; -------------------------------------------------------------------------------- /examples/basic.bot: -------------------------------------------------------------------------------- 1 | # BOTSCRIPT 1 2 | ! dob 2019 3 | ! name Rivebot 4 | 5 | ## dialogue 6 | + hello 7 | - Hello 8 | - What is your name? 9 | - Hi. Could you tell me your name? 10 | 11 | + I'm *{name} 12 | + My name is *{name} 13 | - Nice to meet you $name! 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | os: 4 | - linux 5 | - osx 6 | 7 | node_js: 8 | - 8 9 | - 10 10 | - 12 11 | 12 | before_install: 13 | # show product version 14 | - nvm --version 15 | - node --version 16 | - npm --version 17 | 18 | install: 19 | - npm ci 20 | -------------------------------------------------------------------------------- /src/engine/index.ts: -------------------------------------------------------------------------------- 1 | export * from './request'; 2 | export * from './response'; 3 | export * from './struct'; 4 | export * from './context'; 5 | export * from './pattern'; 6 | export * from './machine'; 7 | export * from './botscript'; 8 | export * from '../version'; 9 | export * from './next'; 10 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Modules which plugins must dependents 3 | * Separating each dependency structure to let the browser reduce dependencies and memory capacity 4 | */ 5 | export { Request } from './engine/request'; 6 | export { Context } from './engine/context'; 7 | export { Struct } from './engine/struct'; 8 | export { Logger } from './lib/logger'; 9 | -------------------------------------------------------------------------------- /examples/data/list.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 3, 3 | "people": [ 4 | { 5 | "name": "Vũ", 6 | "age": 30, 7 | "id": "8888" 8 | }, 9 | { 10 | "name": "Toàn", 11 | "age": 20, 12 | "id": "9999" 13 | }, 14 | { 15 | "name": "Cường", 16 | "age": 25, 17 | "id": "6666" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/plugins/addTimeNow.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: jsdoc-format 2 | import { Request, Context } from '../common'; 3 | 4 | /** 5 | > addTimeNow 6 | 7 | + what time is it 8 | - it is $time 9 | */ 10 | export function addTimeNow(req: Request, ctx: Context) { 11 | const now = new Date(); 12 | req.variables.time = `${now.getHours()}:${now.getMinutes()}`; 13 | } 14 | -------------------------------------------------------------------------------- /examples/clever.bot: -------------------------------------------------------------------------------- 1 | # Vietnamese clever bot! 2 | 3 | > addTimeNow 4 | 5 | + [hello] 6 | * $name != null => Are you $name? 7 | - [hello], human! 8 | 9 | // comment 10 | + tên tôi là gì 11 | * $name != null => Chào $name! 12 | - Tôi không biết. Tên bạn là gì? 13 | - Tôi chưa biết tên bạn? 14 | 15 | + my name is *{name} 16 | + tên * là *{name} 17 | - hello $name 18 | 19 | + what time is it 20 | - it is $time 21 | -------------------------------------------------------------------------------- /src/engine/next.ts: -------------------------------------------------------------------------------- 1 | import { Request } from './request'; 2 | 3 | /** 4 | * Create next request 5 | * @param string 6 | * @param lastRequest 7 | */ 8 | export function createNextRequest(message: string, lastRequest?: Request) { 9 | const request = new Request(); 10 | // transfer state to new request 11 | const vResult = Object.assign(request, lastRequest, { message }); 12 | return vResult; 13 | } 14 | -------------------------------------------------------------------------------- /.mocharc.yml: -------------------------------------------------------------------------------- 1 | require: ts-node/register 2 | reporter: spec 3 | delay: true 4 | retries: 1 5 | exit: true 6 | spec: 7 | - test/e2e/delay.spec.ts 8 | - test/**/*.spec.ts 9 | # - test/engine/conditions.spec.ts 10 | # - test/engine/conditions.spec.ts 11 | # - test/dialogue/sorted.spec.ts 12 | # - test/directives/plugin.spec.ts 13 | # - test/directives/directive.spec.ts 14 | # - test/plugin/plugin.spec.ts 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "commonjs", 5 | "lib": ["dom", "es2018", "es2019.string"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "strict": true, 12 | "strictPropertyInitialization": false, 13 | "esModuleInterop": true 14 | }, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /test/lib/template.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { interpolate } from '../../src/lib/template'; 3 | 4 | describe('Lib: Template', () => { 5 | 6 | describe('Interpolate', () => { 7 | it('should interpolate template with data', async () => { 8 | const param = 'vunb'; 9 | const result = interpolate(`/api/url?query={{param}}`, {param}); 10 | expect(result).eq('/api/url?query=vunb'); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/plugins/transformToNumber.ts: -------------------------------------------------------------------------------- 1 | import { Request } from '../common'; 2 | import clean from './utils/clean'; 3 | 4 | export function transformToNumber(req: Request) { 5 | req.message = clean.all(req.message) 6 | .replace('một', '1') 7 | .replace('hai', '2') 8 | .replace('hay', '2') 9 | .replace('ba', '3') 10 | .replace('bốn', '4') 11 | .replace('năm', '5') 12 | .replace('sáu', '6') 13 | .replace('bảy', '7') 14 | .replace('tám', '8') 15 | .replace('chín', '9') 16 | .replace('mười', '10'); 17 | } -------------------------------------------------------------------------------- /src/plugins/normalize.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: jsdoc-format 2 | import { Request } from '../common'; 3 | import clean from './utils/clean'; 4 | 5 | /** 6 | * Task: Processes input and tries to make it consumable for a bot 7 | * 1. spelling corrections for common spelling errors 8 | * 2. idiom conversions 9 | * 3. junk word removal from sentence 10 | * 5. special sentence effects (question, exclamation, revert question) 11 | * 6. abbreviation expansion and canonization 12 | */ 13 | export function normalize(req: Request) { 14 | req.message = clean.all(req.message).replace(/[+-?!.,]+$/, ''); 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 250 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # Markdown style 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | 19 | # Tab indentation (no size specified) 20 | [Makefile] 21 | indent_style = tab 22 | 23 | # Matches the exact files either package.json or .travis.yml 24 | [{package.json,.travis.yml}] 25 | indent_style = space 26 | indent_size = 2 27 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy github pages on latest version 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 16.x 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 16.x 17 | - run: npm install 18 | - run: npx rimraf docs 19 | - run: npm run build:web 20 | - name: Deploy 21 | uses: peaceiris/actions-gh-pages@v3 22 | with: 23 | github_token: ${{ secrets.GITHUB_TOKEN }} 24 | publish_dir: ./docs 25 | # cname: botscript.yeu.ai 26 | -------------------------------------------------------------------------------- /examples/register.bot: -------------------------------------------------------------------------------- 1 | @ register_account post /api/account/register 2 | 3 | ! yesno 4 | - yes 5 | - no 6 | 7 | ~ reg_username 8 | - Nhập tài khoản bạn muốn đăng ký? 9 | ^ Vui lòng sử dụng chữ cái, số và dấu chấm. 10 | + /^([a-zA-Z]\.?[a-zA-Z0-9]{3,})$/ 11 | 12 | # No prompt response! 13 | ~ reg_password 14 | - Hãy nhập password? 15 | + *{password} 16 | 17 | ~ reg_confirm 18 | - Xác nhận thông tin: 19 | ^ Tài khoản ${reg_username}, ${email} 20 | + [yesno] 21 | 22 | + *đăng ký* 23 | ~ reg_username 24 | ~ reg_password 25 | ~ reg_confirm 26 | * $reg_confirm == 'yes' @> register_account 27 | * $reg_confirm == 'no' -> Bạn đã hủy đăng ký! 28 | - Bạn đã đăng ký thành công, tài khoản $reg_username: $reg_result_message! 29 | -------------------------------------------------------------------------------- /src/interfaces/types.ts: -------------------------------------------------------------------------------- 1 | import { Request, Context } from '../engine'; 2 | 3 | export type TestConditionalCallback = (data: string, ...args: any[]) => boolean | void; 4 | export type PluginCallback = (req: Request, ctx: Context) => void | Promise | PluginCallback; 5 | export type PluginWrapperCallback = () => PluginCallback; 6 | 7 | /** 8 | * Dialogue struct types 9 | */ 10 | export enum Types { 11 | Definition = '!', 12 | Trigger = '+', 13 | Reply = '-', 14 | Flow = '~', 15 | Condition = '*', 16 | Comment = '#', 17 | ConditionalFlow = '~', 18 | ConditionalReply = '-', 19 | ConditionalCommand = '@', 20 | ConditionalPrompt = '?', 21 | ConditionalForward = '>', 22 | ConditionalEvent = '+', 23 | } 24 | -------------------------------------------------------------------------------- /test/engine/population.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { BotScript, Request } from '../../src/engine'; 3 | 4 | describe('Population: Variable $Titlecase', () => { 5 | 6 | it('should distinguish uppercase or lowercase', async () => { 7 | const bot = new BotScript(); 8 | 9 | bot.parse(` 10 | @ service1 put /api/http/put 11 | 12 | + put me 13 | * true @> service1 14 | - Result $message $Titlecase 15 | `); 16 | await bot.init(); 17 | 18 | const req = new Request(); 19 | const res = await bot.handleAsync(req.enter('put me')); 20 | assert.equal(res.speechResponse, 'Result Ok NewVar', 'bot response with command executed and distinguish common case'); 21 | }); 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /src/lib/regex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Regex for utilities 3 | */ 4 | const REGEX_HEADER_SEPARATOR = / *: */; 5 | const REGEX_IP = /^(([1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(\.(?!$)|(?=$))){4}$/; 6 | 7 | /** 8 | * Regex for botscript document 9 | */ 10 | const REGEX_COND_REPLY_TESTER = /.+\s*?([->@?+~=])>\s*?.+/; 11 | 12 | /** 13 | * Support conditional replies: 14 | * Valid token: reply (-), forward (>), command (@), prompt (?), event (+), flow (~) 15 | */ 16 | const REGEX_COND_REPLY_TOKEN = /[->@?+~]/; 17 | // Extended lamda expresion syntax 18 | const REGEX_COND_LAMDA_EXPR = /[->@?+~=]>/; 19 | 20 | export { 21 | REGEX_IP, 22 | REGEX_HEADER_SEPARATOR, 23 | REGEX_COND_REPLY_TESTER, 24 | REGEX_COND_REPLY_TOKEN, 25 | REGEX_COND_LAMDA_EXPR, 26 | }; 27 | -------------------------------------------------------------------------------- /test/lib/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { evaluate } from '../../src/lib/utils'; 3 | 4 | describe('Lib: Utils', () => { 5 | 6 | describe('safeEvalCode', () => { 7 | it('should return true when given boolean value', async () => { 8 | const result = evaluate(`true`, {}); 9 | expect(result).eq(true); 10 | }); 11 | 12 | it('should return true when given simple comparision', async () => { 13 | const result = evaluate(`$name == 'vunb' && $age == 20`, {name: 'vunb', age: 20}); 14 | expect(result).eq(true); 15 | }); 16 | 17 | it('should returns expression value', async () => { 18 | expect(evaluate(`1 + 2`, {})).eq(3); 19 | expect(evaluate(`process.exit(1)`, {})).eq(undefined); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/plugins/noReplyHandle.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: jsdoc-format 2 | import { Request, Context, Struct } from '../common'; 3 | import * as utils from '../lib/utils'; 4 | 5 | /** 6 | * noReplyHandle 7 | * - if dialog is in the flow then repeat reply from last time 8 | */ 9 | export function noReplyHandle() { 10 | const postProcessing = (res: Request, ctx: Context) => { 11 | if (res.speechResponse === 'NO REPLY!') { 12 | if (ctx.flows.has(res.currentFlow)) { 13 | const dialog = ctx.flows.get(res.currentFlow) as Struct; 14 | const replyCandidate = utils.random(dialog.replies); 15 | res.speechResponse = replyCandidate; 16 | } else { 17 | res.speechResponse = `Sorry! I don't understand!`; 18 | } 19 | } 20 | }; 21 | 22 | return postProcessing; 23 | } 24 | -------------------------------------------------------------------------------- /src/engine/response.ts: -------------------------------------------------------------------------------- 1 | import { IReply } from '../interfaces/reply'; 2 | 3 | /** 4 | * Dialogue response 5 | */ 6 | export class Response { 7 | 8 | public botId: string; 9 | public currentNode: string; 10 | public complete: boolean; 11 | public text: string; 12 | public contexts: string[]; 13 | public parameters: string[]; // parameters in current child topic 14 | public extractedParameters: any; 15 | public missingParameters: []; 16 | public speechResponse: string; 17 | public intent: string; 18 | public input: string; 19 | public time: Date; // response time. 20 | // bot response histories 21 | public history: [{ 22 | flows: [{ 23 | [key: string]: string, 24 | }], 25 | }]; 26 | // candidate 27 | reply?: IReply; 28 | 29 | constructor() { 30 | this.contexts = []; 31 | this.time = new Date(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": { 7 | "no-unused-expression": true 8 | }, 9 | "rules": { 10 | "no-console": false, 11 | "no-empty": false, 12 | "quotemark": [ 13 | true, 14 | "single" 15 | ], 16 | "member-access": [ 17 | false 18 | ], 19 | "ordered-imports": [ 20 | false 21 | ], 22 | "max-line-length": [ 23 | true, 24 | 150 25 | ], 26 | "member-ordering": [ 27 | false 28 | ], 29 | "interface-name": [ 30 | false 31 | ], 32 | "arrow-parens": false, 33 | "object-literal-sort-keys": false, 34 | "one-variable-per-declaration": false, 35 | "variable-name": false, 36 | "max-classes-per-file": false, 37 | "radix": false 38 | }, 39 | "rulesDirectory": [] 40 | } 41 | -------------------------------------------------------------------------------- /test/plugin/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { BotScript, Request } from '../../src/engine'; 2 | import { assert } from 'chai'; 3 | 4 | describe('Plugin: utils', () => { 5 | 6 | describe('axios', () => { 7 | const botPlugin = new BotScript(); 8 | botPlugin 9 | .parse(` 10 | > query answer 11 | 12 | + send question 13 | - answer $answer 14 | 15 | /plugin: query answer 16 | ~~~js 17 | const vResp = await utils.axios.get('http://httpbin.org/get?answer=42'); 18 | req.variables.answer = vResp.data.args.answer; 19 | ~~~ 20 | `); 21 | 22 | it('should get answer from httpbin service', async () => { 23 | let req = new Request('send question'); 24 | 25 | req = await botPlugin.handleAsync(req); 26 | assert.equal(req.speechResponse, 'answer 42', 'send request and get value back'); 27 | }); 28 | 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | const APP_NAME = 'BotScript'; 4 | 5 | export class Logger { 6 | 7 | /** 8 | * private members 9 | */ 10 | private _debug: debug.Debugger; 11 | private _info: debug.Debugger; 12 | private _warn: debug.Debugger; 13 | private _error: debug.Debugger; 14 | 15 | constructor(prefix?: string) { 16 | prefix = prefix ? ':' + prefix : ''; 17 | this._debug = debug(`${APP_NAME}:DEBUG${prefix }`); 18 | this._info = debug(`${APP_NAME}:INFO${prefix}`); 19 | this._warn = debug(`${APP_NAME}:WARN${prefix}`); 20 | this._error = debug(`${APP_NAME}:ERROR${prefix}`); 21 | } 22 | 23 | get debug() { 24 | return this._debug; 25 | } 26 | 27 | get info() { 28 | return this._info; 29 | } 30 | 31 | get warn() { 32 | return this._warn; 33 | } 34 | 35 | get error() { 36 | return this._error; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /test/command/http-method.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { BotScript, Request } from '../../src/engine'; 3 | 4 | describe('Command: http method', () => { 5 | 6 | it('should support put, delete method', async () => { 7 | const bot = new BotScript(); 8 | 9 | bot.parse(` 10 | @ service1 put /api/http/put 11 | 12 | @ service2 delete /api/http/delete 13 | 14 | + put me 15 | * true @> service1 16 | - Result $message 17 | 18 | + delete me 19 | * true @> service2 20 | - Result $message2 21 | `); 22 | await bot.init(); 23 | 24 | const req = new Request(); 25 | let res = await bot.handleAsync(req.enter('put me')); 26 | assert.match(res.speechResponse, /result ok/i, 'bot response with command executed'); 27 | 28 | res = await bot.handleAsync(req.enter('delete me')); 29 | assert.match(res.speechResponse, /result ok/i, 'bot response with command executed'); 30 | }); 31 | 32 | }); 33 | -------------------------------------------------------------------------------- /src/lib/vm2.ts: -------------------------------------------------------------------------------- 1 | import { NodeVM, VMScript } from 'vm2'; 2 | import { PluginWrapperCallback } from '../interfaces/types'; 3 | 4 | export class VmRunner { 5 | /** 6 | * Run code in NodeVM 7 | * Example: 8 | * // const runner = new VmRunner(); 9 | * // return runner.runInVm(vm, code); 10 | * @param vm 11 | * @param code 12 | * @param path 13 | * @returns 14 | */ 15 | async runInVm(vm: NodeVM, code: string, path?: string) { 16 | const script = new VMScript(code, { filename: path }); 17 | const retValue = await vm.run(script); 18 | return retValue; 19 | } 20 | 21 | /** 22 | * run code in vm sandbox 23 | * @param code 24 | * @param sandbox 25 | * @returns 26 | */ 27 | static run(code: string, sandbox: any): PluginWrapperCallback { 28 | const vm = new NodeVM({ 29 | wrapper: 'none', 30 | sandbox, 31 | timeout: 5000, 32 | }); 33 | 34 | const retValue = vm.run(code); 35 | return retValue; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/vm.ts: -------------------------------------------------------------------------------- 1 | import { Script } from 'vm'; 2 | import { PluginWrapperCallback } from '../interfaces/types'; 3 | 4 | export class VmRunner { 5 | runInVm(code: string, sandbox: any, path?: string) { 6 | // promisify task runner 7 | return new Promise((resolve, reject) => { 8 | try { 9 | const script = new Script(code, { filename: path }); 10 | const retValue = script.runInNewContext(sandbox); 11 | 12 | // Check if code returned a Promise-like object 13 | if (retValue && typeof retValue.then === 'function') { 14 | retValue.then(resolve, reject); 15 | } else { 16 | resolve(retValue); 17 | } 18 | } catch (err) { 19 | reject(err); 20 | } 21 | }); 22 | } 23 | 24 | /** 25 | * run code in vm sandbox 26 | * @param code 27 | * @param sandbox 28 | * @returns 29 | */ 30 | static run(code: string, sandbox: any): PluginWrapperCallback { 31 | const script = new Script(code, {}); 32 | const retValue = script.runInNewContext(sandbox); 33 | return retValue; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/demo.md: -------------------------------------------------------------------------------- 1 | # Directive 2 | /include: 3 | - https://raw.githubusercontent.com/yeuai/botscript/master/examples/definition.bot 4 | - https://raw.githubusercontent.com/yeuai/botscript/master/examples/basic.bot 5 | 6 | /format: list 7 | {{#each people}} 8 | {{name}} / {{age}}, 9 | {{/each}} 10 | 11 | /plugin: test 12 | ```js 13 | req.variables.today = new Date().getDate(); 14 | req.variables.day = new Date().getDay(); 15 | req.variables.year = new Date().getFullYear(); 16 | ``` 17 | 18 | # Commands 19 | 20 | @ geoip https://api.ipify.org/?format=json 21 | 22 | @ list_patient https://raw.githubusercontent.com/yeuai/botscript/master/examples/data/list.json 23 | 24 | # Definitions 25 | 26 | ! ask_iphone 27 | - [4s](iphone) is great 28 | 29 | # Define dialogue scripts 30 | 31 | + what is my ip 32 | * true => @geoip 33 | - Here is your ip: $ip 34 | 35 | + what time is it 36 | - It is $time 37 | 38 | + show my list 39 | * true => @list_patient 40 | - Here is your list: $people /format:list 41 | 42 | + howdy 43 | - Today is $today 44 | 45 | # enabled plugins 46 | > test 47 | > addTimeNow 48 | -------------------------------------------------------------------------------- /src/lib/clean.ts: -------------------------------------------------------------------------------- 1 | const re1 = new RegExp(/\+/g); 2 | const re2 = new RegExp(/\t/g); 3 | const re3 = new RegExp(/\s+/g); 4 | const re4 = new RegExp(/(’|‘)/g); 5 | const re5 = new RegExp(/(“|”)/g); 6 | const re6 = new RegExp(/(–|—)/g); 7 | const re7 = new RegExp(/[\u00A1-\u1EF3]/g); 8 | const re8 = new RegExp(/[+]{1}/g); 9 | const re9 = new RegExp(//g); 10 | const re10 = new RegExp(/\d,\d/g); 11 | // const re11 = new RegExp(/_/g); 12 | 13 | function pre(msg = '') { 14 | msg = msg.replace(re1, ''); 15 | msg = msg.replace(re2, ' '); 16 | msg = msg.replace(re3, ' '); 17 | msg = msg.replace(re4, `'`); 18 | msg = msg.replace(re5, '"'); 19 | msg = msg.replace(re6, '—'); 20 | msg = msg.replace(re7, ' '); 21 | 22 | return msg; 23 | } 24 | 25 | function post(msg = '') { 26 | msg = msg.replace(re8, ' '); 27 | msg = msg.replace(re9, '+'); 28 | // msg = msg.replace(re11, ' '); 29 | msg = msg.replace(re10, v => v.replace(',', '')); 30 | return msg; 31 | } 32 | 33 | function all(msg = '') { 34 | return post(pre(msg)).trim(); 35 | } 36 | 37 | export default { pre, post, all }; 38 | -------------------------------------------------------------------------------- /src/plugins/utils/clean.ts: -------------------------------------------------------------------------------- 1 | const re1 = new RegExp(/\+/g); 2 | const re2 = new RegExp(/\t/g); 3 | const re3 = new RegExp(/\s+/g); 4 | const re4 = new RegExp(/(’|‘)/g); 5 | const re5 = new RegExp(/(“|”)/g); 6 | const re6 = new RegExp(/(–|—)/g); 7 | const re7 = new RegExp(/[\u00A1-\u1EF3]/g); 8 | const re8 = new RegExp(/[+]{1}/g); 9 | const re9 = new RegExp(//g); 10 | const re10 = new RegExp(/\d,\d/g); 11 | // const re11 = new RegExp(/_/g); 12 | 13 | function pre(msg = '') { 14 | msg = msg.replace(re1, ''); 15 | msg = msg.replace(re2, ' '); 16 | msg = msg.replace(re3, ' '); 17 | msg = msg.replace(re4, `'`); 18 | msg = msg.replace(re5, '"'); 19 | msg = msg.replace(re6, '—'); 20 | msg = msg.replace(re7, ' '); 21 | 22 | return msg; 23 | } 24 | 25 | function post(msg = '') { 26 | msg = msg.replace(re8, ' '); 27 | msg = msg.replace(re9, '+'); 28 | // msg = msg.replace(re11, ' '); 29 | msg = msg.replace(re10, v => v.replace(',', '')); 30 | return msg; 31 | } 32 | 33 | function all(msg = '') { 34 | return post(pre(msg)).trim(); 35 | } 36 | 37 | export default { pre, post, all }; 38 | -------------------------------------------------------------------------------- /test/dialogue/sorted.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { BotScript, Request } from '../../src/engine'; 3 | 4 | describe('Dialogue: sorted activator before reply', () => { 5 | // TODO: implement 6 | 7 | const bot = new BotScript(); 8 | let request = new Request(); 9 | 10 | bot.parse(` 11 | + today ok 12 | - hello 1 13 | 14 | + today okk 15 | - hello 2 16 | 17 | + today * 18 | - reply me! 19 | `); 20 | 21 | it('bot should say hello 2', async () => { 22 | const req = request.enter('today okk'); 23 | const reply = await bot.handleAsync(req); 24 | assert.equal(reply.speechResponse, 'hello 2', 'bot reply'); 25 | }); 26 | 27 | it('bot should say hello 1', async () => { 28 | const req = request.enter('today ok'); 29 | const reply = await bot.handleAsync(req); 30 | assert.equal(reply.speechResponse, 'hello 1', 'bot reply'); 31 | }); 32 | 33 | it('bot should say reply me!', async () => { 34 | const req = request.enter('today okkk'); 35 | const reply = await bot.handleAsync(req); 36 | assert.equal(reply.speechResponse, 'reply me!', 'bot reply'); 37 | }); 38 | 39 | }) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 YeuAI Artificial Intelligence 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 | -------------------------------------------------------------------------------- /src/vendor/words-count/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 baozier 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 | -------------------------------------------------------------------------------- /src/plugins/nlu.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable: jsdoc-format 2 | import { Request, Context, Struct } from '../common'; 3 | import { callHttpService } from '../lib/utils'; 4 | import { Logger } from '../lib/logger'; 5 | 6 | const logger = new Logger('NLU'); 7 | const defaultCommandNlu = Struct.parse(`@ nlu https://botscript-core.yeu.ai/api/nlu`); 8 | const defaultDirectiveNlu = Struct.parse(`/nlu: nlu`); 9 | 10 | /** 11 | > nlu 12 | 13 | + intent: greeting 14 | - Hallo! 15 | */ 16 | export async function nlu(req: Request, ctx: Context) { 17 | // Get nlu command from directive 18 | const vDirectiveNlu = ctx.directives.get('nlu') as Struct || defaultDirectiveNlu; 19 | const vCommandNlu = (ctx.commands.get(vDirectiveNlu.value) as Struct) || defaultCommandNlu; 20 | logger.info(`Send nlu request: (${vDirectiveNlu.value})`, req.message); 21 | 22 | try { 23 | 24 | // Extract intent/entities from vNluDirective API Server. 25 | const { intent = '__UNKNOW__', entities = [] } = await callHttpService(vCommandNlu, req); 26 | 27 | // attach result to request message. 28 | req.intent = intent; 29 | req.entities = entities; 30 | 31 | } catch (error) { 32 | logger.error('Cannot extract nlu message!', error); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Output 64 | dist 65 | docs 66 | #docs 67 | test.ts 68 | *.test.ts 69 | *.js 70 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Publish Package on Release 2 | 3 | on: [push, release] 4 | #on: 5 | # pull_request: 6 | # branches: 7 | # - master 8 | #on: 9 | # push: 10 | # tags: 11 | # - '*' 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Use Node.js 14.x 19 | uses: actions/setup-node@v1 20 | with: 21 | registry-url: https://npm.pkg.github.com/ 22 | node-version: 14.x 23 | scope: "@yeuai" 24 | - name: npm install, build, and test 25 | run: | 26 | npm install 27 | npm run build --if-present 28 | npm test 29 | - name: publish 30 | if: github.event_name == 'release' && github.event.action == 'published' 31 | env: 32 | NPM_TOKEN: ${{ secrets.NPM_PKG_AUTH_TOKEN }} 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_PKG_AUTH_TOKEN }} 34 | run: | 35 | echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc 36 | echo "//npm.pkg.github.com/:_authToken=$GITHUB_TOKEN" >> .npmrc 37 | npm publish --ignore-scripts --@yeuai:registry='https://registry.npmjs.org' 38 | npm publish --ignore-scripts --@yeuai:registry='https://npm.pkg.github.com' 39 | -------------------------------------------------------------------------------- /test/e2e/delay.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import MockAdapter from 'axios-mock-adapter'; 3 | 4 | // Setup axios interceptors 5 | const mock = new MockAdapter(axios); 6 | // Mock specific requests, but let unmatched ones through 7 | mock 8 | .onPost('/api/account/register').reply(200, { 9 | reg_result_message: 'ok', 10 | }) 11 | .onGet('/api/nlu').reply(200, { 12 | intent: 'who', 13 | entities: [{ id: 1, name: 'John Smith' }], 14 | }) 15 | .onGet('/api/nlu/react').reply(200, { 16 | intent: 'react_positive', 17 | entities: [{ id: 1, name: 'John Smith' }], 18 | }) 19 | .onPut('/api/http/put').reply(200, { 20 | error: 0, 21 | message: 'Ok', 22 | Titlecase: 'NewVar', 23 | }) 24 | .onDelete('/api/http/delete').reply(200, { 25 | error: 0, 26 | message2: 'Ok', 27 | }) 28 | .onGet('/api/data/list').reply(200, { 29 | people: [{ 30 | name: 'Vũ', 31 | age: 30, 32 | }, { 33 | name: 'Toàn', 34 | age: 20, 35 | }, { 36 | name: 'Cường', 37 | age: 25, 38 | }, 39 | ], 40 | }) 41 | .onAny().passThrough(); 42 | 43 | /** 44 | * sleep utils 45 | * @param m 46 | */ 47 | const sleep = 48 | (m: number) => new Promise(r => setTimeout(r, m)); 49 | 50 | /** 51 | * Async testing setup 52 | */ 53 | async function bootstrap() { 54 | await sleep(1); 55 | run(); 56 | } 57 | 58 | bootstrap(); 59 | -------------------------------------------------------------------------------- /test/lib/vm.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Context, Request } from '../../src/common'; 3 | import { VmRunner as VmRunnerBrowser } from '../../src/lib/vm'; 4 | import { VmRunner as VmRunnerNode } from '../../src/lib/vm2'; 5 | import { wrapCode as wrapCodeNode, wrapCodeBrowser } from '../../src/plugins/built-in'; 6 | import * as utils from '../../src/lib/utils'; 7 | import { Logger } from '../../src/lib/logger'; 8 | 9 | describe('VmRunner', () => { 10 | 11 | describe('VM for Browser', () => { 12 | it('should run the code', async () => { 13 | const vCode = wrapCodeBrowser(`logger.info('Ok'); return () => {req.message = 123}`); 14 | 15 | const req = new Request(); 16 | const ctx = new Context(); 17 | const logger = new Logger('TESTER'); 18 | const vPreProcess = await VmRunnerBrowser.run(vCode, { req, ctx, utils, logger }); 19 | const vPostProcessingCallback = await vPreProcess(); 20 | expect(vPreProcess).not.eq(undefined); 21 | expect(req.message).not.eq(123); 22 | 23 | vPostProcessingCallback(req, ctx); 24 | expect(req.message).eq(123); 25 | }); 26 | }); 27 | 28 | describe('VM for Node', () => { 29 | it('should run the code', async () => { 30 | const vCode = wrapCodeNode(`logger.info('Ok'); return () => {req.message = 123}`); 31 | 32 | const req = new Request(); 33 | const ctx = new Context(); 34 | const logger = new Logger('TESTER'); 35 | const vPreProcess = await VmRunnerNode.run(vCode, { req, ctx, utils, logger }); 36 | const vPostProcessingCallback = await vPreProcess(); 37 | expect(vPreProcess).not.eq(undefined); 38 | expect(req.message).not.eq(123); 39 | 40 | vPostProcessingCallback(req, ctx); 41 | expect(req.message).eq(123); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/conditions/conditional-flow.spec.ts: -------------------------------------------------------------------------------- 1 | import { BotScript, Request } from '../../src/engine'; 2 | import { assert } from 'chai'; 3 | 4 | describe('conditional flow', () => { 5 | 6 | const bot = new BotScript(); 7 | bot.parse(` 8 | ~ ask topic 9 | - What topic do you want to ask? 10 | + *{topic} 11 | 12 | ~ ask item 13 | - What do you want to buy 14 | + I want to buy *{item} 15 | + *{item} 16 | 17 | # conditional flows 18 | + i want to ask 19 | * $flows.topic == 'buy phone' ~> ask item 20 | * $flows.done && $flows.count == 2 -> You selected: $flows.item 21 | ~ ask topic 22 | - I dont know topic: $topic 23 | `); 24 | 25 | it('should handle conditional flows', async () => { 26 | let req: Request; 27 | req = await bot.handleAsync(bot.newRequest('i want to ask')); 28 | assert.equal(req.speechResponse, 'What topic do you want to ask?', 'bot ask topic'); 29 | assert.equal(req.currentFlow, 'ask topic'); 30 | 31 | req = await bot.handleAsync(bot.newRequest('buy phone')); 32 | assert.match(req.speechResponse, /what do you want to buy/i); 33 | assert.equal(req.currentFlow, 'ask item'); 34 | 35 | req = await bot.handleAsync(bot.newRequest('apple')); 36 | assert.match(req.speechResponse, /You selected: apple/i); 37 | assert.equal(req.currentFlow, undefined); 38 | 39 | }); 40 | 41 | it('should handle conditional reply', async () => { 42 | let req: Request; 43 | // TODO: forgot last request $flows 44 | // bot.lastRequest = undefined; 45 | req = await bot.handleAsync(bot.newRequest('i want to ask')); 46 | assert.equal(req.speechResponse, 'What topic do you want to ask?', 'bot ask topic'); 47 | assert.equal(req.currentFlow, 'ask topic'); 48 | 49 | req = await bot.handleAsync(bot.newRequest('buy ticket')); 50 | assert.match(req.speechResponse, /I dont know topic/i); 51 | assert.equal(req.currentFlow, undefined); 52 | }); 53 | 54 | }); 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@yeuai/botscript", 3 | "version": "1.8.0", 4 | "description": "A text-based scripting language and bot engine for Conversational User Interfaces (CUI)", 5 | "main": "dist/engine", 6 | "scripts": { 7 | "test": "mocha", 8 | "build": "rimraf dist && tsc", 9 | "build:web": "npx gulp" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/yeuai/botscript.git" 14 | }, 15 | "keywords": [ 16 | "chatbot", 17 | "dialog", 18 | "engine" 19 | ], 20 | "author": "vunb", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/yeuai/botscript/issues" 24 | }, 25 | "homepage": "https://github.com/yeuai/botscript#readme", 26 | "devDependencies": { 27 | "@types/chai": "^4.3.3", 28 | "@types/debug": "^4.1.7", 29 | "@types/jexl": "^2.3.1", 30 | "@types/mocha": "^10.0.1", 31 | "@types/node": "^18.11.9", 32 | "axios-mock-adapter": "^1.21.2", 33 | "browserify": "^17.0.0", 34 | "chai": "^4.3.6", 35 | "coffee-script": "^1.12.7", 36 | "gulp": "^4.0.2", 37 | "gulp-cli": "^2.3.0", 38 | "gulp-rename": "^2.0.0", 39 | "gulp-sourcemaps": "^3.0.0", 40 | "gulp-terser": "^2.1.0", 41 | "gulp-typescript": "^6.0.0-alpha.1", 42 | "mocha": "^10.2.0", 43 | "rimraf": "^5.0.1", 44 | "ts-node": "^10.9.1", 45 | "tsify": "^5.0.4", 46 | "tslint": "^5.20.1", 47 | "typescript": "^4.8.2", 48 | "vinyl-buffer": "^1.0.1", 49 | "vinyl-source-stream": "^2.0.0" 50 | }, 51 | "dependencies": { 52 | "axios": "^0.21.4", 53 | "debug": "^4.3.4", 54 | "handlebars": "^4.7.7", 55 | "jexl": "^2.3.0", 56 | "openai": "^4.6.0", 57 | "rxjs": "^7.8.1", 58 | "vm2": "3.9.5", 59 | "xregexp": "^4.4.1", 60 | "xstate": "^4.33.6" 61 | }, 62 | "files": [ 63 | "dist", 64 | "src", 65 | "LICENSE", 66 | "README.md" 67 | ], 68 | "publishConfig": { 69 | "access": "public" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/e2e/register-bot.spec.ts: -------------------------------------------------------------------------------- 1 | import { BotScript, Request } from '../../src/engine'; 2 | import { expect } from 'chai'; 3 | import { readFileSync } from 'fs'; 4 | 5 | /** 6 | * Register a new account 7 | */ 8 | describe('Register.bot (e2e)', async () => { 9 | 10 | const scripts = readFileSync('examples/register.bot', 'utf-8'); 11 | 12 | it('should register account successfully', async () => { 13 | const bot = new BotScript(); 14 | let req = new Request(); 15 | bot.parse(scripts); 16 | 17 | req = await bot.handleAsync(req.enter('đăng ký')); 18 | 19 | expect(req.speechResponse).match(/nhập tài khoản/i); 20 | req = await bot.handleAsync(req.enter('vunb')); 21 | 22 | expect(req.speechResponse).match(/nhập password/i); 23 | req = await bot.handleAsync(req.enter('123456')); 24 | 25 | expect(req.speechResponse).match(/xác nhận thông tin/i); 26 | req = await bot.handleAsync(req.enter('yes')); 27 | 28 | const {reg_result_message} = req.variables; 29 | const vResult = `Bạn đã đăng ký thành công, tài khoản vunb: ok!`; 30 | // console.log('Request:', req); 31 | expect(reg_result_message).match(/ok/); 32 | expect(req.speechResponse).eq(vResult); 33 | bot.logger.info('Chi tiết tài khoản: ', req.variables); 34 | }); 35 | 36 | it('should register account failure', async () => { 37 | const bot = new BotScript(); 38 | let req = new Request(); 39 | bot.parse(scripts); 40 | 41 | req = await bot.handleAsync(req.enter('đăng ký')); 42 | 43 | expect(req.speechResponse).match(/nhập tài khoản/i); 44 | req = await bot.handleAsync(req.enter('vunb2')); 45 | 46 | expect(req.speechResponse).match(/nhập password/i); 47 | req = await bot.handleAsync(req.enter('123456')); 48 | 49 | expect(req.speechResponse).match(/xác nhận thông tin/i); 50 | req = await bot.handleAsync(req.enter('no')); 51 | 52 | expect(req.speechResponse).match(/bạn đã hủy đăng ký/i); 53 | bot.logger.info('Your input: ', req.message); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require("gulp"); 2 | const browserify = require("browserify"); 3 | const tsify = require("tsify"); 4 | const ts = require("gulp-typescript"); 5 | const terser = require('gulp-terser'); 6 | const rename = require('gulp-rename'); 7 | const sourcemaps = require("gulp-sourcemaps"); 8 | const source = require("vinyl-source-stream"); 9 | const buffer = require("vinyl-buffer"); 10 | 11 | /** 12 | * Copy static file for github-pages. 13 | */ 14 | gulp.task("copy-html", function () { 15 | return gulp.src("./demo.html") 16 | .pipe(rename("index.html")) 17 | .pipe(gulp.dest("docs")); 18 | }); 19 | 20 | gulp.task( 21 | "default", 22 | gulp.series(gulp.parallel("copy-html"), 23 | function MainIfy() { 24 | return browserify({ 25 | standalone: 'BotScriptAI', 26 | basedir: ".", 27 | debug: true, 28 | entries: [ 29 | "src/engine/index.ts" 30 | ], 31 | cache: {}, 32 | packageCache: {}, 33 | }) 34 | .plugin(tsify) 35 | .bundle() 36 | .pipe(source("botscript.ai.js")) 37 | .pipe(buffer()) 38 | .pipe(sourcemaps.init({ loadMaps: true })) 39 | .pipe(terser({ 40 | keep_fnames: true, 41 | mangle: false 42 | })) 43 | .pipe(sourcemaps.write("./")) 44 | .pipe(gulp.dest("docs")); 45 | }, 46 | function PluginIfy() { 47 | return browserify({ 48 | standalone: 'BotScriptPlugins', 49 | basedir: ".", 50 | debug: true, 51 | entries: [ 52 | "src/plugins/index.ts" 53 | ], 54 | cache: {}, 55 | packageCache: {}, 56 | }) 57 | .plugin(tsify) 58 | .bundle() 59 | .pipe(source("botscript.plugins.js")) 60 | .pipe(buffer()) 61 | .pipe(sourcemaps.init({ loadMaps: true })) 62 | .pipe(terser({ 63 | keep_fnames: true, 64 | mangle: false 65 | })) 66 | .pipe(sourcemaps.write("./")) 67 | .pipe(gulp.dest("docs")); 68 | }) 69 | ); 70 | -------------------------------------------------------------------------------- /src/plugins/chatgpt.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import { Request, Context, Logger } from '../common'; 3 | 4 | const logger = new Logger('ChatGPT'); 5 | /** 6 | > chatgpt 7 | 8 | + hello bot 9 | - hello human 10 | */ 11 | export async function chatgpt(req: Request, ctx: Context) { 12 | const baseURL = ctx.definitions.get('api-url')?.value as string; 13 | const apiKey = ctx.definitions.get('api-key')?.value as string; 14 | const apiModel = ctx.definitions.get('api-model')?.value as string; 15 | const apiStream = ctx.definitions.get('api-stream')?.value as string === 'true'; 16 | const dangerouslyAllowBrowser = ctx.definitions.get('api-browser')?.value as string !== 'false'; 17 | const messages = req.variables.messages as any[] || []; 18 | const openai = new OpenAI({ 19 | apiKey, 20 | dangerouslyAllowBrowser, 21 | baseURL: baseURL || 'https://api.openai.vn/v1', 22 | }); 23 | 24 | let result = ''; 25 | if (messages.length === 0) { 26 | messages.push({ 27 | role: 'user', 28 | content: req.message, 29 | }); 30 | } 31 | logger.info('Feed the message: ', messages); 32 | if (apiStream) { 33 | const stream = await openai.chat.completions.create({ 34 | messages, 35 | model: apiModel || 'gpt-3.5-turbo', 36 | stream: apiStream, 37 | }); 38 | for await (const chunk of stream) { 39 | const content = chunk.choices[0]?.delta?.content || ''; 40 | const role = chunk.choices[0]?.delta?.role; 41 | result += content; 42 | ctx.emit('typing', { result, content, role }); 43 | } 44 | } else { 45 | const completion = await openai.chat.completions.create({ 46 | messages, 47 | model: apiModel || 'gpt-3.5-turbo', 48 | stream: apiStream, 49 | }); 50 | result = completion.choices[0].message.content as string; 51 | } 52 | 53 | return () => { 54 | messages.push({ 55 | role: 'assistant', 56 | content: result, 57 | }); 58 | Object.assign(req.variables, { messages }); 59 | req.speechResponse = result; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/engine/trigger.ts: -------------------------------------------------------------------------------- 1 | import { IActivator } from '../interfaces/activator'; 2 | import { Logger } from '../lib/logger'; 3 | import { wordsCount } from '../vendor/words-count/words-count'; 4 | 5 | const logger = new Logger('Trigger'); 6 | 7 | /** 8 | * Dialogue trigger 9 | */ 10 | export class Trigger { 11 | // dialogue id 12 | dialog: string; 13 | original: string; 14 | source: string; // translated original 15 | pattern: RegExp | IActivator; 16 | hasWildcards: boolean; 17 | 18 | constructor(pattern: string = '', id?: string) { 19 | this.original = pattern; 20 | this.source = pattern; 21 | this.dialog = id || ''; 22 | } 23 | 24 | /** 25 | * atomic pattern 26 | */ 27 | get isAtomic(): boolean { 28 | return this.original === this.source; 29 | } 30 | 31 | /** 32 | * Get words count 33 | */ 34 | get countWords(): number { 35 | return wordsCount(this.source); 36 | } 37 | countWildcards: number; 38 | 39 | /** 40 | * Trigger sorter 41 | * @param a 42 | * @param b 43 | * @returns 44 | */ 45 | public static sorter(a: Trigger, b: Trigger): number { 46 | if (a.isAtomic && b.isAtomic) { 47 | const aWords = a.countWords; 48 | const bWords = b.countWords; 49 | if (aWords === bWords) { 50 | // sắp xếp theo thứ tự alphabet 51 | logger.debug('a & b is atomic!'); 52 | if (b.source > a.source) { 53 | return 1; 54 | } 55 | return b.source < a.source ? -1 : 0; 56 | } else if (bWords > aWords) { 57 | return 1; 58 | } else { 59 | return bWords < aWords ? -1 : 0; 60 | } 61 | } else if (a.isAtomic) { 62 | logger.debug('a is atomic!'); 63 | return -1; 64 | } else if (b.isAtomic) { 65 | logger.debug('b is atomic!'); 66 | return 1; 67 | } else { 68 | // Both of a & b is not atomic 69 | // TODO: Improve 70 | logger.debug('a & b is not atomic!'); 71 | return b.source.length - a.source.length; 72 | } 73 | } 74 | 75 | toString() { 76 | return this.source; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/plugins/built-in.ts: -------------------------------------------------------------------------------- 1 | export const SYM_CODE1 = '```'; 2 | export const SYM_CODE2 = '~~~'; 3 | export const PLUGINS_BUILT_IN = ` 4 | /plugin: addTimeNow 5 | ~~~js 6 | const now = new Date(); 7 | req.variables.time = \`\${now.getHours()}:\${now.getMinutes()}\`; 8 | ~~~ 9 | 10 | 11 | /plugin: normalize 12 | ~~~js 13 | req.message = utils.clean.all(req.message).replace(/[+-?!.,]+$/, ''); 14 | ~~~ 15 | 16 | /plugin: noReplyHandle 17 | ~~~js 18 | const postProcessing = (res, ctx) => { 19 | if (res.speechResponse === 'NO REPLY!') { 20 | if (ctx.flows.has(res.currentFlow)) { 21 | const dialog = ctx.flows.get(res.currentFlow); 22 | const replyCandidate = utils.random(dialog.replies); 23 | res.speechResponse = replyCandidate; 24 | } else { 25 | res.speechResponse = "Sorry! I don't understand!"; 26 | } 27 | } 28 | }; 29 | 30 | return postProcessing; 31 | ~~~ 32 | 33 | /plugin: nlu 34 | ~~~js 35 | logger.info('Process NLU: ', 123); 36 | const vCommandNlu = ctx.commands.get('nlu'); 37 | // Extract intent/entities from vNluDirective API Server. 38 | logger.info('Send command request: ' + vCommandNlu?.name); 39 | const vResult = await utils.callHttpService(vCommandNlu, req); 40 | 41 | // attach result to request message. 42 | req.intent = vResult.intent; 43 | req.entities = vResult.entities; 44 | logger.info('NLU intent: ' + vResult.intent); 45 | logger.info('NLU entities: ' + JSON.stringify(vResult.entities)); 46 | ~~~ 47 | `; 48 | 49 | /** 50 | * Extract plugin code & wrap it! 51 | * @param plugin 52 | * @returns 53 | */ 54 | export function wrapCode(plugin: string): string { 55 | const vCode = plugin 56 | .replace(/```js([^`]*)```/, (m: string, code: string) => code) 57 | .replace(/~~~js([^~]*)~~~/, (m: string, code: string) => code); 58 | 59 | return ` 60 | return () => (async ({req, ctx, utils, logger}) => { 61 | try { 62 | ${vCode} 63 | } catch (error) { 64 | logger.error('Execute error!', error); 65 | } 66 | })({req, ctx, utils, logger})`; 67 | } 68 | 69 | /** 70 | * Extract plugin code & wrap it for Browser! 71 | * @param plugin 72 | * @returns 73 | */ 74 | export function wrapCodeBrowser(plugin: string): string { 75 | const vCode = plugin 76 | .replace(/```js([^`]*)```/, (m: string, code: string) => code) 77 | .replace(/~~~js([^~]*)~~~/, (m: string, code: string) => code); 78 | 79 | return ` 80 | () => (async ({req, ctx, utils, logger}) => { 81 | try { 82 | ${vCode} 83 | } catch (error) { 84 | logger.error('Execute error!', error); 85 | } 86 | })({req, ctx, utils, logger})`; 87 | } 88 | -------------------------------------------------------------------------------- /test/engine/pattern.spec.ts: -------------------------------------------------------------------------------- 1 | import { execPattern, transform, Context, Request } from '../../src/engine'; 2 | import { expect, assert } from 'chai'; 3 | 4 | const INPUT_TEXT = 'I would like to buy 10 tickets'; 5 | 6 | describe('Pattern', () => { 7 | 8 | describe('RegEx Syntax', () => { 9 | it('should match a part of sentence', async () => { 10 | const pattern = transform('like', new Request(), new Context(), false); 11 | assert.instanceOf(pattern, RegExp); 12 | assert.isTrue(pattern.test(INPUT_TEXT)); 13 | }); 14 | 15 | it('should match a named variable', async () => { 16 | const pattern = transform('/^I would (?.+) to/', new Request(), new Context(), false); 17 | assert.isTrue(pattern.test(INPUT_TEXT)); 18 | const captures = execPattern(INPUT_TEXT, pattern); 19 | assert.deepEqual(captures, { 20 | $1: 'like', 21 | verb: 'like', 22 | }); 23 | }); 24 | 25 | it('should captures intent and entities', async () => { 26 | const pattern = transform('/^I (.*) to (?.+) (\\d+) (?.*)/', new Request(), new Context(), false); 27 | assert.isTrue(pattern.test(INPUT_TEXT)); 28 | const captures = execPattern(INPUT_TEXT, pattern); 29 | assert.deepEqual(captures, { 30 | $1: 'would like', 31 | $2: 'buy', 32 | $3: '10', 33 | $4: 'tickets', 34 | verb: 'buy', 35 | what: 'tickets', 36 | }); 37 | }); 38 | }); 39 | 40 | describe('BotScript Syntax', () => { 41 | it('should match exact same sentence', async () => { 42 | const pattern = transform(INPUT_TEXT, new Request(), new Context(), false); 43 | assert.isTrue(pattern.test(INPUT_TEXT)); 44 | }); 45 | 46 | it('should not match part of sentence', async () => { 47 | const pattern = transform('wou', new Request(), new Context(), false); 48 | assert.isFalse(pattern.test(INPUT_TEXT)); 49 | }); 50 | 51 | it('should match star wildcard', async () => { 52 | const pattern = transform('I would like *', new Request(), new Context(), false); 53 | assert.isTrue(pattern.test(INPUT_TEXT)); 54 | 55 | const captures = execPattern(INPUT_TEXT, pattern); 56 | assert.deepEqual(captures, { 57 | $1: 'to buy 10 tickets', 58 | }); 59 | }); 60 | 61 | it('should match number wildcard', async () => { 62 | const pattern = transform('buy # tickets', new Request(), new Context(), false); 63 | assert.isTrue(pattern.test(INPUT_TEXT)); 64 | 65 | const captures = execPattern(INPUT_TEXT, pattern); 66 | assert.deepEqual(captures, { 67 | $1: '10', 68 | }); 69 | }); 70 | 71 | }); 72 | 73 | }); 74 | -------------------------------------------------------------------------------- /test/directives/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { BotScript, Request } from '../../src/engine'; 3 | 4 | describe('Directive: /plugin', () => { 5 | 6 | describe('A simple plugin', async () => { 7 | const bot = new BotScript(); 8 | 9 | bot.parse(` 10 | /plugin: test 11 | \`\`\`js 12 | req.variables.today = new Date().getDate(); 13 | req.variables.day = new Date().getDay(); 14 | 15 | # test 2 16 | req.variables.year = new Date().getFullYear(); 17 | \`\`\` 18 | 19 | > test 20 | 21 | + howdy 22 | - Today is $today 23 | `); 24 | await bot.init(); 25 | 26 | it('should load directive /plugin: test', async () => { 27 | // console.log(bot.context.directives); 28 | // console.log(bot.context.plugins); 29 | assert.isTrue(bot.context.directives.has('plugin:test'), 'contains directive /plugin'); 30 | }); 31 | 32 | it('should execute plugin and get value', async () => { 33 | const today = new Date().getDate(); 34 | const req = new Request(); 35 | // ask bot with data output format 36 | const res2 = await bot.handleAsync(req.enter('howdy')); 37 | assert.match(res2.speechResponse, /today is/i, 'bot response'); 38 | assert.equal(res2.variables.today, today, 'today value number'); 39 | }); 40 | }); 41 | 42 | describe('Support post-processing', async () => { 43 | const bot = new BotScript(); 44 | 45 | bot.parse(` 46 | /plugin: testNoReply 47 | \`\`\`js 48 | req.variables.today = new Date().getDate(); 49 | 50 | return (req, ctx) => { 51 | // do post-processing. 52 | if (req.speechResponse === 'Hello') { 53 | req.speechResponse = 'Hello Human!'; 54 | } 55 | if (req.isNotResponse) { 56 | req.speechResponse = 'I dont know!'; 57 | } 58 | } 59 | \`\`\` 60 | 61 | > testNoReply 62 | 63 | + hello bot 64 | - Hello 65 | `); 66 | await bot.init(); 67 | 68 | it('should load directive /plugin: testNoReply', async () => { 69 | assert.isTrue(bot.context.directives.has('plugin:testNoReply'), 'contains directive /plugin'); 70 | }); 71 | 72 | it('should execute plugin and get value', async () => { 73 | const today = new Date().getDate(); 74 | const req = new Request(); 75 | // ask bot with data output format 76 | const res1 = await bot.handleAsync(req.enter('hello bot')); 77 | assert.match(res1.speechResponse, /Hello Human!/i, 'bot response'); 78 | assert.equal(res1.variables.today, today, 'today value number'); 79 | // no reply 80 | const res2 = await bot.handleAsync(req.enter('something great')); 81 | assert.match(res2.speechResponse, /I dont know!/i, 'bot response'); 82 | assert.equal(res2.variables.today, today, 'today value number'); 83 | 84 | }); 85 | }); 86 | 87 | }); 88 | -------------------------------------------------------------------------------- /src/vendor/words-count/words-count.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_PUNCTUATION = [ 2 | ',', ',', '.', '。', ':', ':', ';', ';', '[', ']', '【', ']', '】', '{', '{', '}', '}', 3 | '(', '(', ')', ')', '<', '《', '>', '》', '$', '¥', '!', '!', '?', '?', '~', '~', 4 | "'", '’', '"', '“', '”', 5 | '*', '/', '\\', '&', '%', '@', '#', '^', '、', '、', '、', '、' 6 | ]; 7 | 8 | const EMPTY_RESULT = { 9 | words: [], 10 | count: 0 11 | } 12 | 13 | type Config = { 14 | punctuationAsBreaker?: boolean; 15 | disableDefaultPunctuation?: boolean; 16 | punctuation?: string[]; 17 | }; 18 | 19 | const wordsDetect = (text: string, config: Config = {}) => { 20 | if (!text || text.trim() === '') return EMPTY_RESULT; 21 | 22 | const punctuationReplacer = config.punctuationAsBreaker ? ' ' : ''; 23 | const defaultPunctuations = config.disableDefaultPunctuation ? [] : DEFAULT_PUNCTUATION; 24 | const customizedPunctuations = config.punctuation || []; 25 | const combinedPunctionations = defaultPunctuations.concat(customizedPunctuations); 26 | // Remove punctuations or change to empty space 27 | combinedPunctionations.forEach(function (punctuation) { 28 | const punctuationReg = new RegExp('\\' + punctuation, 'g'); 29 | text = text.replace(punctuationReg, punctuationReplacer); 30 | }); 31 | // Remove all kind of symbols 32 | text = text.replace(/[\uFF00-\uFFEF\u2000-\u206F]/g, ''); 33 | // Format white space character 34 | text = text.replace(/\s+/, ' '); 35 | // Split words by white space (For European languages) 36 | let words = text.split(' '); 37 | words = words.filter(word => word.trim()); 38 | // Match latin, cyrillic, Malayalam letters and numbers 39 | const common = '(\\d+)|[a-zA-Z\u00C0-\u00FF\u0100-\u017F\u0180-\u024F\u0250-\u02AF\u1E00-\u1EFF\u0400-\u04FF\u0500-\u052F\u0D00-\u0D7F]+|'; 40 | // Match Chinese Hànzì, the Japanese Kanji and the Korean Hanja 41 | const cjk = '\u2E80-\u2EFF\u2F00-\u2FDF\u3000-\u303F\u31C0-\u31EF\u3200-\u32FF\u3300-\u33FF\u3400-\u3FFF\u4000-\u4DBF\u4E00-\u4FFF\u5000-\u5FFF\u6000-\u6FFF\u7000-\u7FFF\u8000-\u8FFF\u9000-\u9FFF\uF900-\uFAFF'; 42 | // Match Japanese Hiragana, Katakana, Rōmaji 43 | const jp = '\u3040-\u309F\u30A0-\u30FF\u31F0-\u31FF\u3190-\u319F'; 44 | // Match Korean Hangul 45 | const kr = '\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uAC00-\uAFFF\uB000-\uBFFF\uC000-\uCFFF\uD000-\uD7AF\uD7B0-\uD7FF'; 46 | 47 | const reg = new RegExp( 48 | common + '[' + cjk + jp + kr + ']', 49 | 'g' 50 | ); 51 | let detectedWords: string[] = []; 52 | words.forEach(function (word) { 53 | const carry = []; 54 | let matched; 55 | do { 56 | matched = reg.exec(word); 57 | if (matched) carry.push(matched[0]) 58 | } while (matched); 59 | if (carry.length === 0) { 60 | detectedWords.push(word); 61 | } else { 62 | detectedWords = detectedWords.concat(carry); 63 | } 64 | }); 65 | return { 66 | words: detectedWords, 67 | count: detectedWords.length 68 | }; 69 | } 70 | 71 | const wordsCount = (text: string, config?: Config) => { 72 | const { count } = wordsDetect(text, config); 73 | return count; 74 | } 75 | 76 | const wordsSplit = (text: string, config?: Config) => { 77 | const { words } = wordsDetect(text, config); 78 | return words; 79 | } 80 | 81 | export { 82 | wordsCount, 83 | wordsSplit, 84 | wordsDetect 85 | }; 86 | -------------------------------------------------------------------------------- /test/engine/botscript.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { BotScript, Request } from '../../src/engine'; 3 | 4 | describe('BotScript', () => { 5 | 6 | const bot = new BotScript(); 7 | 8 | bot.parse(` 9 | ! name BotScript 10 | 11 | + hello bot 12 | - Hello human! 13 | 14 | + what is your name 15 | - My name is [name] 16 | 17 | `); 18 | 19 | describe('basic reply', () => { 20 | it('respond a message to human', async () => { 21 | const req = new Request('hello bot'); 22 | const res = await bot.handleAsync(req); 23 | assert.match(res.speechResponse, /hello human/i, 'bot reply human'); 24 | }); 25 | 26 | it('should reply with definition', async () => { 27 | const req = new Request('what is your name'); 28 | const res = await bot.handleAsync(req); 29 | assert.match(res.speechResponse, /my name is botscript/i, 'bot shows his name'); 30 | }); 31 | }); 32 | 33 | describe('no reply', () => { 34 | it('should respond no reply!', async () => { 35 | const req = new Request('sfdsfi!'); 36 | const res = await bot.handleAsync(req); 37 | assert.match(res.speechResponse, /no reply/i, 'bot no reply'); 38 | }); 39 | }); 40 | 41 | describe('add custom pattern', () => { 42 | // tslint:disable-next-line: no-shadowed-variable 43 | const bot = new BotScript(); 44 | bot.parse(`/plugin: nlp 45 | ~~~js 46 | if (req.message === 'tôi là ai') { 47 | req.intent = 'whoami'; 48 | req.entities = [{ 49 | name: 'PER', 50 | value: 'Genius', 51 | }]; 52 | } 53 | ~~~ 54 | `); 55 | bot.parse(` 56 | + ([{ tag:VB }]) [{ word:you }] 57 | - So you want to $1 me, huh? 58 | 59 | + intent: whoami 60 | - You are genius! 61 | 62 | > nlp 63 | `); 64 | bot 65 | .addPatternCapability({ 66 | name: 'TokensRegex', 67 | match: /\[\s*\{\s*(?:word|tag|lemma|ner|normalized):/i, 68 | func: (pattern) => ({ 69 | source: pattern, 70 | test: (input) => /love/.test(input), 71 | exec: (input) => [input, 'love'], 72 | toString: () => pattern, 73 | }), 74 | }) 75 | .addPatternCapability({ 76 | name: 'Intent detection', 77 | match: /^intent:/i, 78 | func: (pattern, req) => ({ 79 | source: pattern, 80 | test: (input) => { 81 | const vIntentName = pattern.replace(/^intent:/i, '').trim(); 82 | return req.intent === vIntentName; 83 | }, 84 | exec: (input) => { 85 | // entities list 86 | return req.entities.map((x: any) => x.value); 87 | }, 88 | toString: () => pattern, 89 | }), 90 | }); 91 | 92 | it('should support TokensRegex', async () => { 93 | const res = await bot.handleAsync(new Request('love you')); 94 | assert.match(res.speechResponse, /you want to love/i, 'bot reply'); 95 | }); 96 | 97 | it('should detect intent', async () => { 98 | const res = await bot.handleAsync(new Request('tôi là ai')); 99 | assert.match(res.intent, /whoami/i, 'intent'); 100 | assert.match(res.speechResponse, /you are genius/i, 'bot reply'); 101 | }); 102 | }); 103 | 104 | }); 105 | -------------------------------------------------------------------------------- /src/engine/request.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Dialogue request (human context) 3 | */ 4 | export class Request { 5 | 6 | public botId: string; 7 | public sessionId: string; 8 | public message: string; 9 | public speechResponse: string; 10 | public time: Date; // request time 11 | 12 | /** 13 | * This flag indicates the dialogue is forwarding 14 | * Bot must reset request and enter the new dialogue 15 | */ 16 | public isForward: boolean; 17 | 18 | /** 19 | * This flag indicates the dialogue is flowing 20 | * Bot must enter the flow and resolve it 21 | */ 22 | public isFlowing: boolean; 23 | 24 | /** 25 | * This flag indicates the dialogue is resolved 26 | */ 27 | public isNotResponse: boolean; 28 | 29 | /** 30 | * Dialogue flows in queue 31 | */ 32 | public flows: string[]; 33 | 34 | /** 35 | * Flows queue are resolved 36 | */ 37 | public resolvedFlows: string[]; 38 | 39 | /** 40 | * Flows are missing 41 | */ 42 | public missingFlows: string[]; 43 | 44 | /** 45 | * Data context flows 46 | * TODO: Rename $flows to $scope? => $scope.ask_flow_info 47 | */ 48 | public $flows: { 49 | [x: string]: string; 50 | }; 51 | 52 | /** 53 | * Human variables extracted in the conversation 54 | */ 55 | public variables: any; 56 | 57 | /** 58 | * NLP extracted entities (current) 59 | */ 60 | public entities: any; 61 | 62 | /** 63 | * NLP intent detection 64 | */ 65 | public intent: string; 66 | 67 | /** 68 | * Current flow to be resolved 69 | */ 70 | public currentFlow: string; 71 | 72 | /** 73 | * Current flow resolved state 74 | */ 75 | public currentFlowIsResolved: boolean; 76 | 77 | /** 78 | * Current talking dialogue 79 | */ 80 | public currentDialogue: string; 81 | 82 | /** 83 | * Original talking dialogue 84 | */ 85 | public originalDialogue: string; 86 | 87 | /** 88 | * Prompt human how to answer 89 | */ 90 | public prompt: string[]; 91 | 92 | /** 93 | * Bot previous speech responses 94 | */ 95 | public previous: string[]; 96 | 97 | /** 98 | * Initialize a new message request 99 | * @param message text input 100 | */ 101 | constructor(message?: string) { 102 | this.flows = []; 103 | this.variables = {}; 104 | this.isFlowing = false; 105 | this.isForward = false; 106 | this.resolvedFlows = []; 107 | this.missingFlows = []; 108 | this.previous = []; 109 | this.$flows = {}; 110 | this.time = new Date(); 111 | 112 | if (message) { 113 | this.message = message.toLowerCase(); 114 | } 115 | } 116 | 117 | /** 118 | * Update new message text 119 | * FOR: Testing 120 | * @param text 121 | */ 122 | enter(text: string) { 123 | this.message = text; 124 | this.isForward = false; 125 | return this; 126 | } 127 | 128 | /** 129 | * Get current request contexts scope 130 | */ 131 | get contexts() { 132 | const resolved = this.resolvedFlows.length; 133 | const missing = this.missingFlows.length; 134 | const count = this.flows.length; 135 | // flows are resolved 136 | const done = (count > 0) && (count === resolved) && (missing === 0); 137 | const $flows = { ...this.$flows, resolved, missing, count, done }; // $flow scope 138 | return { 139 | ...this.variables, 140 | ...this.$flows, 141 | $previous: this.previous, 142 | $input: this.message, 143 | $flows, 144 | }; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /test/plugin/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { BotScript, Request } from '../../src/engine'; 2 | import { assert } from 'chai'; 3 | 4 | describe('Plugin', () => { 5 | 6 | describe('(built-in)', () => { 7 | 8 | const botPlugin = new BotScript(); 9 | 10 | botPlugin.parse(` 11 | > addTimeNow 12 | > noReplyHandle 13 | * true 14 | 15 | ~ name 16 | - what is your name? 17 | + my name is *{name} 18 | 19 | + what is my name 20 | ~ name 21 | - your name is $name 22 | 23 | + what time is it 24 | - it is $time 25 | `); 26 | 27 | it('should ask time now', async () => { 28 | const now = new Date(); 29 | const req = new Request('what time is it'); 30 | const time = `${now.getHours()}:${now.getMinutes()}`; 31 | 32 | const res = await botPlugin.handleAsync(req); 33 | assert.equal(res.variables.time, time, 'respond time in format HH:mm'); 34 | }); 35 | 36 | it('should respond not understand', async () => { 37 | const req = new Request('how is it today'); 38 | 39 | const res = await botPlugin.handleAsync(req); 40 | assert.match(res.speechResponse, /i don't understand/i); 41 | }); 42 | 43 | it('should ask human again if dialog is in the flow', async () => { 44 | let flowsReq = new Request(); 45 | 46 | flowsReq = await botPlugin.handleAsync(flowsReq.enter('what is my name')); 47 | assert.match(flowsReq.speechResponse, /what is your name/i); 48 | 49 | flowsReq = await botPlugin.handleAsync(flowsReq.enter('what?')); 50 | assert.match(flowsReq.speechResponse, /what is your name/i); 51 | 52 | }); 53 | 54 | }); 55 | 56 | describe('(extend)', () => { 57 | 58 | const botPlugin = new BotScript(); 59 | botPlugin 60 | .parse(` 61 | ! name Alice 62 | 63 | > human name 64 | > other preprocess 65 | > unknow plugin 66 | 67 | + what is your name 68 | - my name is [name] 69 | 70 | + what is my name 71 | - my name is $name 72 | 73 | /plugin: human name 74 | ~~~js 75 | req.variables.name = 'Bob'; 76 | req.variables.intent = 'ask name'; 77 | ~~~ 78 | `); 79 | // .plugin('human name', async (req, ctx) => { 80 | // req.variables.name = 'Bob'; 81 | // req.variables.intent = 'ask name'; 82 | // }); 83 | 84 | it('should know human name', async () => { 85 | let req = new Request('what is your name?'); 86 | 87 | req = await botPlugin.handleAsync(req); 88 | assert.match(req.speechResponse, /my name is alice/i, 'ask bot name'); 89 | assert.match(req.variables.intent, /ask name/i, 'async plugin'); 90 | }); 91 | 92 | }); 93 | 94 | describe('(AND) multiple conditions', () => { 95 | 96 | const botPlugin = new BotScript(); 97 | botPlugin 98 | .parse(` 99 | > human name 100 | * true 101 | * $fired != true 102 | 103 | + what is your name 104 | * $flows.name == undefined => i lost my name: $flows.name! 105 | - my name is $flows.name 106 | 107 | /plugin: human name 108 | ~~~js 109 | req.variables.fired = true; 110 | req.$flows.name = 'Bob'; 111 | ~~~ 112 | `); 113 | // .plugin('human name', async (req, ctx) => { 114 | // req.variables.fired = true; 115 | // req.$flows.name = 'Bob'; 116 | // }); 117 | 118 | it('should know human name', async () => { 119 | let req = new Request('what is your name?'); 120 | 121 | req = await botPlugin.handleAsync(req); 122 | assert.match(req.speechResponse, /my name is bob/i, 'ask name'); 123 | assert.equal(req.$flows.name, 'Bob', 'got name'); 124 | 125 | req = await botPlugin.handleAsync(req); 126 | assert.match(req.speechResponse, /i lost my name/i, 'ask name again'); 127 | assert.isUndefined(req.$flows.name, 'forgot `name` when plugin is not activated'); 128 | 129 | }); 130 | 131 | }); 132 | 133 | }); 134 | -------------------------------------------------------------------------------- /test/directives/directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { BotScript, Request } from '../../src/engine'; 3 | 4 | describe('Feature: Directive', () => { 5 | 6 | describe('Default NLU', () => { 7 | const bot = new BotScript(); 8 | 9 | bot.parse(` 10 | > nlu 11 | 12 | @ nlu /api/nlu 13 | 14 | + intent: who 15 | - You are genius 16 | `); 17 | 18 | it('should detect intent by default NLU', async () => { 19 | const req = await bot.handleAsync(new Request('tôi là ai')); 20 | assert.match(req.intent, /who/i, 'intent'); 21 | assert.match(req.speechResponse, /you are genius/i, 'bot reply'); 22 | }); 23 | }); 24 | 25 | describe('Custom NLU by using Directive', () => { 26 | const bot = new BotScript(); 27 | bot.parse(` 28 | > nlu 29 | 30 | @ nlu /api/nlu/react 31 | 32 | + intent: react_positive 33 | - You are funny 34 | `); 35 | 36 | it('should detect intent by custom NLU (using directive)', async () => { 37 | const req = await bot.handleAsync(new Request('You are great')); 38 | assert.match(req.intent, /react_positive/i, 'intent'); 39 | assert.match(req.speechResponse, /you are funny/i, 'bot reply'); 40 | }); 41 | }); 42 | 43 | describe('Directive: include', async () => { 44 | it('should include scripts from url', async () => { 45 | const bot = new BotScript(); 46 | bot.parse(` 47 | /include: https://raw.githubusercontent.com/yeuai/botscript/master/examples/hello.bot 48 | `); 49 | await bot.init(); 50 | const req = await bot.handleAsync(new Request('Hello bot')); 51 | assert.match(req.speechResponse, /Hello, human!/i, 'bot reply'); 52 | }); 53 | }); 54 | 55 | describe('Directive: format', () => { 56 | it('should format response with data', async () => { 57 | const bot = new BotScript(); 58 | bot.parse(` 59 | @ list_patient /api/data/list 60 | 61 | /format: list 62 |
    63 | {{#each people}} 64 |
  • {{name}} / {{age}}
  • , 65 | {{/each}} 66 |
67 | 68 | + show my list 69 | * true @> list_patient 70 | - $people /format:list 71 | 72 | + shorthand format 73 | * true @> list_patient 74 | - $people :list 75 | `); 76 | await bot.init(); 77 | 78 | const vFormatDirective = bot.context.directives.get('format:list'); 79 | const vNonExistsDirective = bot.context.directives.get('format: list'); 80 | assert.isNotNull(vFormatDirective, 'Parsed directive format'); 81 | assert.isUndefined(vNonExistsDirective, 'Get directive without name normalization'); 82 | 83 | // ask bot with data output format 84 | const req = await bot.handleAsync(new Request('show my list')); 85 | // response with formmated data 86 | assert.match(req.speechResponse, /^
    .*<\/ul>$/i, 'show formatted response'); 87 | // console.log('Speech response: ', req.speechResponse); 88 | // response with template engine (current support handlebars) 89 | const vOccurs = req.speechResponse.split('
  • ').length; 90 | assert.equal(vOccurs - 1, 3, 'generated data with template'); 91 | 92 | // ask bot with data output format 93 | const res2 = await bot.handleAsync(new Request('shorthand format')); 94 | // console.log('Output: ', res2.speechResponse); 95 | assert.equal(res2.speechResponse.split('
  • ').length - 1, 3, 'shorthand format'); 96 | }); 97 | 98 | it('should format variable via common name: value or $var', async () => { 99 | const bot = new BotScript(); 100 | bot.parse(` 101 | /format: bold 102 | {{value}}{{me}} 103 | 104 | + bold *{me} 105 | - $me :bold 106 | `); 107 | await bot.init(); 108 | 109 | // ask bot with data output format 110 | const res2 = await bot.handleAsync(new Request('bold vunb')); 111 | assert.equal(res2.speechResponse, 'vunbvunb', 'format bold'); 112 | }); 113 | }); 114 | 115 | }); 116 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello BotScript! 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 |
    19 |

    Hello, BotScript!

    20 |

    A text-based scripting language, dialog system and bot engine for Conversational User Interfaces

    21 |

    22 | Register Now 23 | » 24 | 26 |

    27 |
    28 |
    29 |
    30 |

    Do you have any questions to ask BotScript?

    31 |

    32 |
    33 |
    34 |
    35 | 113 | 114 | 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /test/engine/conditions.spec.ts: -------------------------------------------------------------------------------- 1 | import { BotScript, Request } from '../../src/engine'; 2 | import { REGEX_IP } from '../../src/lib/regex'; 3 | import { assert } from 'chai'; 4 | 5 | describe('BotScript: Conditional dialogue', () => { 6 | 7 | const bot = new BotScript(); 8 | 9 | bot.parse(` 10 | + hello* 11 | * $name != null -> Are you $name? 12 | - Hello, human! 13 | - hi 14 | 15 | + my name is *{name} 16 | * $name == 'vunb' => Hello my boss! 17 | * $name == 'boss' => - I know you! 18 | - hello $name 19 | `); 20 | 21 | describe('basic condition', () => { 22 | it('respond NO REPLY!', async () => { 23 | let req = new Request(); 24 | req = await bot.handleAsync(req.enter('my name is bob')); 25 | assert.match(req.speechResponse, /hello bob/i); 26 | 27 | req = await bot.handleAsync(req.enter('hello')); 28 | assert.match(req.speechResponse, /are you bob/i); 29 | 30 | req = await bot.handleAsync(req.enter('something')); 31 | assert.match(req.speechResponse, /NO REPLY/i); 32 | }); 33 | }); 34 | 35 | // common syntax 36 | describe('Syntax: * expresssion => action', () => { 37 | it('a reply', async () => { 38 | let req = new Request(); 39 | req = await bot.handleAsync(req.enter('my name is vunb')); 40 | assert.equal(req.speechResponse, 'Hello my boss!', 'reply exactly!'); 41 | 42 | req = await bot.handleAsync(req.enter('my name is boss')); 43 | assert.equal(req.speechResponse, 'I know you!'); 44 | }); 45 | }); 46 | 47 | describe('conditional dialogues', () => { 48 | const condBot = new BotScript(); 49 | condBot.parse(` 50 | ! topics 51 | - warranty 52 | - support 53 | - feedback 54 | 55 | + cancel 56 | - You are canceled! 57 | 58 | @ geoip https://api.ipify.org/?format=json 59 | #- header: value 60 | #- header: value (2) 61 | 62 | # conditional command 63 | + what is my ip 64 | * true @> geoip 65 | - Here is your ip: $ip 66 | 67 | # conditional redirect 68 | + i dont wanna talk to you 69 | * true >> cancel 70 | - Ok! 71 | 72 | # conditional event 73 | + turn off the light 74 | * true +> notify 75 | - Ok! 76 | 77 | # conditional prompt 78 | + help me 79 | * true ?> topics 80 | - Please choose a topic! 81 | 82 | # conditional activations & previous history 83 | + knock knock 84 | - who is there 85 | 86 | + * 87 | * $previous[0] == 'who is there' 88 | * $input == 'its me' -> i know you! 89 | - $1 who? 90 | `); 91 | 92 | /** 93 | * Add bot event listener 94 | */ 95 | condBot.once('notify', (req: Request) => { 96 | condBot.logger.info('Got an event:', req.currentDialogue, req.variables); 97 | req.variables.notified = true; 98 | }); 99 | 100 | it('should handle conditional activation', async () => { 101 | let req = new Request(); 102 | 103 | req = await condBot.handleAsync(req.enter('knock knock')); 104 | assert.match(req.speechResponse, /who is there/i); 105 | 106 | req = await condBot.handleAsync(req.enter('vunb')); 107 | assert.match(req.speechResponse, /vunb who/i); 108 | 109 | // lost context previous reply. 110 | req = await condBot.handleAsync(req.enter('vunb')); 111 | assert.match(req.speechResponse, /NO REPLY!/i); 112 | 113 | let req2 = new Request(); 114 | 115 | req2 = await condBot.handleAsync(req2.enter('knock knock')); 116 | assert.match(req2.speechResponse, /who is there/i); 117 | 118 | req2 = await condBot.handleAsync(req2.enter('its me')); 119 | assert.match(req2.speechResponse, /i know you/i); 120 | }); 121 | 122 | it('should handle conditional command', async () => { 123 | // ensure the internet is connected for this test case 124 | let req = new Request('what is my ip'); 125 | req = await condBot.handleAsync(req); 126 | assert.match(req.speechResponse, /here is your ip/i, 'bot reply'); 127 | assert.match(req.variables.ip, REGEX_IP, 'match ip'); 128 | }); 129 | 130 | it('should handle conditional redirect', async () => { 131 | let req = new Request('i dont wanna talk to you'); 132 | req = await condBot.handleAsync(req); 133 | assert.isTrue(req.isForward); 134 | assert.isFalse(req.isFlowing); 135 | assert.match(req.speechResponse, /you are canceled/i, 'bot reply'); 136 | }); 137 | 138 | it('should handle conditional event', async () => { 139 | let req = new Request('turn off the light'); 140 | req = await condBot.handleAsync(req); 141 | assert.match(req.speechResponse, /ok/i, 'bot reply'); 142 | assert.equal(req.variables.notified, true, 'add more info'); 143 | }); 144 | 145 | it('should handle conditional prompt', async () => { 146 | let req = new Request('help me'); 147 | req = await condBot.handleAsync(req); 148 | assert.match(req.speechResponse, /choose a topic/i, 'bot reply'); 149 | assert.deepEqual(req.prompt, ['warranty', 'support', 'feedback'], 'get prompts'); 150 | }); 151 | }); 152 | 153 | }); 154 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import axios, { Method } from 'axios'; 2 | import jexl from 'jexl'; 3 | import { Struct, Request } from '../common'; 4 | import { TestConditionalCallback, Types } from '../interfaces/types'; 5 | import { Logger } from './logger'; 6 | import { interpolate } from './template'; 7 | import { REGEX_HEADER_SEPARATOR } from './regex'; 8 | import clean from './clean'; 9 | 10 | const logger = new Logger('Utils'); 11 | 12 | /** 13 | * Get random candidate 14 | * @param candidates array 15 | */ 16 | export function random(candidates: T[]) { 17 | return candidates[Math.floor(Math.random() * candidates.length)]; 18 | } 19 | 20 | /** 21 | * Generate a new id 22 | * Ref: https://stackoverflow.com/a/44078785/1896897 23 | * @returns Simple unique id 24 | */ 25 | export function newid() { 26 | return Date.now().toString(36) + Math.random().toString(36).substring(2); 27 | } 28 | 29 | /** 30 | * Test conditional flow 31 | * @param dialogue 32 | * @param variables 33 | */ 34 | export function testConditionalFlow(dialogue: Struct, req: Request, callback: TestConditionalCallback) { 35 | return testConditionalType(Types.ConditionalFlow, dialogue, req, callback); 36 | } 37 | 38 | /** 39 | * Test conditional reply 40 | * @param dialogue 41 | * @param req 42 | * @param callback 43 | */ 44 | export function testConditionalReply(dialogue: Struct, req: Request, callback: TestConditionalCallback) { 45 | return testConditionalType(Types.ConditionalReply, dialogue, req, callback); 46 | } 47 | 48 | /** 49 | * Test conditional dialogues given type 50 | * @param type 51 | * @param dialogue 52 | * @param req 53 | * @param callback stop if callback returns true 54 | */ 55 | export function testConditionalType(type: Types, dialogue: Struct, req: Request, callback: TestConditionalCallback) { 56 | if (!dialogue) { 57 | logger.info('No dialogue for test:', type); 58 | return false; 59 | } 60 | 61 | const separator = new RegExp(`\\${type}>`); 62 | const conditions = (dialogue.conditions || []).filter(x => separator.test(x)); 63 | return conditions.some(cond => { 64 | const tokens = cond.split(separator).map(x => x.trim()); 65 | if (tokens.length === 2) { 66 | const expr = tokens[0]; 67 | const value = tokens[1]; 68 | logger.debug(`Test conditional type: ${type}, botid=${req.botId}, expr=${expr}`); 69 | 70 | if (evaluate(expr, req.variables)) { 71 | return callback(value, req); 72 | } 73 | } 74 | }); 75 | } 76 | 77 | /** 78 | * Safe eval expression 79 | * @param expr str 80 | * @param context variables 81 | */ 82 | export function evaluate(expr: string, context: any) { 83 | const keys = Object.keys(context || {}); 84 | const vars = Object.assign({}, ...keys.map(x => ({ 85 | [x.startsWith('$') ? x : `$${x}`]: context[x], 86 | }))); 87 | 88 | try { 89 | logger.debug(`Evaluate test: expr=${expr}, %s`, vars); 90 | // const expression = createExpression(expr); 91 | const vTestResult = jexl.evalSync(expr, vars); 92 | logger.debug(`Evaluate test: done expr=${expr} => ${vTestResult}`); 93 | return vTestResult; 94 | } catch (err) { 95 | const { message } = err as Error; 96 | const detail = message || JSON.stringify(err); 97 | logger.error(`Error while eval expression: expr=${expr} =>`, { detail }); 98 | return undefined; 99 | } 100 | 101 | } 102 | 103 | /** 104 | * Call http service 105 | * @param command 106 | * @param req 107 | */ 108 | export function callHttpService(command: Struct, req: Request) { 109 | const contexts = req.contexts; 110 | const vIsGetMethod = /^get$/i.test(command.options[0]); 111 | const url = interpolate(command.options[1], contexts); 112 | const body = vIsGetMethod ? undefined : contexts; 113 | const method = command.options[0] as Method; 114 | const headers = command.body 115 | .filter(x => x.split(REGEX_HEADER_SEPARATOR).length === 2) 116 | .map(x => x.split(REGEX_HEADER_SEPARATOR).map(kv => kv.trim())); 117 | 118 | logger.info(`Send command request @${command.name}: ${method} ${url}${(method === 'POST' && body) ? ', body=' + JSON.stringify(body) : ''}`); 119 | 120 | return axios 121 | .request({ url, headers, method, data: body }) 122 | .then(res => { 123 | logger.debug(`Send command request @${command.name}: Done, Response=`, JSON.stringify(res.data)); 124 | return res.data; 125 | }) 126 | .catch(err => { 127 | logger.error('Can not send request:', url, method, body, headers, err); 128 | return Promise.reject(err); 129 | }); 130 | } 131 | 132 | /** 133 | * Download botscript data. 134 | * @param url 135 | */ 136 | export async function downloadScripts(url: string): Promise { 137 | logger.info('Starting download', url); 138 | // download data file. 139 | const vResult = await axios.get(url); 140 | // test data content type. 141 | const vContentType = vResult.headers['content-type']; 142 | if (/^text\/plain/.test(vContentType as string)) { 143 | const vTextData = await vResult.data; 144 | return [vTextData]; 145 | } else if (/^application\/json/.test(vContentType as string)) { 146 | const vListData = await vResult.data; // require array response. 147 | return vListData; 148 | } else { 149 | throw new Error('Data format unsupported!'); 150 | } 151 | } 152 | 153 | /** 154 | * Re-export other utils. 155 | */ 156 | export { 157 | clean, 158 | axios, 159 | }; 160 | -------------------------------------------------------------------------------- /test/dialogue/flow.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { BotScript, Request } from '../../src/engine'; 3 | 4 | describe('Dialogue: flow', () => { 5 | 6 | describe('basic flows', () => { 7 | const bot = new BotScript(); 8 | let flowsRequest = new Request(); 9 | 10 | bot.parse(` 11 | ~ age 12 | - How old are you? 13 | + I am #{age} 14 | + #{age} 15 | 16 | ~ email 17 | - What is your email 18 | + My email is *{email} 19 | 20 | + my name is *{name} 21 | + *{name} is my name 22 | ~ age 23 | ~ email 24 | - Hello $name, you are $flows.age and email $flows.email! 25 | `); 26 | 27 | it('bot should ask human age', async () => { 28 | const req = flowsRequest.enter('My name is Vu'); 29 | flowsRequest = await bot.handleAsync(req); 30 | assert.isTrue(flowsRequest.isFlowing, 'enter dialogue flows!'); 31 | assert.match(flowsRequest.speechResponse, /how old are you/i, 'bot ask human\'s age'); 32 | }); 33 | 34 | it('bot should prompt again', async () => { 35 | const req = flowsRequest.enter('something'); 36 | flowsRequest = await bot.handleAsync(req); 37 | assert.isTrue(flowsRequest.isFlowing, 'still in dialogue flows!'); 38 | assert.match(flowsRequest.speechResponse, /how old are you/i, 'prompt one again'); 39 | }); 40 | 41 | it('bot should ask human email', async () => { 42 | const req = flowsRequest.enter('20'); 43 | flowsRequest = await bot.handleAsync(req); 44 | const {name, age} = flowsRequest.contexts; // request contexts 45 | assert.isTrue(flowsRequest.isFlowing, 'still in dialogue flows!'); 46 | assert.equal(name, 'Vu', 'human name'); 47 | assert.equal(age, '20', 'human age'); 48 | assert.equal(flowsRequest.variables.name, 'Vu', 'name as a variable context'); 49 | assert.equal(flowsRequest.$flows.age, '20', 'age as a flow context'); 50 | assert.match(flowsRequest.speechResponse, /What is your email/i, 'bot send a next question'); 51 | }); 52 | 53 | it('bot should respond a greet with human name, age and email', async () => { 54 | const req = flowsRequest.enter('my email is vunb@example.com'); 55 | flowsRequest = await bot.handleAsync(req); 56 | assert.isFalse(flowsRequest.isFlowing, 'exits dialogue flows!'); 57 | assert.equal(flowsRequest.variables.name, 'Vu', 'human name'); 58 | // variable: v1.x (not clean after flow is resolved) 59 | // assert.equal(flowsRequest.variables.age, '20', 'human age'); 60 | // assert.equal(flowsRequest.variables.email, 'vunb@example.com', 'human email'); 61 | // scope: $flow from v1.7.x 62 | assert.equal(flowsRequest.$flows.age, '20', 'human age'); 63 | assert.equal(flowsRequest.$flows.email, 'vunb@example.com', 'human email'); 64 | assert.match(flowsRequest.speechResponse, /hello/i, 'bot send a greeting'); 65 | }); 66 | }); 67 | 68 | describe('Context flows', () => { 69 | 70 | const bot = new BotScript(); 71 | let flowsRequest = new Request(); 72 | 73 | bot.parse(` 74 | ~ ask_age 75 | - How old are you? 76 | + I am #{age} 77 | + #{age} 78 | 79 | ~ ask_email 80 | - What is your email 81 | + My email is *{email} 82 | 83 | ~ ask_name 84 | - Hello, what is your name? 85 | + My name is *{name} 86 | 87 | + hello 88 | ~ ask_name 89 | ~ ask_age 90 | ~ ask_email 91 | - Hello $flows.name, you are $flows.age and email $flows.email! 92 | `); 93 | 94 | it('bot should ask human name', async () => { 95 | const req = flowsRequest.enter('hello'); 96 | flowsRequest = await bot.handleAsync(req); 97 | assert.isTrue(flowsRequest.isFlowing, 'enter dialogue flows!'); 98 | assert.match(flowsRequest.speechResponse, /Hello, what is your name/i, 'bot ask human\'s age'); 99 | }); 100 | 101 | it('bot should prompt age', async () => { 102 | const req = flowsRequest.enter('My name is Vu'); 103 | flowsRequest = await bot.handleAsync(req); 104 | assert.isTrue(flowsRequest.isFlowing, 'still in dialogue flows!'); 105 | assert.match(flowsRequest.speechResponse, /how old are you/i, 'prompt one again'); 106 | }); 107 | 108 | it('bot should ask human email', async () => { 109 | const req = flowsRequest.enter('20'); 110 | flowsRequest = await bot.handleAsync(req); 111 | assert.isTrue(flowsRequest.isFlowing, 'still in dialogue flows!'); 112 | assert.equal(flowsRequest.contexts.name, 'Vu', 'human name'); 113 | assert.equal(flowsRequest.contexts.age, '20', 'human age'); 114 | assert.match(flowsRequest.speechResponse, /What is your email/i, 'bot send a next question'); 115 | }); 116 | 117 | it('bot should respond a greet with human name, age and email', async () => { 118 | const req = flowsRequest.enter('my email is vunb@example.com'); 119 | flowsRequest = await bot.handleAsync(req); 120 | assert.isFalse(flowsRequest.isFlowing, 'exit dialogue flows!'); 121 | // variable: v1.x (not clean after flow is resolved) 122 | // assert.equal(flowsRequest.variables.name, 'Vu', 'human name'); 123 | // scope: $flow v1.7+ 124 | assert.equal(flowsRequest.$flows.age, '20', 'human age'); 125 | assert.equal(flowsRequest.$flows.email, 'vunb@example.com', 'human email'); 126 | assert.equal(flowsRequest.speechResponse, 'Hello Vu, you are 20 and email vunb@example.com!', 'bot reply a greeting'); 127 | }); 128 | }); 129 | 130 | }); 131 | -------------------------------------------------------------------------------- /src/engine/struct.ts: -------------------------------------------------------------------------------- 1 | import { IMapValue } from '../interfaces/map-value'; 2 | import { newid } from '../lib/utils'; 3 | 4 | /** 5 | * Struct types 6 | */ 7 | export const TYPES: IMapValue = ({ 8 | '!': 'definition', 9 | '+': 'dialogue', 10 | '-': 'response', 11 | '@': 'command', 12 | '?': 'question', 13 | '~': 'flows', 14 | '#': 'comment', 15 | '*': 'condition', 16 | '>': 'plugin', 17 | '/': 'directive', 18 | }); 19 | 20 | /** 21 | * Get type declaration 22 | * @param script 23 | */ 24 | function getScriptType(script: string) { 25 | return TYPES[script.charAt(0)]; 26 | } 27 | 28 | /** 29 | * Get body declartion without remove tokens 30 | * @param script 31 | */ 32 | function getScriptBody(script: string): string[] { 33 | const type = script.charAt(0); 34 | return script.split('\n').map(x => x.trim()).filter(x => !x.startsWith(type)); 35 | } 36 | 37 | /** 38 | * Get head declartion 39 | * @param script 40 | */ 41 | function getScriptHead(script: string): string[] { 42 | const type = script.charAt(0); 43 | return script.split('\n') 44 | .map(x => x.trim()) 45 | // get all types declaration 46 | .filter(x => x.startsWith(type)) 47 | .map(x => x.substring(1).trim()); 48 | } 49 | 50 | /** 51 | * Script data structure 52 | */ 53 | export class Struct { 54 | id: string; 55 | type: string; 56 | content: string; 57 | name: string; 58 | head: string[]; 59 | body: string[]; 60 | flows: string[]; 61 | replies: string[]; 62 | triggers: string[]; 63 | conditions: string[]; 64 | options: string[]; 65 | value?: any; 66 | 67 | /** 68 | * Init script struct and parse components 69 | * @param content 70 | */ 71 | constructor(content: string) { 72 | this.id = newid(); 73 | this.type = getScriptType(content); 74 | this.head = getScriptHead(content); 75 | this.body = getScriptBody(content); 76 | 77 | // extract default name 78 | this.content = content; 79 | this.name = this.head.find(() => true) || ''; 80 | this.flows = []; 81 | this.replies = []; 82 | this.triggers = []; 83 | this.conditions = []; 84 | this.options = []; 85 | } 86 | 87 | /** 88 | * Normalize script content to block of structs 89 | * @param content 90 | */ 91 | static normalize(content: string): string[] { 92 | const vContent = content 93 | // convert CRLF into LF 94 | .replace(/\r\n/g, '\n') 95 | // remove spacing 96 | .replace(/\n +/g, '\n') 97 | // remove comments 98 | .replace(/^#.*$\n/igm, '') 99 | // remove inline comment 100 | .replace(/# .*$\n/igm, '') 101 | // separate definition struct (normalize) 102 | .replace(/^!/gm, '\n!') 103 | // concat multiple lines (normalize) 104 | .replace(/\n\^/gm, ' ') 105 | // normalize javascript code block 106 | .replace(/```js([^`]*)```/gm, (match) => { 107 | const vBlockNormalize = match.replace(/\n+/g, '\n'); 108 | return vBlockNormalize; 109 | }) 110 | .replace(/~~~js([^~]*)~~~/gm, (match) => { 111 | const vBlockNormalize = match.replace(/\n+/g, '\n'); 112 | return vBlockNormalize; 113 | }) 114 | // remove spaces 115 | .trim(); 116 | 117 | const scripts = vContent 118 | // split structure by linebreaks 119 | .split(/\n{2,}/) 120 | // remove empty lines 121 | .filter(script => script) 122 | // trim each of them 123 | .map(script => script.trim()); 124 | return scripts; 125 | } 126 | 127 | /** 128 | * Parse data to script structure 129 | * @param data 130 | */ 131 | static parse(data: string) { 132 | const struct = new Struct(data); 133 | 134 | // valuable data struct 135 | switch (struct.type) { 136 | case TYPES['!']: // definition 137 | if (struct.body.length === 0) { 138 | // tslint:disable-next-line: no-shadowed-variable 139 | const tokens = struct.head[0].split(' '); 140 | struct.value = tokens.pop() || ''; 141 | struct.name = tokens.pop() || ''; 142 | struct.options = [struct.value]; 143 | } else { 144 | struct.options = struct.body.map(x => x.replace(/^-\s*/, '')); 145 | if (struct.options.length > 1) { 146 | struct.value = struct.options; 147 | } else { 148 | struct.value = struct.options.find(x => true); 149 | } 150 | } 151 | break; 152 | case TYPES['+']: // dialogue 153 | struct.triggers = struct.head; 154 | struct.replies = struct.body.filter(x => x.startsWith('-')).map(x => x.replace(/^-\s*/, '')); 155 | struct.flows = struct.body.filter(x => x.startsWith('~')).map(x => x.replace(/^~\s*/, '')); 156 | struct.conditions = struct.body.filter(x => x.startsWith('*')).map(x => x.replace(/^\*\s*/, '')); 157 | break; 158 | case TYPES['~']: // flows 159 | struct.triggers = struct.body.filter(x => x.startsWith('+')).map(x => x.replace(/^\+\s*/, '')); 160 | struct.replies = struct.body.filter(x => x.startsWith('-')).map(x => x.replace(/^-\s*/, '')); 161 | struct.flows = struct.body.filter(x => x.startsWith('~')).map(x => x.replace(/^~\s*/, '')); 162 | struct.conditions = struct.body.filter(x => x.startsWith('*')).map(x => x.replace(/^\*\s*/, '')); 163 | break; 164 | case TYPES['@']: // command: SERVICE_NAME [GET|POST] ENDPOINT 165 | const tokens = struct.head[0].split(' '); 166 | if (tokens.length === 2) { 167 | struct.name = tokens[0]; 168 | struct.options = ['GET', tokens[1]]; 169 | } else if (tokens.length === 3) { 170 | const method = tokens[1]; 171 | struct.name = tokens[0]; 172 | struct.options = [method, tokens[2]]; 173 | } else { 174 | // TODO: support new command definition 175 | /** 176 | * @ command_name 177 | * - url: POST https://command-service.url/api/endpoint 178 | * - header: 1 179 | * - header: 2 180 | */ 181 | throw new Error('invalid command'); 182 | } 183 | break; 184 | case TYPES['>']: // plugins 185 | struct.conditions = struct.body.filter(x => x.startsWith('*')).map(x => x.replace(/^\*\s*/, '')); 186 | break; 187 | case TYPES['/']: // directives 188 | const sepIndex = struct.head[0].indexOf(':'); 189 | const name = struct.head[0].replace(/\s+/g, ''); 190 | struct.name = name; 191 | struct.options = struct.body.map(x => x.replace(/^-\s*/, '')); 192 | struct.value = struct.body.join(' '); 193 | // support directive /nlu: custom_command 194 | if (struct.body.length === 0) { 195 | struct.value = struct.head[0].substr(sepIndex + 1).trim(); 196 | struct.options = [struct.value]; 197 | } 198 | break; 199 | 200 | } 201 | return struct; 202 | } 203 | 204 | toString() { 205 | return `${this.type}: ${this.options.join(',')}`; 206 | } 207 | 208 | } 209 | -------------------------------------------------------------------------------- /src/engine/pattern.ts: -------------------------------------------------------------------------------- 1 | import XRegExp from 'xregexp'; 2 | import { Struct } from './struct'; 3 | import { Request } from './request'; 4 | import { Context } from './context'; 5 | import { IActivator } from '../interfaces/activator'; 6 | import { Logger } from '../lib/logger'; 7 | import { IMapActivator } from '../interfaces/map-activator'; 8 | import { evaluate } from '../lib/utils'; 9 | import { IMapValue } from '../interfaces/map-value'; 10 | import { IReply } from '../interfaces/reply'; 11 | import { Trigger } from './trigger'; 12 | 13 | const logger = new Logger('Pattern'); 14 | 15 | const PATTERN_INTERPOLATIONS = [ 16 | { 17 | // escape characters '.' and '?' 18 | search: /[.?]/g, 19 | replaceWith: '\\$&', 20 | }, 21 | { 22 | // '#{varName}' => '(? \d[\d\,\.\s]* )' 23 | search: /#\{([a-z][\w_]*)\}/g, 24 | replaceWith: '(?<$1>\\d[\\d\\,\\.\\s]*)', 25 | }, 26 | { 27 | // '${varName}' => '(? [a-z]+ )' 28 | search: /\$\{([a-z][\w_]*)\}/g, 29 | replaceWith: '(?<$1>[a-z]+)', 30 | }, 31 | { 32 | // '*{varName}' => '(? .* )' 33 | search: /\*\{([a-z][\w_]*)\}/g, 34 | replaceWith: '(?<$1>.*)', 35 | }, 36 | { 37 | // '$varName' => '(? [a-z]+ )' 38 | search: /\$([a-z][\w_]*)/g, 39 | replaceWith: '(?<$1>[a-z]+)', 40 | }, 41 | { 42 | // '#' => '(\d+)' 43 | search: /(^|[\s,;—])#(?!\w)/g, 44 | replaceWith: '$1(\\d+)', 45 | }, 46 | { 47 | // '*' => '(.*)' 48 | search: /(^|[\s,;—])\*(?!\w)/g, 49 | replaceWith: '$1(.*)', 50 | }, 51 | { 52 | // '[definition_name]' => '(?:item_1|item_2)' 53 | search: /!*\[(\w+)\]/g, 54 | replaceWith: (sub: string, name: string, def: Map) => { 55 | const struct = def.get(name.toLowerCase()) as Struct; 56 | return !struct ? name : `(${struct.options.join('|')})`; 57 | }, 58 | }, 59 | ]; 60 | 61 | /** 62 | * Find & replace options pattern 63 | */ 64 | const findDefinitionReplacer = ( 65 | replacement: string, 66 | search: RegExp, 67 | replaceWith: (sub: string, name: string, def: Map) => string, 68 | definitions: Map, 69 | ): string => { 70 | // Check if the list contains reference to another list 71 | while (replacement.match(search) !== null) { 72 | (replacement.match(search) as RegExpMatchArray).map(rl => { 73 | const referencingListName = rl.slice(1, rl.length - 1); 74 | const referencingListPattern = replaceWith(rl, referencingListName, definitions); 75 | const referencingListReg = new RegExp(`\\[${referencingListName}\\]`, 'g'); 76 | replacement = replacement.replace(referencingListReg, referencingListPattern.slice(1, referencingListPattern.length - 1)); 77 | }); 78 | } 79 | 80 | return replacement; 81 | }; 82 | 83 | /** 84 | * Format pattern before transform 85 | * @param pattern 86 | * @param context 87 | * @returns 88 | */ 89 | export function format(pattern: string, context: Context): Trigger { 90 | const trigger = new Trigger(pattern); 91 | // is it already a string pattern? 92 | if (/^\/.+\/$/m.test(pattern)) { 93 | trigger.source = (pattern.match(/^\/(.+)\/$/m) as RegExpMatchArray)[1]; 94 | return trigger; 95 | } else { 96 | // definition poplulation 97 | const definitions = context.definitions; 98 | // basic pattern 99 | PATTERN_INTERPOLATIONS.forEach(p => { 100 | const { search, replaceWith } = p; 101 | if (typeof replaceWith === 'string') { 102 | trigger.source = trigger.source.replace(search, replaceWith); 103 | } else { 104 | trigger.source = trigger.source.replace(search, 105 | (substr, name) => { 106 | const replacement = replaceWith(substr, name, definitions); 107 | return findDefinitionReplacer(replacement, search, replaceWith, definitions); 108 | }, 109 | ); 110 | } 111 | }); 112 | } 113 | 114 | return trigger; 115 | } 116 | 117 | /** 118 | * Transform & interpolate pattern 119 | * @param pattern dialogue trigger 120 | * @param context bot data context 121 | * @param notEqual negative flag 122 | */ 123 | export function transform(pattern: string, request: Request, context: Context, notEqual: boolean) { 124 | 125 | // test custom patterns in triggers 126 | for (const [name, value] of context.patterns) { 127 | if (value.match.test(pattern)) { 128 | logger.debug('Pattern match: ', name, pattern, value.match.source); 129 | return value.func(pattern, request); 130 | } 131 | } 132 | 133 | // is it already a string pattern? 134 | if (/^\/.+\/$/m.test(pattern)) { 135 | pattern = (pattern.match(/^\/(.+)\/$/m) as RegExpMatchArray)[1]; 136 | return XRegExp(pattern); 137 | } 138 | 139 | // definition poplulation 140 | const definitions = context.definitions; 141 | // basic pattern 142 | PATTERN_INTERPOLATIONS.forEach(p => { 143 | const { search, replaceWith } = p; 144 | if (typeof replaceWith === 'string') { 145 | pattern = pattern.replace(search, replaceWith); 146 | } else { 147 | pattern = pattern.replace(search, 148 | (substr, name) => { 149 | const replacement = replaceWith(substr, name, definitions); 150 | return findDefinitionReplacer(replacement, search, replaceWith, definitions); 151 | }, 152 | ); 153 | } 154 | }); 155 | 156 | return notEqual 157 | ? XRegExp(`^((?!^${pattern}$).)+(?!\\w)`, 'ig') 158 | : XRegExp(`(?:^|[\\s,;—])${pattern}(?!\\w)`, 'ig'); 159 | } 160 | 161 | /** 162 | * Extract and captures named variables 163 | * @param input 164 | * @param pattern 165 | */ 166 | export function execPattern(input: string, pattern: RegExp | IActivator) 167 | : IMapValue { 168 | const result = pattern instanceof RegExp ? XRegExp.exec(input, pattern) : pattern.exec(input); 169 | 170 | // no captures! 171 | if (!result) { return {}; } 172 | const keys = Object.keys(result).filter(key => !['index', 'input', 'groups'].includes(key)); 173 | const captures = keys.map(key => ({ [key.match(/^\d+$/) ? `$${parseInt(key)}` : key]: result[key as any] })).splice(1); 174 | return captures.reduce((a, b) => Object.assign(a, b), {}); 175 | } 176 | 177 | /** 178 | * Get trigger activators 179 | * @param dialog random or dialogue flow 180 | * @param notEqual negative flag 181 | */ 182 | export function getActivators(dialog: Struct, ctx: Context, req: Request, notEqual = false) { 183 | return dialog.triggers.map(x => transform(x, req, ctx, notEqual)); 184 | } 185 | 186 | /** 187 | * Get conditional activation 188 | * @param dialog a dialogue 189 | */ 190 | export function getActivationConditions(dialog: Struct) { 191 | // exclude conditional reply 192 | return dialog.conditions.filter(x => !/>/.test(x)); 193 | } 194 | 195 | /** 196 | * - Get sorted trigger activators 197 | * - Explore request message 198 | * - Extract matched pattern 199 | */ 200 | export function getReplyDialogue(ctx: Context, req: Request) 201 | : IReply { 202 | // transform activators and sort 203 | let vCaptures: IMapValue = {}; 204 | let vDialogue: Struct | undefined; 205 | 206 | // get sorted activators. 207 | const vActivators: IMapActivator[] = ctx.triggers 208 | // transform trigger into activator 209 | .map(x => { 210 | const activator = transform(x.source, req, ctx, false); 211 | const item: IMapActivator = { 212 | id: x.dialog, 213 | trigger: x.source, 214 | pattern: activator 215 | } 216 | return item; 217 | }) 218 | // filter pattern candidates 219 | .filter(x => { 220 | logger.debug(`Test candidate: [${req.message}][${x.pattern.source}]`); 221 | return x.pattern.test(req.message); 222 | }); 223 | // compare & matching conditional dialog 224 | vActivators 225 | // map info 226 | .some(x => { 227 | const captures = execPattern(req.message, x.pattern); 228 | const knowledges = { ...req.variables, ...captures, $previous: req.previous, $input: req.message }; 229 | logger.debug(`Evaluate dialogue: ${x.pattern.source} => captures:`, captures); 230 | 231 | // Test conditional activation 232 | // - A conditions begins with star symbol: * 233 | // - Syntax: * expression 234 | const dialog = ctx.getDialogue(x.id) as Struct; 235 | const conditions = getActivationConditions(dialog); 236 | if (conditions.length > 0) { 237 | for (const cond of conditions) { 238 | const expr = cond.replace(/^[*]/, ''); 239 | const vTestResult = evaluate(expr, knowledges); 240 | if (!vTestResult) { 241 | return false; 242 | } 243 | } 244 | } 245 | // summary knowledges 246 | vDialogue = dialog; 247 | vCaptures = captures; 248 | return true; 249 | }); 250 | return { 251 | dialog: vDialogue, 252 | captures: vCaptures, 253 | candidate: vActivators.length, 254 | }; 255 | } 256 | -------------------------------------------------------------------------------- /src/engine/machine.ts: -------------------------------------------------------------------------------- 1 | import { EventObject, StateMachine, createMachine, interpret } from 'xstate'; 2 | import { Context } from './context'; 3 | import { Logger } from '../lib/logger'; 4 | import * as utils from '../lib/utils'; 5 | import { Request } from './request'; 6 | import { getActivators, execPattern, getActivationConditions, getReplyDialogue } from './pattern'; 7 | import { Struct } from './struct'; 8 | import { Response } from './response'; 9 | 10 | export class BotMachine { 11 | 12 | private machine: StateMachine<{ ctx: Context, req: Request, res: Response }, any, EventObject>; 13 | private logger: Logger; 14 | 15 | constructor() { 16 | this.logger = new Logger('Machine'); 17 | this.machine = createMachine( 18 | { 19 | id: 'botscript', 20 | initial: 'pending', 21 | predictableActionArguments: true, 22 | states: { 23 | pending: { 24 | on: { 25 | DIGEST: { 26 | target: 'digest', 27 | actions: ['onDigest'], 28 | }, 29 | }, 30 | }, 31 | digest: { 32 | always: [ 33 | { 34 | target: 'dialogue', 35 | cond: 'isForward', 36 | }, 37 | { 38 | target: 'dialogue', 39 | cond: 'isDialogue', 40 | }, 41 | { 42 | target: 'flow', 43 | cond: 'isFlow', 44 | }, 45 | { 46 | target: 'nomatch', 47 | cond: (ctx, event) => true, 48 | }, 49 | ], 50 | }, 51 | dialogue: { 52 | always: [ 53 | { 54 | target: 'output', 55 | cond: (context, event) => { 56 | const { req } = context; 57 | if (req.missingFlows.length === 0) { 58 | req.isFlowing = false; 59 | this.logger.debug('Dialogue state is resolved then now forward to output!'); 60 | return true; 61 | } else { 62 | this.logger.debug('Dialogue flows remaining: ', req.missingFlows.length); 63 | return false; 64 | } 65 | }, 66 | }, 67 | { 68 | target: 'flow', 69 | cond: (context, event) => { 70 | const { req } = context; 71 | req.isFlowing = true; // init status 72 | 73 | return true; 74 | }, 75 | }, 76 | 77 | ], 78 | }, 79 | flow: { 80 | always: [ 81 | { 82 | target: 'output', 83 | cond: (context, event) => { 84 | // popolate flows from currentFlow and assign to request 85 | const { req, ctx } = context; 86 | 87 | if (req.currentFlowIsResolved) { 88 | // remove current flow & get next 89 | if (req.currentFlow) { 90 | this.logger.debug('Remove current flow: ', req.currentFlow); 91 | req.resolvedFlows.push(req.currentFlow); 92 | } 93 | req.missingFlows = req.missingFlows.filter(x => x !== req.currentFlow); 94 | req.currentFlow = req.missingFlows.find(() => true) as string; 95 | req.isFlowing = req.missingFlows.some(() => true); 96 | req.currentFlowIsResolved = false; // reset state 97 | this.logger.debug('Next flow: ', req.currentFlow); 98 | } else if (!req.currentFlow) { 99 | // get next flow 100 | req.currentFlow = req.missingFlows.find(() => true) as string; 101 | req.currentFlowIsResolved = false; 102 | this.logger.debug('Start new dialogue flow: ', req.currentFlow); 103 | } else { 104 | this.logger.info('Prompt or send reply again!'); 105 | } 106 | 107 | this.logger.info('Check & Update nested flows!'); 108 | if (ctx.flows.has(req.currentFlow)) { 109 | const currentFlow = ctx.flows.get(req.currentFlow) as Struct; 110 | const setFlows = new Set(req.flows); 111 | // update nested flows 112 | currentFlow.flows.forEach(x => setFlows.add(x)); 113 | // missing optional flows (conditional flows) 114 | for (const flow of req.missingFlows) { 115 | setFlows.add(flow); 116 | } 117 | req.flows = Array.from(setFlows); 118 | } 119 | 120 | this.logger.info(`Dialogue is flowing: ${req.isFlowing}, current: ${req.currentFlow || '[none]'}`); 121 | return true; 122 | }, 123 | }, 124 | ], 125 | }, 126 | nomatch: { 127 | always: [ 128 | { 129 | target: 'output', 130 | cond: (context, event) => { 131 | context.req.speechResponse = 'NO REPLY!'; 132 | context.req.isNotResponse = true; 133 | return true; 134 | }, 135 | }, 136 | ], 137 | }, 138 | output: { 139 | entry: [ 140 | 'notifyDone', 141 | ], 142 | type: 'final', 143 | }, 144 | }, 145 | }, 146 | { 147 | guards: { 148 | isForward: (context, event) => { 149 | const { req, ctx } = context; 150 | if (req.isForward) { 151 | const dialog = ctx.dialogues.get(req.currentDialogue) as Struct; 152 | this.explore({ dialog, ctx, req }); 153 | 154 | this.logger.debug('Redirect to: ', dialog.name, req.variables); 155 | req.currentDialogue = dialog.name; 156 | req.originalDialogue = dialog.name; 157 | req.flows = dialog.flows; 158 | req.missingFlows = dialog.flows; 159 | return true; 160 | } 161 | return false; 162 | }, 163 | isDialogue: (context, event) => { 164 | this.logger.info('Find dialogue candidate ...'); 165 | const { req, res, ctx } = context; 166 | const reply = getReplyDialogue(ctx, req); 167 | // assign reply 168 | res.reply = reply; 169 | 170 | if (reply.dialog) { 171 | const dialog = reply.dialog; 172 | req.currentDialogue = dialog.name; 173 | req.currentFlowIsResolved = true; 174 | if (!req.isFlowing) { 175 | // process purpose bot 176 | this.logger.debug('Found a dialogue candidate: ', dialog.name, req.variables); 177 | req.originalDialogue = dialog.name; 178 | req.flows = dialog.flows; 179 | req.missingFlows = dialog.flows; 180 | Object.assign(req.variables, reply.captures); 181 | return true; 182 | } else { 183 | this.logger.info(`Dialogue is flowing: [current=${req.currentDialogue},original=${req.originalDialogue}]`); 184 | // assign session captured flows 185 | Object.assign(req.$flows, reply.captures, { [req.currentFlow]: reply.captures.$1 }); 186 | } 187 | } 188 | return false; 189 | }, 190 | isFlow: ({ req, ctx }) => { 191 | 192 | if (req.isFlowing && ctx.flows.has(req.currentFlow)) { 193 | const flow = ctx.flows.get(req.currentFlow) as Struct; 194 | 195 | this.logger.debug('Dialogue request is in the flow: ', req.currentFlow); 196 | // Explore and capture variables 197 | const isMatch = this.explore({ dialog: flow, ctx, req }); 198 | if (isMatch) { 199 | const vCurrentFlowValue = req.$flows[req.currentFlow]; 200 | this.logger.debug(`Captured a dialogue flow: ${req.currentFlow} => ${vCurrentFlowValue}`); 201 | } else { 202 | this.logger.debug('Dialogue flow is not captured!'); 203 | } 204 | } 205 | return req.isFlowing; 206 | }, 207 | }, 208 | actions: { 209 | onDigest: ({ req, res, ctx }) => { 210 | this.logger.debug('onDigest: ', req.message); 211 | }, 212 | notifyDone: (context, event) => { 213 | this.logger.info('Bot machine done!'); 214 | }, 215 | }, 216 | }, 217 | ); 218 | } 219 | 220 | /** 221 | * Resolve dialogue flows with current context 222 | * @param req - human context 223 | * @param ctx - bot context 224 | */ 225 | resolve(req: Request, ctx: Context) { 226 | // reset speech response 227 | // & interpret new state machine 228 | req.prompt = []; 229 | req.speechResponse = ''; 230 | req.isNotResponse = false; 231 | if (!req.isForward) { 232 | // clear first-state conditions 233 | req.currentDialogue = ''; 234 | } 235 | this.logger.info(`Resolve: ${req.message}, isFlowing: ${req.isFlowing}`); 236 | 237 | // TODO: Explore dialogues first to define type which is forward, flow or first-dialogue. 238 | // TODO: Explore should support async task 239 | const res = new Response(); 240 | const botMachine = this.machine.withContext({ ctx, req, res }); 241 | const botService = interpret(botMachine) 242 | .onTransition(state => { 243 | this.logger.info('Enter state: ', state.value); 244 | }) 245 | .start(); 246 | botService.send('DIGEST'); 247 | this.logger.info('Resolved dialogue:', req.currentDialogue); 248 | return req; 249 | } 250 | 251 | /** 252 | * Explore dialogue triggers 253 | * @param obj dialog, ctx, req 254 | */ 255 | private explore({ dialog, ctx, req }: { dialog: Struct, ctx: Context, req: Request }) { 256 | try { 257 | const result = getActivators(dialog, ctx, req) 258 | .filter((x) => x.test(req.message)) 259 | .some(pattern => { 260 | // extract message information 261 | const captures = execPattern(req.message, pattern); 262 | const knowledges = { ...req.variables, ...captures, $previous: req.previous, $input: req.message }; 263 | this.logger.debug(`Explore dialogue for evaluation: ${pattern.source} => captures:`, captures); 264 | 265 | // Test conditional activation 266 | // - A conditions begins with star symbol: * 267 | // - Syntax: * expression 268 | const conditions = getActivationConditions(dialog); 269 | if (conditions.length > 0) { 270 | for (const cond of conditions) { 271 | const expr = cond.replace(/^[*]/, ''); 272 | const vTestResult = utils.evaluate(expr, knowledges); 273 | if (!vTestResult) { 274 | return false; 275 | } 276 | } 277 | } 278 | 279 | // update dialogue response 280 | req.currentDialogue = dialog.name; 281 | req.currentFlowIsResolved = true; 282 | if (req.isFlowing) { 283 | // assign session captured flows 284 | Object.assign(req.$flows, captures, { [req.currentFlow]: captures.$1 }); 285 | } else { 286 | Object.assign(req.variables, captures); 287 | } 288 | 289 | return true; 290 | }); 291 | 292 | // log result 293 | this.logger.debug(`Test dialogue candidate: ${dialog.name} =>`, result); 294 | 295 | return result; 296 | } catch (error) { 297 | this.logger.error('Cannot explore Dialogue!', error); 298 | throw error; 299 | } 300 | } 301 | 302 | } 303 | -------------------------------------------------------------------------------- /src/engine/context.ts: -------------------------------------------------------------------------------- 1 | import { interpolate } from '../lib/template'; 2 | import { Request } from './request'; 3 | import { Struct } from './struct'; 4 | import { IActivator } from '../interfaces/activator'; 5 | import { Logger } from '../lib/logger'; 6 | import { Trigger } from './trigger'; 7 | import { wrapCode, wrapCodeBrowser } from '../plugins/built-in'; 8 | import * as utils from '../lib/utils'; 9 | import { BehaviorSubject, filter } from 'rxjs'; 10 | import { PluginCallback } from '../interfaces/types'; 11 | 12 | /** 13 | * Bot context 14 | */ 15 | export class Context { 16 | 17 | // dialogue structures 18 | definitions: Map; 19 | dialogues: Map; 20 | commands: Map; 21 | flows: Map; 22 | // support custom patterns 23 | patterns: Map RegExp | IActivator }>; 24 | // plugins system 25 | plugins: Map; 26 | // directives system 27 | directives: Map; 28 | /** 29 | * id context 30 | */ 31 | idctx: string; 32 | ready: boolean; 33 | 34 | private _sorted_triggers: Trigger[]; 35 | private logger = new Logger('Context'); 36 | private $action = new BehaviorSubject<{ 37 | type: string, 38 | data?: any, 39 | }>({ type: 'init' }); 40 | 41 | constructor() { 42 | this.definitions = new Map(); 43 | this.dialogues = new Map(); 44 | this.flows = new Map(); 45 | this.commands = new Map(); 46 | this.patterns = new Map(); 47 | this.plugins = new Map(); 48 | this.directives = new Map(); 49 | this.idctx = utils.newid(); 50 | this._sorted_triggers = []; 51 | } 52 | 53 | /** 54 | * Get bot id from definition 55 | */ 56 | get id(): string { 57 | return this.definitions.has('botid') 58 | ? (this.definitions.get('botid') as Struct).value 59 | : this.idctx; 60 | } 61 | 62 | /** 63 | * Get all of context triggers 64 | */ 65 | get triggers(): Trigger[] { 66 | if (this._sorted_triggers?.length > 0) { 67 | return this._sorted_triggers; 68 | } else { 69 | this.sortTriggers(); 70 | return this._sorted_triggers; 71 | } 72 | } 73 | 74 | /** 75 | * Get struct type 76 | * @param type type 77 | */ 78 | private type(type: string): Map { 79 | switch (type) { 80 | case 'definition': 81 | return this.definitions; 82 | case 'dialogue': 83 | return this.dialogues; 84 | case 'flows': 85 | return this.flows; 86 | case 'command': 87 | return this.commands; 88 | case 'plugin': 89 | return this.plugins; 90 | case 'directive': 91 | return this.directives; 92 | default: 93 | throw new Error('Not found type: ' + type); 94 | } 95 | } 96 | 97 | /** 98 | * Add context struct 99 | * @param struct 100 | */ 101 | add(struct: Struct) { 102 | this.type(struct.type).set(struct.name, struct); 103 | this.$action.next({ type: 'add', data: struct }); 104 | return this; 105 | } 106 | 107 | addPlugin(name: string, handler: PluginCallback) { 108 | const plugin = new Struct('/plugin:' + name); 109 | plugin.name = 'plugin:' + name; 110 | plugin.value = handler; 111 | return this.add(plugin); 112 | } 113 | 114 | addDefinition(name: string, value: string) { 115 | const definition = Struct.parse(`!${name}\n-${value}`); 116 | return this.add(definition); 117 | } 118 | 119 | /** 120 | * Script structure parser 121 | * @param content 122 | */ 123 | parse(content: string) { 124 | if (!content) { 125 | throw new Error('Cannot parse script: null or empty!'); 126 | } 127 | const scripts = Struct.normalize(content); 128 | scripts.forEach(data => { 129 | const struct = Struct.parse(data); 130 | // add context struct data types. 131 | this.add(struct); 132 | }); 133 | 134 | return scripts; 135 | } 136 | 137 | emit(type: string, data: any) { 138 | this.$action.next({ type, data }); 139 | return this; 140 | } 141 | 142 | asObservable() { 143 | return this.$action.asObservable(); 144 | } 145 | 146 | asTypingObservable() { 147 | return this.asObservable().pipe(filter(x => x.type === 'typing')); 148 | } 149 | 150 | /** 151 | * Script data parse from Url. 152 | * @param url 153 | */ 154 | async parseUrl(url: string) { 155 | try { 156 | const vListData = await utils.downloadScripts(url); 157 | for (const vItem of vListData) { 158 | this.parse(vItem); 159 | } 160 | } catch (error) { 161 | const { message } = error as Error; 162 | this.logger.error(`Cannot download script: 163 | - Url: ${url} 164 | - Msg: ${message || error}`); 165 | } 166 | } 167 | 168 | /** 169 | * sort trigger 170 | */ 171 | sortTriggers(): void { 172 | const vTriggers: Trigger[] = []; 173 | this._sorted_triggers = []; 174 | Array.from(this.dialogues.values()) 175 | .forEach(x => { 176 | vTriggers.push(...x.triggers.map(t => new Trigger(t, x.name))); 177 | }); 178 | // sort & cache triggers. 179 | this._sorted_triggers = vTriggers.sort(Trigger.sorter); 180 | this.ready = true; 181 | } 182 | 183 | async init() { 184 | const logger = new Logger('Plugin'); 185 | for (const item of this.directives.keys()) { 186 | this.logger.info('Preprocess directive:', item); 187 | if (/^include/.test(item)) { 188 | const vInclude = this.directives.get(item) as Struct; 189 | for (const vLink of vInclude.options) { 190 | this.logger.info('Parse url from:', vLink); 191 | await this.parseUrl(vLink); 192 | } 193 | } else if (/^plugin/.test(item)) { 194 | const vPlugin = this.directives.get(item) as Struct; 195 | if (typeof vPlugin.value === 'function') { 196 | // built-in or type-safe code. 197 | const original = vPlugin.value as PluginCallback; 198 | vPlugin.value = async (req: Request, ctx: Context) => { 199 | logger.debug(`Execute [${vPlugin.name}]!`); 200 | const vPostProcessingCallback = await original.call(ctx, req, ctx); 201 | logger.debug(`Plugin [${vPlugin.name}] has pre-processed!`); 202 | return vPostProcessingCallback; 203 | } 204 | continue; 205 | } 206 | const vCode = wrapCode(vPlugin.value); 207 | const vCodeBrowser = wrapCodeBrowser(vPlugin.value); 208 | const vName = vPlugin.name.replace(/^plugin:/, ''); 209 | // this.this.logger.debug(`javascript code: /plugin: ${vName} => ${vCode}`); 210 | this.logger.debug(`add custom plugin & save handler in it own directive: /${vPlugin.name}`); 211 | vPlugin.value = async (req: Request, ctx: Context) => { 212 | // run in browser or node 213 | if (typeof window === 'undefined') { 214 | logger.debug(`Execute /plugin: ${vName} in node!`); 215 | const { VmRunner } = await import('../lib/vm2'); 216 | const vPreProcess = await VmRunner.run(vCode, { req, ctx, utils, logger }); 217 | const vPostProcessingCallback = await vPreProcess(); 218 | // support post-processing 219 | this.logger.debug(`Plugin [${vName}] has pre-processed!`); 220 | return vPostProcessingCallback; 221 | } else { 222 | this.logger.debug(`Execute /plugin: ${vName} in browser!`); 223 | const { VmRunner } = await import('../lib/vm'); 224 | const vPreProcess = await VmRunner.run(vCodeBrowser, { req, ctx, utils, logger }); 225 | const vPostProcessingCallback = await vPreProcess(); 226 | // support post-processing 227 | this.logger.debug(`Plugin [${vName}] has pre-processed!`); 228 | return vPostProcessingCallback; 229 | } 230 | }; 231 | } 232 | } 233 | 234 | // sort triggers 235 | this.sortTriggers(); 236 | } 237 | 238 | /** 239 | * Get dialogue by name 240 | * Notice: a flow is a dialogue 241 | * @param name 242 | */ 243 | getDialogue(name: string) { 244 | if (this.flows.has(name)) { 245 | return this.flows.get(name); 246 | } else { 247 | return this.dialogues.get(name); 248 | } 249 | } 250 | 251 | /** 252 | * Get definition interpolation 253 | * @param text 254 | */ 255 | interpolateDefinition(text: string) { 256 | this.logger.debug('interpolateDefinition:', text); 257 | return text.replace(/\[([\w-]+)\]/g, (match, defName) => { 258 | const definition = defName.toLowerCase(); 259 | if (!this.definitions.has(definition)) { 260 | // return original 261 | return match; 262 | } 263 | const list = this.definitions.get(definition) as Struct; 264 | return utils.random(list.options); 265 | }); 266 | } 267 | 268 | /** 269 | * Interpolate variables from request 270 | * @param text message response 271 | * @param req message request 272 | */ 273 | interpolateVariables(text: string, req: Request) { 274 | this.logger.debug('interpolateVariables:', text); 275 | return text 276 | // 1. object/array referencing. 277 | // matching & replacing: $var.[0].a.b (note: .[0].a.b is a path of property of an array) 278 | .replace(/\$([a-z][\w_-]*)(\.[.\w[\]]*[\w\]])/g, (match: string, variable: string, propPath: string) => { 279 | try { 280 | const data = {}; 281 | if (variable === 'flows') { // keyword: $flows 282 | const prop = propPath.replace(/^\.+/, ''); 283 | Object.assign(data, { 284 | flows: { 285 | [prop]: req.$flows[prop], 286 | }, 287 | }); 288 | } else { 289 | const vValue = req.contexts[variable]; 290 | Object.assign(data, { [variable]: vValue }); 291 | } 292 | 293 | // interpolate value from variables 294 | const template = `{{${variable + propPath}}}`; 295 | this.logger.info(`interpolate: ${template}, ${JSON.stringify(data)}`); 296 | const vResult = interpolate(template, data); 297 | return vResult; 298 | } catch (error) { 299 | this.logger.error(`Cannot interpolate variable: ${variable} ${propPath}`, error); 300 | return 'undefined'; 301 | } 302 | }) 303 | // 2. variable reference 304 | // matching & replacing: ${var}, $var, #{var}, #var 305 | // syntax: $var /format:list 306 | // shorthand: $var :list 307 | .replace(/[#$]\{?([a-zA-Z][\w_-]*)\}?(\s*[\/:][a-z:_-]+)?/g, (match, variable: string, format: string) => { 308 | const value = req.contexts[variable]; 309 | // allow multiple spaces repeat in front of a format => so we must trim() it! 310 | format = (format || '').trim(); 311 | if (format && /[/:]/.test(format.charAt(0))) { 312 | let vDirectiveName = format.substring(1); 313 | if (!/^format/.test(vDirectiveName)) { 314 | if (vDirectiveName.charAt(0) !== ':') { 315 | vDirectiveName = ':' + vDirectiveName; 316 | } 317 | // support shorthand $var /:list or $var :list 318 | vDirectiveName = 'format' + vDirectiveName; 319 | } 320 | this.logger.info('Directive /format: ' + vDirectiveName); 321 | if (this.directives.has(vDirectiveName)) { 322 | const vFormatTemplate = this.directives.get(vDirectiveName)?.value; 323 | const vResult = interpolate(vFormatTemplate, { 324 | [variable]: value, // access via name of user variable, ex: $people 325 | value, // access via name: value 326 | }); 327 | return vResult; 328 | } 329 | } 330 | return value || ''; 331 | }) 332 | // 3. number reference 333 | // matching & replacing: $123, $456 334 | .replace(/(\$\d*(?![\w\d]))/g, (match, variable) => { 335 | const value = req.contexts[variable]; 336 | return value || ''; 337 | }); 338 | } 339 | 340 | /** 341 | * Response interpolation 342 | * @param text 343 | * @param req 344 | */ 345 | interpolate(text: string, req: Request) { 346 | this.logger.debug('interpolate:', text); 347 | let output = this.interpolateDefinition(text); 348 | output = this.interpolateVariables(output, req); 349 | return output; 350 | } 351 | 352 | /** 353 | * Copy shadow data to botscript request 354 | * - Normalize human request 355 | * - Support scope variables, flows and context data 356 | * TODO: rename `newRequest()` to `createResponse()` 357 | * @param req 358 | */ 359 | newRequest(req: Request) 360 | : Request { 361 | const request = new Request(); 362 | request.enter(req.message); 363 | if (req.botId !== this.id) { 364 | // a new first-message request 365 | // or change new bot context => just reset 366 | this.logger.info('Human send the first-message request: ' + request.message); 367 | request.botId = this.id; 368 | return request; 369 | } 370 | 371 | let $flows = {}; 372 | if (req.isFlowing) { 373 | $flows = req.$flows; 374 | } 375 | 376 | // keep state value persitence in dialogue flows (scope) 377 | const { 378 | prompt, 379 | isNotResponse, 380 | isFlowing, 381 | originalDialogue, 382 | currentDialogue, 383 | currentFlow, 384 | currentFlowIsResolved, 385 | variables, 386 | entities, 387 | flows, 388 | intent, 389 | missingFlows, 390 | previous, 391 | resolvedFlows, 392 | sessionId, 393 | botId, 394 | } = req; 395 | this.logger.info('Normalize human request: isFlowing=' + isFlowing, request.message); 396 | // transfer state to new request 397 | Object.assign(request, { 398 | prompt, 399 | isNotResponse, 400 | isFlowing, 401 | originalDialogue, 402 | currentDialogue, 403 | currentFlow, 404 | currentFlowIsResolved, 405 | variables, 406 | entities, 407 | flows, 408 | intent, 409 | missingFlows, 410 | previous, 411 | resolvedFlows, 412 | sessionId, 413 | botId, 414 | $flows, 415 | }); 416 | 417 | return request; 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /src/engine/botscript.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { Context } from './context'; 3 | import { Request } from './request'; 4 | import { Struct } from './struct'; 5 | import { Logger } from '../lib/logger'; 6 | import { BotMachine } from './machine'; 7 | import { IActivator } from '../interfaces/activator'; 8 | import * as utils from '../lib/utils'; 9 | import { REGEX_COND_REPLY_TESTER, REGEX_COND_REPLY_TOKEN, REGEX_COND_LAMDA_EXPR } from '../lib/regex'; 10 | import { Types, PluginCallback } from '../interfaces/types'; 11 | import { createNextRequest } from './next'; 12 | import { PLUGINS_BUILT_IN } from '../plugins/built-in'; 13 | 14 | /** 15 | * BotScript dialogue engine 16 | */ 17 | export class BotScript extends EventEmitter { 18 | 19 | /** 20 | * Bot data context 21 | */ 22 | context: Context; 23 | 24 | /** 25 | * Bot state machine 26 | */ 27 | machine: BotMachine; 28 | 29 | /** 30 | * Bot logger 31 | */ 32 | logger: Logger; 33 | 34 | /** 35 | * Last request 36 | */ 37 | lastRequest?: Request; 38 | 39 | constructor() { 40 | super(); 41 | this.context = new Context(); 42 | this.logger = new Logger('Engine'); 43 | this.machine = new BotMachine(); 44 | this.parse(PLUGINS_BUILT_IN); 45 | 46 | // add built-in patterns (NLU) 47 | this.addPatternCapability({ 48 | name: 'nlu test', 49 | match: /^intent/, 50 | func: (pattern: string, req: Request) => { 51 | this.logger.info('NLU Preprocess: ', pattern); 52 | // return custom pattern 53 | return ({ 54 | source: pattern, 55 | test: (input) => { 56 | const vIntentName = pattern.replace(/^intent:/i, '').trim(); 57 | this.logger.info(`NLU test: ${input}, intent: ${req.intent}`); 58 | return req.intent === vIntentName; 59 | }, 60 | exec: (input) => { 61 | // entities list 62 | this.logger.info('NLU extract entities: ', input); 63 | if (!Array.isArray(req.entities)) { 64 | return []; 65 | } 66 | return req.entities.map((x: any) => x.value); 67 | }, 68 | toString: () => pattern, 69 | }); 70 | }, 71 | }); 72 | } 73 | 74 | /** 75 | * Override emitter 76 | * @param event 77 | * @param args 78 | */ 79 | emit(event: string | symbol, ...args: any[]) { 80 | const vResult = super.emit(event, ...args); 81 | this.logger.debug(`Fired event: '${event.toString()}', hasListener: (${vResult})`); 82 | super.emit('*', event, ...args); 83 | return vResult; 84 | } 85 | 86 | /** 87 | * Script structure parser 88 | * @param content 89 | */ 90 | parse(content: string) { 91 | const scripts = this.context.parse(content); 92 | // notify event parse botscript data context 93 | this.emit('parse', scripts); 94 | return this; 95 | } 96 | 97 | /** 98 | * Script data parse from Url. 99 | * @param url 100 | */ 101 | async parseUrl(url: string) { 102 | await this.context.parseUrl(url); 103 | return this; 104 | } 105 | 106 | async init() { 107 | await this.context.init(); 108 | this.logger.info('Ready!'); 109 | this.emit('ready'); 110 | return this; 111 | } 112 | 113 | /** 114 | * Add trigger pattern capability 115 | * @param options name, match, func 116 | */ 117 | addPatternCapability({ name, match, func }: { 118 | name: string, 119 | match: RegExp, 120 | func: (pattern: string, req: Request) => RegExp | IActivator, 121 | }) { 122 | this.context.patterns.set(name, { name, match, func }); 123 | return this; 124 | } 125 | 126 | /** 127 | * Async handle message request then create response back 128 | * @param req 129 | * @param ctx 130 | */ 131 | async handleAsync(req: Request, ctx?: Context) { 132 | this.logger.debug('New request: ', req.message); 133 | const context = ctx || this.context; 134 | const request = context.newRequest(req); 135 | 136 | // req.botId = context.id; 137 | // req.isForward = false; 138 | 139 | req = request; 140 | if (!context.ready) { 141 | await context.init(); 142 | } 143 | 144 | // fire plugin for pre-processing 145 | const postProcessing = await this.preProcessRequest(req, context); 146 | 147 | // fires state machine to resolve request 148 | this.machine.resolve(req, context); 149 | 150 | // Handle conditional commands, conditional reply 151 | await this.applyConditionalDialogues(req, context); 152 | this.populateReply(req, context); 153 | 154 | // post-processing 155 | await this.postProcessRequest(postProcessing, req, context); 156 | 157 | // emit reply done. 158 | this.emit('reply', req, ctx); 159 | 160 | // remember last request 161 | this.lastRequest = req; 162 | if (!this.lastRequest.isFlowing) { 163 | // TODO: Refactor and move bot reply to ./response.model 164 | this.logger.info('Clean dialogue flows as a completed task: ' + req.message); 165 | this.lastRequest.flows = []; 166 | this.lastRequest.missingFlows = []; 167 | this.lastRequest.resolvedFlows = []; 168 | } 169 | return req; 170 | } 171 | 172 | /** 173 | * Run pre-process request 174 | * @param plugins Context plugin 175 | * @param req 176 | * @param ctx 177 | */ 178 | private async preProcessRequest(req: Request, ctx: Context) { 179 | const postProcessing: PluginCallback[] = []; 180 | const activatedPlugins: PluginCallback[] = []; 181 | const plugins = [...ctx.plugins.keys()]; 182 | 183 | plugins 184 | .forEach(x => { 185 | // check context conditional plugin for activation 186 | const info = ctx.plugins.get(x) as Struct; 187 | for (const cond of info.conditions) { 188 | if (!utils.evaluate(cond, req.contexts)) { 189 | return false; 190 | } 191 | } 192 | 193 | // deconstruct group of plugins from (struct:head) 194 | info.head.forEach(plugin => { 195 | // Normalize plugin name 196 | const vPluginName = `plugin:${plugin.replace(/\s+/g, '')}`; 197 | if (ctx.directives.has(vPluginName)) { 198 | this.logger.debug('context plugin is activated: [%s]', vPluginName); 199 | const pluginHandler = ctx.directives.get(vPluginName)?.value as PluginCallback; 200 | activatedPlugins.push(pluginHandler); 201 | } else { 202 | this.logger.warn('context plugin not found: [%s]!', vPluginName); 203 | } 204 | }); 205 | }); 206 | 207 | // fire plugin pre-processing 208 | for (const plugin of activatedPlugins) { 209 | this.logger.debug('plugin fire: %s', plugin.name); 210 | const vPostProcessing = await plugin(req, ctx); 211 | if (typeof vPostProcessing === 'function') { 212 | postProcessing.push(vPostProcessing); 213 | } 214 | } 215 | 216 | return postProcessing; 217 | } 218 | 219 | /** 220 | * Run post-process request 221 | * @param plugins context plugin 222 | * @param req 223 | * @param ctx 224 | */ 225 | private async postProcessRequest(plugins: PluginCallback[], req: Request, ctx: Context) { 226 | // post-processing 227 | for (const plugin of plugins) { 228 | await plugin(req, ctx); 229 | } 230 | } 231 | 232 | /** 233 | * Test & apply conditional dialogue 234 | * @param req 235 | * @param ctx 236 | */ 237 | private async applyConditionalDialogues(req: Request, ctx: Context): Promise { 238 | if (req.isNotResponse) { 239 | this.logger.info('Bot has no response! Conditions will not be applied.'); 240 | return req; 241 | } 242 | this.logger.info('Evaluate conditions for dialogue:', req.currentDialogue, req.contexts); 243 | let conditions: string[] = []; 244 | const dialog = ctx.getDialogue(req.currentDialogue) as Struct; 245 | if (dialog) { 246 | conditions = dialog.conditions; 247 | } 248 | 249 | // support original conditions 250 | if (req.currentDialogue !== req.originalDialogue && ctx.dialogues.has(req.originalDialogue)) { 251 | conditions = conditions.concat((ctx.dialogues.get(req.originalDialogue) as Struct).conditions); 252 | } 253 | 254 | const dialogConditions = conditions 255 | // filter only conditional reply dialogue 256 | .filter(x => { 257 | // pattern ensures valid syntax: expr => action 258 | const match = REGEX_COND_REPLY_TESTER.exec(x) as RegExpExecArray; 259 | if (!match) { 260 | this.logger.debug('Not a conditional reply:', x); 261 | return false; 262 | } else { 263 | return true; 264 | } 265 | }) 266 | .map(x => { 267 | // Re-run tester to get verify expression 268 | const match = REGEX_COND_REPLY_TESTER.exec(x) as RegExpExecArray; 269 | // split exactly supported conditions 270 | const tokens = x.split(REGEX_COND_LAMDA_EXPR); 271 | let type = match[1]; 272 | const expr = tokens[0].trim(); 273 | let value = tokens[1].trim(); 274 | // New syntax support 275 | // https://github.com/yeuai/botscript/issues/20 276 | if (type === '=') { 277 | this.logger.info('New syntax support: ' + x); 278 | const explicitedType = value.charAt(0); 279 | if (REGEX_COND_REPLY_TOKEN.test(explicitedType)) { 280 | type = explicitedType; 281 | value = value.slice(1).trim(); 282 | } else { 283 | // default type (a reply) 284 | // ex: * expression => a reply 285 | // or: * expression => - a reply 286 | type = Types.ConditionalReply; 287 | } 288 | } 289 | // e.g. a conditional reply to call http service 290 | // * $reg_confirm == 'yes' @> register_account 291 | // * $name == undefined -> You never told me your name 292 | return { type, expr, value }; 293 | }) 294 | .filter(x => { 295 | const vTestResult = utils.evaluate(x.expr, req.contexts); 296 | this.logger.info(`Evaluate test: ${vTestResult} is ${!!vTestResult} | ${x.expr} => ${x.type} ${x.value}`); 297 | return vTestResult; 298 | }); 299 | 300 | this.logger.info('Conditions test passed:', dialogConditions); 301 | 302 | for (const x of dialogConditions) { 303 | if (x.type === Types.ConditionalForward) { 304 | // conditional forward 305 | if (ctx.dialogues.has(x.value)) { 306 | req.isForward = true; 307 | req.isFlowing = false; 308 | this.logger.info('Redirect dialogue to:', x.value); 309 | req.currentDialogue = x.value; 310 | this.machine.resolve(req, ctx); 311 | } else { 312 | this.logger.warn('No forward destination:', x.value); 313 | } 314 | } else if (x.type === Types.ConditionalFlow) { 315 | let vIsAddedFlow = false; 316 | const flow = x.value; 317 | if ( 318 | req.resolvedFlows.indexOf(flow) < 0 319 | && req.missingFlows.indexOf(flow) < 0 320 | && !req.isFlowing 321 | ) { 322 | this.logger.info('Add conditional flow: ', flow, req.resolvedFlows); 323 | req.missingFlows.push(flow); 324 | vIsAddedFlow = true; 325 | } 326 | 327 | if (vIsAddedFlow) { 328 | req.isFlowing = true; 329 | req.currentFlowIsResolved = false; 330 | // req.currentFlow = req.missingFlows.find(() => true) as string; 331 | this.logger.debug('Resolve conditional flow of current dialogue: ' + req.currentDialogue); 332 | this.machine.resolve(req, ctx); 333 | } 334 | 335 | } else if (x.type === Types.ConditionalReply) { 336 | // conditional reply 337 | const reply = x.value; 338 | this.logger.info('Populate speech response, with conditional reply:', req.message, reply); 339 | // speech response candidate 340 | req.speechResponse = reply; 341 | } else if (x.type === Types.ConditionalPrompt) { 342 | // conditional prompt 343 | this.logger.debug('Get prompt definition:', x.value); 344 | if (ctx.definitions.has(x.value)) { 345 | req.prompt = (ctx.definitions.get(x.value) as Struct).options; 346 | } else { 347 | this.logger.warn('No prompt definition:', x.value); 348 | } 349 | } else if (x.type === Types.ConditionalCommand) { 350 | // conditional command 351 | if (ctx.commands.has(x.value)) { 352 | const command = ctx.commands.get(x.value) as Struct; 353 | 354 | try { 355 | // execute commands 356 | this.logger.debug('Execute command: ', x.value); 357 | const result = await utils.callHttpService(command, req); 358 | 359 | // append result into variables 360 | this.logger.debug('Append command result into variables:', x.value); 361 | this.emit('command', null, { req, ctx, result, name: command.name }); 362 | if (!Array.isArray(result)) { 363 | // backwards compatibility. 364 | // TODO: Remove in version 2.x 365 | Object.assign(req.variables, result); 366 | } 367 | Object.assign(req.variables, { [command.name]: result }); 368 | } catch (err) { 369 | this.logger.info('Cannot call http service: ', command); 370 | this.emit('command', err, { req, ctx, name: command.name }); 371 | } 372 | } else { 373 | this.logger.warn('No command definition:', x.value); 374 | this.emit('command', 'No command definition!', { req, ctx, name: x.value }); 375 | } 376 | } else if (x.type === Types.ConditionalEvent) { 377 | // conditional event 378 | this.logger.debug('Emit conditional event:', x.value); 379 | this.emit(x.value, req, ctx); 380 | } else { 381 | this.logger.warn('Unknow condition type:', x.type, x.expr, x.value); 382 | } 383 | 384 | } 385 | return req; 386 | } 387 | 388 | /** 389 | * Generate speech response 390 | * @param req 391 | * @param ctx 392 | */ 393 | private populateReply(req: Request, ctx: Context): Request { 394 | 395 | let replyCandidate = req.speechResponse; 396 | this.logger.info(`Current request: isFlowing=${req.isFlowing}, dialogue=${req.currentDialogue}, flow=${req.currentFlow}, replyCandidate=${replyCandidate}`); 397 | 398 | // no reply candidate 399 | if (!replyCandidate) { 400 | let dialog: Struct; 401 | if (!req.isFlowing) { 402 | // TODO/Refactor: Get current dialogue? 403 | dialog = ctx.dialogues.get(req.originalDialogue) as Struct; 404 | } else { 405 | dialog = ctx.flows.get(req.currentFlow) as Struct; 406 | } 407 | if (dialog) { 408 | this.logger.info('Get dialogue candidate:', dialog.name); 409 | replyCandidate = utils.random(dialog.replies); 410 | } else { 411 | this.logger.info('No dialogue population!'); 412 | } 413 | } else { 414 | this.logger.info('Populate already candidate:', req.speechResponse, req.contexts); 415 | } 416 | 417 | // Generate output! 418 | req.speechResponse = ctx.interpolate(replyCandidate || '[noReply]', req); 419 | this.logger.info(`Populate speech response: ${req.message} -> ${replyCandidate} -> ${req.speechResponse}`); 420 | // Add previous speech history 421 | // Since v1.6: system variables are initialized before process! 422 | req.previous.splice(0, 0, req.speechResponse); 423 | if (req.previous.length > 100) { 424 | req.previous.pop(); 425 | } 426 | 427 | return req; 428 | } 429 | 430 | /** 431 | * New request flows 432 | */ 433 | newRequest(message: string) { 434 | return createNextRequest(message, this.lastRequest); 435 | } 436 | 437 | } 438 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BotScript 2 | 3 | A text-based scripting language, dialog system and bot engine for Conversational User Interfaces (CUI) 4 | 5 | [![Join the chat at https://gitter.im/yeuai/rivebot-ce](https://badges.gitter.im/yeuai/rivebot-ce.svg)](https://gitter.im/yeuai/rivebot-ce) 6 | [![Git tag](https://img.shields.io/github/tag/yeuai/botscript.svg)](https://github.com/yeuai/botscript) 7 | [![npm version](https://img.shields.io/npm/v/@yeuai/botscript.svg?style=flat)](https://www.npmjs.com/package/@yeuai/botscript) 8 | [![npm downloads](https://img.shields.io/npm/dm/@yeuai/botscript.svg)](https://www.npmjs.com/package/@yeuai/botscript) 9 | [![Travis](https://travis-ci.org/yeuai/botscript.svg)](https://travis-ci.org/yeuai/botscript) 10 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 11 | 12 | > This is a part of project [yeu.ai](https://github.com/yeuai). An open platform for experiment and training Vietnamese chatbot! 13 | 14 | # Documentation 👋 15 | 16 | Here is a list of resources to get you started 17 | 18 | - 👯 [Read the wiki](https://github.com/yeuai/botscript/wiki) for all the details on how to get started playing with BotScript 19 | - 🤔 [Read API References](https://github.com/yeuai/botscript/wiki/API-References) to start coding 20 | - 💬 [Playground](https://yeuai.github.io/botscript/) to say hello? 21 | 22 | # Specification 23 | 24 | To get started playing with BotScript, you must follows the following rules: 25 | 26 | ## definition 27 | 28 | A `definition` is an identifier of an entity, a group, a list or a property. 29 | 30 | The syntax start with symbol `!`: 31 | 32 | ```bash 33 | ! name 34 | - CuteBot 35 | ``` 36 | 37 | The `CuteBot` is value instance of the property `name`. Think of bots with many names! 38 | 39 | To define a list of items, just enter item in a new line which started with symbol `-`: 40 | 41 | ```bash 42 | ! colors 43 | - red 44 | - green 45 | - blue 46 | ``` 47 | 48 | Also, you can define intents by data samples as following: 49 | 50 | ```bash 51 | ! intent:goodbye 52 | - bye 53 | - goodbye 54 | - see you around 55 | - see you later 56 | - talk to you later 57 | 58 | ! intent:ask_identity 59 | - who are you 60 | - what is your name 61 | - how should i address you 62 | - may i know your name 63 | - are you a bot 64 | ``` 65 | 66 | ## comment 67 | 68 | Comments make your code clearer, you can add a comment in BotScript document by starting the symbol `#` at the begining of a line or followed by a space: 69 | 70 | ```bash 71 | # here is a comment 72 | # here is an other 73 | ``` 74 | 75 | ## continuation 76 | 77 | The continuation allows the code to break to span multiple of lines. In the case, you can write a really long reply or prompt. 78 | 79 | A continuation must start with symbol `^` at the beginning of the line. 80 | 81 | Example: 82 | 83 | ```bash 84 | + tell me a joke 85 | - As a scarecrow, people say I'm outstanding in my field. 86 | ^ But hay - it's in my jeans. 87 | - I told my girlfriend she drew her eyebrows too high. 88 | ^ She seemed surprised. 89 | - I have kleptomania. 90 | ^ But when it gets bad, I take something for it! 91 | ``` 92 | 93 | ## dialogue 94 | 95 | A dialogue is a piece of conversation that human and bot interact with each other. 96 | 97 | A dialogue must contains a `+` line, that defines a pattern can activate the bot to respond. This line also called with other name **trigger**. 98 | 99 | A dialogue also must contains a `-` line, that defines a pattern response which is output to reply to human. 100 | 101 | A dialogue must have at least one reply and one trigger. 102 | 103 | ```bash 104 | + message pattern 105 | - message reply 106 | ``` 107 | 108 | Example: 109 | 110 | ```bash 111 | + hello bot 112 | - Hello, human! 113 | ``` 114 | 115 | A dialogue may contains: 116 | 117 | - triggers 118 | - replies 119 | - flows 120 | - conditions 121 | - variables 122 | - commands 123 | - prompts 124 | 125 | ## triggers 126 | 127 | A trigger is a pattern help bot knows what human is saying. 128 | 129 | A trigger begins with symbol `+` in the dialogue. 130 | 131 | A trigger may contains **wildcards**, **Alternations**, or **Defintion**. With wildcards, you can set a placeholder within trigger that the bot can capture. The values matched by the wildcards can be retrieved in the responses by using the tags `$var` or values in order `$1`, `$2`, `$3`. 132 | 133 | Example: 134 | 135 | ```bash 136 | ## trigger uses asterisk as a wildcard 137 | + My name is *{name} 138 | - Nice to meet you $name! 139 | 140 | ## trigger use a parentheses and straight slash as an alternations 141 | + (hello|hallo|chào) 142 | - Hello $1 143 | 144 | ## trigger use defintion as alternations array. 145 | + my favorite color is [colors] 146 | - I like $1 too. 147 | ``` 148 | 149 | A dialogue may contains more than one trigger. Which helps bot to detect exactly in more case. 150 | 151 | ```bash 152 | + My name is *{name} 153 | + *{name} is my name 154 | - Nice to meet you $name! 155 | ``` 156 | 157 | A trigger may contains: 158 | 159 | - definitions 160 | - patterns 161 | - variable 162 | 163 | ## replies 164 | 165 | A reply begins with `-` symbol in the dialogue and goes with the trigger. If the dialogue has multiple replies then a random reply will be selected. 166 | 167 | ```bash 168 | + hello 169 | - Hello. What is your name? 170 | - Hi. Could you tell me your name? 171 | - [yes]. What's your name? 172 | ``` 173 | 174 | A reply may contains: 175 | 176 | - definitions 177 | - variables 178 | 179 | ## flows 180 | 181 | Flows are tasks which need to be resolved. A flow can used to determine a precise flow of conversation 182 | 183 | A flow must start with a `~` line, that defines the the task name. 184 | 185 | A flow contains lines started with symbol `-` to guide human answers the quiz and may contains lines `+` help the bot captures the information. If the flow does not contains `+`, after responded the flow will ends. 186 | 187 | A flow can referenced by an other. 188 | 189 | Flows are activated within a dialogue. The bot will respond if all tasks are resolved! 190 | 191 | ```bash 192 | ~ maker 193 | - What cell phone vendor? 194 | - Which brand of smartphone do you want to buy? 195 | + I want to buy *{maker} 196 | + *{maker} 197 | ``` 198 | 199 | The dialogue jumps to the splash flow then back to continue. 200 | 201 | ```bash 202 | ~ random 203 | - I am happy to hear you! 204 | - It's fine today. 205 | 206 | + hello * 207 | ~ random 208 | - Great! 209 | ``` 210 | 211 | A flow may contains: 212 | 213 | - triggers 214 | - replies 215 | - flows 216 | - conditions 217 | - commands 218 | - variables 219 | - prompts 220 | 221 | ## prompts 222 | 223 | Prompt is suggested answers which helps human quickly select and reply. 224 | 225 | Prompt must be started with symbol `?`. 226 | 227 | Prompt declared in a dialogue, flows or defined within a conditional prompt. If the conditional prompt is satisfied, the prompt in the dialogue will be overrided. 228 | 229 | If the dialogue has multiple prompts then a random one will be selected. 230 | 231 | Example: 232 | 233 | ```bash 234 | ! pizza_types 235 | - Pepperoni 236 | - Margherita 237 | - Hawaiian 238 | 239 | + I need a pizza 240 | - What kind of pizza? 241 | ? [pizza_types] 242 | ``` 243 | 244 | ## conditions 245 | 246 | A conditions begins with star symbol: `*` 247 | 248 | Syntax: `* expression` 249 | 250 | There are two categories of conditions in the dialogue: 251 | 252 | - [x] **Conditional activation**: monitoring the ability to activate the dialogue in the conversation 253 | - [x] **Conditional reply**: checking the operation process in the dialogue and ability to respond to human 254 | 255 | For example: 256 | 257 | ```bash 258 | + knock knock 259 | - who is there 260 | 261 | + * 262 | * $previous[0] == 'who is there' # must have happened 263 | * $input == 'its me' -> i know you! 264 | - $1 who? 265 | ``` 266 | 267 | A conditional reply allows bot test the conditions and do some logics before replies to human. 268 | 269 | Syntax: `* expression [type] [action]` 270 | 271 | There are six subcategories of conditional processing: 272 | 273 | - Conditional reply 274 | - Conditional flow 275 | - Conditional redirect 276 | - Conditional command 277 | - Conditional prompt 278 | - Conditional event 279 | 280 | Example: 281 | 282 | ```bash 283 | * expression => - a reply 284 | * expression => @ a command 285 | * expression => ~ a flow 286 | * expression => + a redirect 287 | * expression => * an event 288 | * expression => ? a prompt 289 | ``` 290 | 291 | A conditional reply let bot replies smarter base on the condition or pick random replies from a list definition. That means before reply bot will check its memory and create reponse if the bot knows. 292 | 293 | ```bash 294 | * $name == undefined -> You never told me your name 295 | ``` 296 | 297 | A conditional flow let bot resolves an additional task if the condition is match. 298 | 299 | ```bash 300 | * $topic == buy phone ~> ask phone 301 | ``` 302 | 303 | A conditional redirect let bot breaks current dialogue and flows to turn to other dialogue. This helps bot cancel current task and do a new one if the condition is met. 304 | 305 | ```bash 306 | * $action == cancel >> task cancel 307 | ``` 308 | 309 | A conditional prompt allows bot sending to human additional prompt list. This helps human knows how to reply to the bot after that. 310 | 311 | ```bash 312 | * $input == i dont know ?> [show the list] 313 | ``` 314 | 315 | A conditional command let bot execute an http POST request to an api endpoint with `req` data context. Once the endpoint returns json data then it will be populated before generate speech response. 316 | 317 | ```bash 318 | * $input == play music @> play favorite music 319 | * $input == confirm order @> send the order 320 | ``` 321 | 322 | A conditional event can be integrated with code instead of using `conditional command`. 323 | 324 | ```bash 325 | * $var == true +> event name 326 | ``` 327 | 328 | Example: 329 | 330 | ```bash 331 | 332 | # conditional reply 333 | + what is my name 334 | * $name == undefined -> You never told me your name. 335 | - Your name is $name! 336 | - Aren\'t you $name? 337 | 338 | # conditional flow 339 | + *advise me 340 | * $topic == buy phone ~> ask phone 341 | * $topic == ask warranty ~> warranty guide 342 | ~ ask something 343 | - You are done! Do you want ask more? 344 | 345 | # conditional redirect 346 | + i want to say *{action} 347 | * $action == cancel >> task cancel 348 | * $action == something -> [bot say something] 349 | - You said $action 350 | ``` 351 | 352 | ## commands 353 | 354 | An action command allow you to do more powerful things with bot's responses. 355 | 356 | A command must starting with the sign `@`. Followed by the command name and its API endpoint. 357 | 358 | A command can be consumed within a dialogue conditions that means the command only executes if the condition satisfied. 359 | 360 | The command will be sent with `req` data context. Once the endpoint returns json data then it will be populated before generate speech response. 361 | 362 | Syntax: 363 | 364 | ```bash 365 | @ COMMAND_NAME [POST|GET] API_ENDPOINT 366 | ``` 367 | 368 | For example, you can allow the user to ask the bot questions about the current weather or about movie ticket prices, and your bot can send an http request that goes out to the internet to fetch that information. 369 | 370 | ```bash 371 | @ geoip https://api.ipify.org/?format=json 372 | # output result: {"ip":"10.10.10.100"} 373 | 374 | + what is my ip 375 | * true @> geoip 376 | - Here is your ip: $ip. 377 | ``` 378 | 379 | ## variables 380 | 381 | What good is a chatbot if it can't even remember your name? BotScript has the capability to captures the information given by human and automatically store its into the request context. 382 | 383 | A variable appears in a dialogue in both triggers and replies. 384 | 385 | A variable is declared within parentheses: `*{var1}` to capture a string and `#{var2}` to captures a number. 386 | 387 | A variable is populated in replies by defining after `$var1` sign or within form `${var2}`. 388 | 389 | Example: 390 | 391 | ```bash 392 | + My name is *{name} 393 | - Nice to meet you $name! 394 | 395 | + I am #{age} years old 396 | - You are $age 397 | ``` 398 | 399 | System variables: 400 | 401 | - `$previous`: history of dialogue chat 402 | - `$flows`: available in context of dialogue is flowing 403 | - `$input`: human message input 404 | 405 | ## patterns 406 | 407 | A pattern within trigger which helps the dialogue `human <-> bot` can be activated and bot has a better capability to reply human. 408 | 409 | Advanced pattern helps bot exactly knows what human is saying. 410 | 411 | There are two ways add pattern capability in BotScript: 412 | 413 | - Built-in pattern capability using Regular Expression 414 | - Custom pattern capability by add new handler 415 | 416 | ### 1. Built-in pattern capability using Regular Expression 417 | 418 | Built-in pattern capability already supported in BotScript. Just declare and use basic [Regular Expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) within form: `/(\w+)\s(\w+)/` and it will capture two words `John Smith`, for example. 419 | 420 | A pattern must be wrapped in `/` to use advanced syntax which [XRegExp](http://xregexp.com/) supports. 421 | A part of a pattern can be enclosed in parentheses (...). This is called a [capturing group](https://javascript.info/regexp-groups) 422 | 423 | > Note: Pattern will capture entities in a group then it can be accessed via variables in order $1, $2, ... 424 | 425 | Example: 426 | 427 | ```bash 428 | # I like to buy it -> Do you really buy it 429 | # I really need this -> So you need this, right? 430 | + /^I (?:.+\s)?(\w+) (?:.+\s)?(it|this)/ 431 | - Do you really $1 $2? 432 | - So you $1 $2, right? 433 | ``` 434 | 435 | ### 2. Custom pattern capability by add new handler 436 | 437 | This way, bot is added a new matching handler and trying to match the input which human say, with highest priority. This feature is enabled through code integration. 438 | 439 | NLP can be integrated by this way. See [an example](./examples/nlp.js). 440 | 441 | Example: 442 | 443 | ```bash 444 | + ([ner: PERSON]+) /was|is/ /an?/ []{0,3} /painter|artist/ 445 | - An accomplished artist you say. 446 | - Yeah, i know $1! 447 | ``` 448 | 449 | By combining `NLP`, `Command Service`, `Events` you can teach the bot to be smarter. 450 | 451 | ## plugins 452 | 453 | BotScript allows to define plugins which will be activated usage if the one added via code implementation 454 | 455 | A plugin started with `>` line for pre, post-processing 456 | A plugin runs in pipeline of message request processing 457 | A plugin may contain conditional activation 458 | A plugin may be grouped in a group 459 | 460 | Syntax: 461 | 462 | ```bash 463 | > plugin name 464 | * conditional expression 465 | ``` 466 | 467 | > From `v1.6.0`: A plugin can be compiled directly in the botscript document 468 | 469 | Example: 470 | 471 | ```js 472 | /** 473 | > addTimeNow 474 | > noReplyHandle 475 | 476 | + what time is it 477 | - it is $time 478 | */ 479 | function addTimeNow(req: Request, ctx: Context) { 480 | const now = new Date(); 481 | req.variables.time = `${now.getHours()} : ${now.getMinutes()}`; 482 | } 483 | 484 | /** 485 | * plugin for post-processing 486 | * */ 487 | function noReplyHandle() { 488 | const postProcessing = (res: Request) => { 489 | if (res.message === "NO REPLY!") { 490 | res.message = `Sorry! I don't understand!`; 491 | } 492 | }; 493 | 494 | return postProcessing; 495 | } 496 | ``` 497 | 498 | ## directives 499 | 500 | **Directive** is an instruction that helps Engine understand enhanced implementation. Imagine the directive as a switch to direct action. 501 | 502 | Syntax: 503 | 504 | ```bash 505 | /directive: name 506 | - option 1 507 | - option 2 508 | ``` 509 | 510 | Available built-in supported directives: 511 | 512 | - include 513 | - nlu 514 | - format 515 | - plugin 516 | 517 | ### Directive /include 518 | 519 | Example: 520 | 521 | ```bash 522 | # import botscript document from urls 523 | /include: 524 | - url 1 525 | - url 2 526 | ``` 527 | 528 | ### Directive /nlu 529 | 530 | Example: 531 | 532 | ```bash 533 | # use your custom nlu server 534 | /nlu: name of command 535 | ``` 536 | 537 | ### Directive /format 538 | 539 | Example: 540 | 541 | ```bash 542 | /format: bold 543 | {{value}} 544 | 545 | + bold *{me} 546 | - $me :bold 547 | 548 | # addvanced example, use handlebars syntax 549 | # format variable in population 550 | 551 | /format: list 552 | {{#each people}} 553 | {{name}} / {{age}}, 554 | {{/each}} 555 | 556 | + show my list 557 | * true @> cmd_list_patient 558 | - Here is your list: $people :list 559 | ``` 560 | 561 | ### Directive /plugin 562 | 563 | A plugin uses available context params: `({ req, ctx, utils, logger })` 564 | A plugin is compiled in the script document and one defined in a directive: 565 | 566 | Syntax: 567 | 568 | ````bash 569 | /plugin: name 570 | ```js 571 | # javascript code here; 572 | # access human request or bot context via name: req, ctx 573 | # normalize message or what you need 574 | 575 | console.log('Human say: ', req.message) 576 | 577 | return (req, ctx) => { 578 | // do post-processing. 579 | if (req.isNotResponse) { 580 | req.speechResponse = 'I dont know!'; 581 | } 582 | } 583 | ``` 584 | ```` 585 | 586 | Example: 587 | 588 | ````bash 589 | # add time now to current variable for each request 590 | /plugin: addTimeNow 591 | ```js 592 | const now = new Date(); 593 | req.variables.time = `${now.getHours()}:${now.getMinutes()}`; 594 | ``` 595 | ```` 596 | 597 | # Examples 598 | 599 | See the [`examples/`](./examples) directory. 600 | 601 | # Contributing 602 | 603 | Pull requests and stars are highly welcome. 604 | 605 | For bugs and feature requests, please [create an issue](https://github.com/yeuai/botscript/issues/new). 606 | 607 | # License 608 | 609 | BotScript is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 610 | --------------------------------------------------------------------------------